JVM 内存结构与 GC 调优:从 900ms 到 180ms
堆调到 8G 停顿反而变长?从分代布局讲清四类 GC 症状的判别方式,再用一次真实调优过程说明参数该怎么推。
线上一个服务开始频繁 Full GC,平均每 40 秒一次,每次停顿 800ms,接口 P99 从 120ms 涨到 900ms。运维的第一反应是把堆调大——从 4G 调到 8G,停顿反而变成 1.6 秒,因为它把更多的垃圾攒到一次回收。
这是 GC 调优里最反直觉的一点:堆越大,单次 Full GC 的停顿越长。调优的目标从来不是「让堆够大」,而是「让垃圾在年轻代就死掉」。
这篇文章从 JVM 内存结构的实际布局讲起,然后给出四类症状对应的参数组合,最后是一个完整的分代调优过程。
一、先把内存结构画清楚
JVM 的内存不是一块,是好几块:
┌─────────────────────────────────────────────────────────┐
│ 堆(-Xmx / -Xms) │
│ ┌──────────────┬──────────────┬──────────────────────┐ │
│ │ Eden │ Survivor S0 │ Survivor S1 │ │ ← 年轻代 -Xmn
│ │ (8/10) │ (1/10) │ (1/10) │ │
│ └──────────────┴──────────────┴──────────────────────┘ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 老年代(Old Gen) │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
┌──────────────┐ ┌────────────┐ ┌──────────────┐ ┌────────┐
│ Metaspace │ │ 线程栈 │ │ Code Cache │ │ 直接 │
│ (方法区) │ │ (每线程1M) │ │ (JIT 编译) │ │ 内存 │
└──────────────┘ └────────────┘ └──────────────┘ └────────┘
↑ 这些都不在堆里,但算进容器内存
三个关键事实:
- Eden : S0 : S1 默认 8:1:1,年轻代总共可用的是 Eden + 一个 Survivor = 9/10。所谓「Survivor 空间不足」就是指这 1/10 装不下存活对象。
- Metaspace 在堆外,默认无上限(卸载不掉的类会一直涨)。
-Xmx只限制堆,容器 limit 还包括后面四块。这正是上一篇OOMKilled的主要成因。
二、先搞清楚你是哪种「慢」
GC 导致的慢分四类,对应的处理完全不同。不要先改参数,先分类。
| 症状 | 观测数据 | 根因 | 方向 |
|---|---|---|---|
| A. 周期性长停顿 | Full GC 频繁,老年代回收后仍占 60%+ | 内存泄漏或晋升过快 | 查泄漏 / 调分代 |
| B. 高频短停顿 | Young GC 每 1~2 秒一次,单次 20ms | 年轻代太小 | 加年轻代 |
| C. 单次超长停顿(>2s) | Full GC 单次 3s+ | 堆太大 / 用错收集器 | 换 G1/ZGC |
| D. 长时间卡顿但 GC 日志正常 | GC 耗时占比 <5% | 不是 GC 问题 | 查锁、IO、DB |
判断 D 类特别重要。 很多「以为是 GC 问题」的排查最后发现是下游超时。先用 GC 日志算出 STW 总时长占比:
# 从 GC 日志统计总停顿时间
grep -oP 'Pause Young.*?\K\d+(?=ms)' gc.log | awk '{s+=$1} END {print "总停顿:", s/1000, "秒"}'
# 再除以运行时长,占比 <5% 就别在 GC 上花时间了
三、必须开的诊断参数
调优前先能看见。这四个参数应该进所有 Java 服务的默认配置:
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/var/log/app/gc-%t.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=20M
JDK 9+ 用统一的 -Xlog:
-Xlog:gc*,gc+heap=info,gc+age=trace:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=10,filesize=20M
gc+age=trace 非常有用,它会打印对象晋升年龄分布:
Desired survivor size 43646976 bytes, new threshold 15 (max 15)
- age 1: 18485872 bytes, 18485872 total
- age 2: 3443992 bytes, 3443992 total
...
- age 15: 128832 bytes, 128832 total
如果 age 1 的对象存活量就有 18 MB,而 Survivor 只有 43 MB,说明大量对象在第一次 GC 后还活着——它们会被反复搬运,最终晋升到老年代。这是「晋升过快」的典型证据。
四、四类症状的处置方式
A 类:内存泄漏 / 晋升过快
先确认是不是真泄漏。方法:连续三次 Full GC 后看老年代占用。
jcmd 1 GC.heap_info
输出:
garbage-first heap total 4194304K, used 3355443K [0x..., 0x...)
region size 2048K, 24 young (49152K), 1 survivors (2048K)
Metaspace used 128349K, capacity 131072K, ...
连续采样三次(间隔 2 分钟,中间手动 jcmd 1 GC.run),如果老年代 used 只涨不跌,就是泄漏。此时改参数没用,要用 MAT 找引用链:
jcmd 1 GC.heap_dump /tmp/heap.hprof
# 拉到本地用 MAT 打开,看 Dominator Tree 最大的几个对象
如果是晋升过快而非泄漏(老年代上涨但 Full GC 后能回落到 30% 以下),才轮得到调参:
-Xmn4g # 加大年轻代,让对象活到自然死亡
-XX:SurvivorRatio=8 # Eden:单个S = 8:1(默认)
-XX:MaxTenuringThreshold=15 # 提高晋升年龄(默认15)
-XX:+UseAdaptiveSizePolicy # 默认开,会动态调整 Survivor
-XX:MaxTenuringThreshold 调到 15(G1 下最大就是 15)能减少晋升,但也可能造成 Survivor 溢出转为提前晋升(premature promotion)。要看日志里的 to-space exhausted——出现这个说明 Survivor 真装不下了,该加 -Xmn 而不是调阈值。
B 类:年轻代太小
现象是 Young GC 次数极多。假设堆 8G,默认 -Xmn 是堆的 1/3 ≈ 2.7G,如果 QPS 高、每秒产生 500 MB 垃圾,那大约 5 秒就会触发一次 Young GC。
处置:加大 -Xmn 或提高 -XX:NewRatio 反向调整。但注意上界——年轻代太大会让单次 Young GC 停顿变长(复制存活对象更久)。
经验法则:单次 Young GC 停顿控制在 20~50 ms,频率 1 次/5 秒以上。
C 类:单次超长停顿
如果是 Parallel / CMS 收集器,堆超过 8G 就该换 G1:
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 # 目标停顿,G1 会据此调整年轻代大小
-XX:G1HeapRegionSize=8m # region 大小,堆 4-16G 建议 8m
-XX:G1NewSizePercent=10
-XX:G1MaxNewSizePercent=60
-XX:G1MixedGCLiveThresholdPercent=85 # 存活超过85%的region不参与混合回收
MaxGCPauseMillis 是软目标,G1 会尽力但可能达不到。设在 200ms,实际可能 150~300ms 波动。设太小(比如 50ms)会导致年轻代被压得很小,Young GC 变得频繁,吞吐反而下降。
JDK 11+ 且堆 < 16G、延迟敏感的服务,可以上 ZGC:
-XX:+UseZGC -XX:+ZGenerational # JDK 21+ 分代 ZGC
ZGC 的停顿基本在 1ms 以内,代价是吞吐量下降约 10% 和更高的内存占用(需要额外 20% 的堆做并发标记)。适合「延迟优先」的在线服务,不适合离线批处理。
D 类:不是 GC 问题
用 GC 日志证明「GC 不是瓶颈」之后,方向转向:
# 1. 线程 Dump,看是不是锁等待
jstack 1 > /tmp/thread.txt
grep -c 'BLOCKED\|WAITING' /tmp/thread.txt
# 2. 如果有 Arthas,直接看慢方法
arthas-boot > trace com.example.OrderService getOrder '#cost > 200'
经验上,接口 P99 高但 GC 正常的成因排序是:DB 慢查询 > 下游接口超时 > 线程池打满 > 锁竞争 > GC。
五、一个完整的调优过程
以那个「4G 堆每 40 秒 Full GC」的服务为例,真实过程是这样的。
第 1 步:采集基线
jstat -gcutil 1 1000 30
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 98.32 12.41 99.61 92.18 89.04 1241 12.043 38 30.412 42.455
0.00 98.32 18.22 99.63 92.18 89.04 1242 12.051 38 30.412 42.455
关键读法:
- O(老年代)99.6% → 老年代已满,这才是 Full GC 的直接原因
- FGC 38 次,FGCT 30.4 秒 → 平均每次 800ms,符合观察
- YGC 1241 次,平均 9.7ms → 年轻代回收很快,不是瓶颈
- S1 98.32% → Survivor 快满了,说明存活对象多
结论:不是年轻代问题,是老年代被塞满了。
第 2 步:确认是泄漏还是晋升过快
jcmd 1 GC.run # 手动 Full GC
jstat -gcutil 1 1000 3 # 立刻看
如果 O 从 99.6% 掉到 15%,说明堆里没有泄漏,全是垃圾,只是晋升太快。如果只掉到 85%,就是泄漏。
这个案例掉到了 12%,所以是空间配置问题。
第 3 步:定位晋升过快的证据
# 看 GC 日志里的晋升统计
grep 'to-space exhausted\|Promotion' gc.log | tail -20
发现大量 to-space exhausted——Survivor 装不下,对象直接被塞进老年代。这解释了为什么老年代涨得这么快。
第 4 步:调参并复测
# 原来
-Xmx4g -Xms4g -XX:+UseParallelGC
# 调整后
-Xmx4g -Xms4g \
-XX:+UseG1GC \
-Xmn1536m \ # 年轻代从 1.3G 提到 1.5G
-XX:SurvivorRatio=8 \
-XX:MaxTenuringThreshold=15 \
-XX:MaxGCPauseMillis=200 \
-XX:+ParallelRefProcEnabled \ # 并行处理 Reference,减少 STW
-XX:+AlwaysPreTouch # 启动时预分配页,避免运行时缺页停顿
-XX:+AlwaysPreTouch 是个容易被忽略的优化:它让 JVM 在启动时把所有堆页真正分配并触碰一遍。启动慢几秒,但运行期不会因为缺页中断出现偶发的长停顿。对延迟敏感的服务值得加。
第 5 步:结果
YGC 从 1241 次/小时 → 420 次/小时(频率降低 66%)
FGC 从 38 次/小时 → 0 次/小时
单次 Young GC 停顿 9.7ms → 38ms
P99 从 900ms → 180ms
代价是单次年轻代停顿变长了(9.7 → 38ms),但总停顿大幅下降。这是典型的用吞吐换停顿次数的取舍。
六、几个常见误判
1. System.gc() 能解决内存问题
System.gc() 触发的是 Full GC。如果对象还被引用着,GC 完也释放不掉,只是白白停顿一次。真正的内存问题只能靠消除引用。
2. 把 -Xms 设小于 -Xmx
-Xms1g -Xmx8g # 错误示范
运行期堆会动态扩张,每次扩容都可能触发 Full GC(-XX:+UseParallelGC 下叫 Ergonomics 扩容)。生产环境 -Xms 和 -Xmx 设成一样,避免动态调整带来的抖动。
3. 只看 GC 次数,不看停顿总时长
Young GC 100 次 × 5ms = 500ms 总停顿,比 Full GC 1 次 × 800ms 更好。评价指标应该是 GC 时间占比(GCT / uptime),目标是 < 5%,最好 < 1%。
4. 调大 -Xmn 一定能减少 Full GC
如果在 SurivorRatio 不变的情况下加大年轻代,Survivor 也同步变大,确实能减少晋升。但如果对象生命周期本身就很长(比如本地缓存),它们早晚会进老年代,加年轻代只是延后问题。这种情况要限制缓存大小。
七、速查:不同场景的推荐配置
| 场景 | 堆 | 收集器 | 关键参数 |
|---|---|---|---|
| 小服务(< 2G) | 1~2G | G1 | MaxRAMPercentage=70 |
| 通用 API(4~8G) | — | G1 | MaxGCPauseMillis=200 AlwaysPreTouch |
| 低延迟(P99 < 50ms) | 8~32G | ZGC | +ZGenerational |
| 离线批处理(大堆) | 16G+ | Parallel | 大堆 + 高吞吐,不追延迟 |
| 内存受限容器 | — | G1 | MaxRAMPercentage=65 留堆外空间 |
最后一句:GC 调优的本质是「让对象在年轻代死掉」。所有参数都是为这一个目标服务的。如果一个对象注定要进老年代,那要么它生命周期真的长(那就放缓存里、算好大小),要么它就是泄漏。这两种情况都不是调参数能解决的。
正在加载评论…