JVM 堆内存分配与 GC 流程
一、堆内存结构
堆(Heap)
┌─────────────────────────────────────────────┐
│ 新生代(Young) │
│ ┌──────────────┬────────┬────────┐ │
│ │ Eden │ S0 │ S1 │ │
│ │ (伊甸园) │(幸存区) │(幸存区) │ │
│ │ 占 8/10 │ 1/10 │ 1/10 │ │
│ └──────────────┴────────┴────────┘ │
│ │
│ ┌──────────────────────────────────────────┐│
│ │ 老年代(Old) ││
│ │ 长期存活的对象(Bean、连接池等) ││
│ └──────────────────────────────────────────┘│
└─────────────────────────────────────────────┘
二、对象分配与 GC 完整流程(Parallel / Serial GC)
初始状态
Eden: 空
S0: 空 ← from 区(存放上次 GC 活下来的对象)
S1: 空 ← to 区(这次 GC 要往这挪)
第 1 次:new 对象填满 Eden
① new 对象全部放在 Eden
② Eden 满了 → 触发 Minor GC
③ 暂停所有业务线程(STW)
④ 标记 Eden 里哪些对象还在被引用(还活着)
⑤ 把活着的对象 → 挪到 S0(此时 S0 是 to 区,接收方)
⑥ dead 的直接扔掉
⑦ 清空 Eden
⑧ 恢复业务线程
结果:
Eden: 空
S0: 存活对象 A(age=1) ← from(这里存着活下来的对象)
S1: 空 ← to(下次的接收方)
第 2 次:Eden 又满了
① 新对象再次填满 Eden
② 触发 Minor GC
③ STW
④ 标记 Eden 中存活的对象
⑤ Eden 存活对象 → 挪到 S1(S1 是 to 区)
⑥ S0 里的对象 A 如果还活着 → 也挪到 S1,年龄+1
如果 A 已经死了(没人引用)→ 直接扔掉
⑦ 清空 Eden + S0
⑧ 恢复业务线程
结果:
Eden: 空
S0: 空 ← to(角色互换)
S1: 存活对象 A(age=2), 新存活对象 B(age=1) ← from
第 3 次:Eden 又满了
① 触发 Minor GC
② STW
③ Eden 存活对象 → 挪到 S0(S0 现在是 to 区)
④ S1 里的 A 和 B 如果还活着 → 挪到 S0,年龄继续+1
⑤ 如果 A 年龄达到阈值(默认 15 次)→ 升到老年代,不进 S0
⑥ 清空 Eden + S1
结果:
Eden: 空
S0: A(→老年代), B(age=3), C(age=1) ← from
S1: 空 ← to
以后每次循环
Eden 满 → Minor GC →
① Eden 存活对象 → 挪到 to 区
② from 区存活对象 → 也挪到 to 区(年龄+1)
③ 年龄到阈值(默认 15)→ 升老年代
④ 死了 → 直接扔掉
⑤ 清空 Eden + from 区
⑥ S0/S1 角色互换(from ↔ to)
关键点总结
| 概念 | 说明 |
|---|---|
| from 区 | 上次 GC 存活对象所在的位置 |
| to 区 | 空的,这次 GC 要往这挪 |
| 每次 GC 完 | from 清空,from ↔ to 互换 |
| 对象年龄 | 每熬过一次 GC 就 +1 |
| 晋升阈值 | 默认 15 次(-XX:MaxTenuringThreshold) |
| S0/S1 为什么不用一个 | 避免碎片。两个区交替用,每次 to 区是紧凑排列 |
| 两个 Survivor 都放不下 | 直接升老年代(Handle Promotion Failure) |
三、Full GC 完整流程
触发条件
| 条件 | 说明 |
|---|---|
| 老年代占用 > 92%(默认) | JVM 提前触发,不等满 |
| Minor GC 前预测装不下 | 新生代要升对象,老年代剩余 < 新生代存活总量,直接 Full GC |
| Metaspace 满了 | 类定义太多,扩容失败 |
System.gc() |
代码里主动调,或 JVM 参数 -XX:+ExplicitGCInvokesConcurrent |
| CMS GC 失败(G1 的并发模式失败) | GC 线程跑得没业务线程快 |
Full GC 执行过程(以 Parallel GC 为例)
① STW(Stop The World)--- 暂停所有业务线程
↓
② 标记阶段 --- 从 GC Roots 出发,扫描整个堆
(新生代 + 老年代 + Metaspace)
标记所有存活的对象
↓
③ 清理阶段 --- 回收所有没被标记的对象
↓
④ 压缩阶段 --- 把存活对象往老年代前端搬,消除碎片
↓
⑤ 恢复业务线程
Full GC 的代价
堆大小 预计停顿时间
──────────────────────
256m ~0.1 秒
1g ~0.3-0.5 秒
4g ~1-2 秒
8g ~2-5 秒
16g ~5-10+ 秒
Full GC 期间:你的服务完全不可用,所有请求排队等 GC 结束。
所以生产上要尽量避免 Full GC。如果发生,最好是凌晨低峰期。
什么样的对象会在 Full GC 中被回收
只回收"从 GC Roots 出发找不到"的对象,不看它在哪个区。
不会被回收(引用链完整):
GC Root → ApplicationContext → Bean → 其他 Bean → 连接池
会被回收(引用链断了):
方法返回后 → 局部变量出栈 → 临时对象变成垃圾 → Full GC 看到就收
Spring 管理的 Bean、数据库连接池等,只要引用链完整,Full GC 100% 不动它们。
四、各 GC 回收器对比
| 回收器 | JDK 默认 | S0/S1 互换 | 特点 |
|---|---|---|---|
| Serial | JDK 5/6(客户端默认) | ✅ | 单线程,小堆 < 100m |
| Parallel | JDK 8 默认 | ✅ | 多线程,高吞吐,大堆 STW 长 |
| G1 | JDK 9+ 默认 | ❌ | 分区 Region,每次只清最脏的区,停顿可控 |
| ZGC | JDK 21 可用 | ❌ | 不分代,几乎不停顿 |
按场景选
| 场景 | 推荐 | 原因 |
|---|---|---|
| 普通微服务(JDK 8) | Parallel(默认不改) | 最稳 |
| 普通微服务(JDK 17+) | G1(默认不改) | 默认就是 G1 |
| 用户量大、GC 卡顿被投诉 | G1 | 控制停顿 200ms |
| 极致低延迟 | ZGC | 几乎零停顿 |
| 开发环境 IDEA 跑微服务 | G1 | 分区清理,体感卡顿少 |
五、生产中需要关注的 JVM 参数
5.1 必设参数
ini
# 堆大小(最重要的两个)
-Xms4g # 初始堆,建议 = -Xmx
-Xmx4g # 最大堆,设物理内存的 50%-70%
# 元空间
-XX:MetaspaceSize=256m # 触发 Full GC 的阈值,提前扩容到位
-XX:MaxMetaspaceSize=256m # 上限,防止类加载器泄漏撑爆内存
# 栈大小(一般不动)
-Xss1m # 每个线程 1m,线程太多时可降为 512k
5.2 堆大小计算公式
机器内存 → 分配给 JVM 堆的比例
机器内存 建议堆(单服务独占)
────────────────────────
4G -Xmx2g
8G -Xmx4g~5g
16G -Xmx8g~10g
32G -Xmx16g
剩下内存留给:操作系统 + 文件缓存 + 其他进程
多个服务分摊:
8 个微服务 + 32G 机器 → 每个 -Xmx3g
先保守,监控发现堆长期用不满再降
5.3 GC 相关参数
ini
# 选择 GC
-XX:+UseG1GC # 用 G1(JDK 9+ 默认,JDK 8 需手动加)
-XX:+UseParallelGC # 用 Parallel(JDK 8 默认)
# G1 调优
-XX:MaxGCPauseMillis=200 # 目标 GC 停顿不超过 200ms
-XX:ParallelGCThreads=4 # 并行 GC 线程数
-XX:ConcGCThreads=2 # 并发 GC 线程数(与业务线程并行跑的)
# Parallel 调优
-XX:ParallelGCThreads=4 # GC 线程数
-XX:MaxTenuringThreshold=15 # 对象升老年代年龄阈值
# 通用
-XX:CICompilerCount=2 # JIT 编译线程数(开发环境设小,省 CPU)
-XX:SoftRefLRUPolicyMSPerMB=50 # 软引用缓存回收策略,设小减少 Full GC
5.4 排查诊断参数
ini
# GC 日志(线上必开,几乎无性能损耗)
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/path/gc.log
-XX:+UseGCLogFileRotation # 日志轮转
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=10M
# OOM 时自动 dump 堆(方便事后分析)
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/path/heap.hprof
# 内存泄漏时拒绝 GC 回收
-XX:+DisableExplicitGC # 禁止 System.gc()
5.5 参数实战组合
开发环境(IDEA 跑微服务):
ini
-Xms256m -Xmx768m
-XX:+UseG1GC
-XX:CICompilerCount=2
-XX:ParallelGCThreads=4
-XX:SoftRefLRUPolicyMSPerMB=50
-XX:+HeapDumpOnOutOfMemoryError
生产环境(8G 机器单服务):
ini
-Xms4g -Xmx4g
-XX:MetaspaceSize=256m
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:ParallelGCThreads=4
-XX:ConcGCThreads=2
-XX:+HeapDumpOnOutOfMemoryError
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/app/logs/gc.log
-XX:+DisableExplicitGC
六、线上排查命令
6.1 看 GC 状态
bash
jstat -gcutil <PID> 2000
输出说明:
| 字段 | 含义 | 正常值 |
|---|---|---|
| E | Eden 使用率 | 30-80% |
| O | 老年代使用率 | 持续上升不降 → 泄漏 |
| YGC | Minor GC 次数 | 一直涨是正常的 |
| YGCT | Minor GC 总耗时 | / YGC = 单次 < 50ms |
| FGC | Full GC 次数 | 最好为 0 |
| FGCT | Full GC 总耗时 | / FGC = 单次 > 1s 说明堆太大 |
6.2 看各区用量
bash
jcmd <PID> GC.heap_info
关注:Eden、Old Gen 的 used 值,看趋势是稳还是涨。
6.3 看什么对象占内存
bash
jmap -histo:live <PID> | head -20
看排前面的类名,如果某个业务类实例数异常多,就是泄漏点。
6.4 看 JVM 参数
bash
jcmd <PID> VM.flags
确认 -Xmx、GC 类型等参数是否生效。
6.5 堆 Dump 分析
bash
# 手动触发 dump(线上谨慎,会 STW)
jmap -dump:format=b,file=heap.hprof <PID>
然后用 MAT(Eclipse Memory Analyzer)打开,看 Leak Suspects 报告。