先量再调,不要先改参数
GC 调优最常见的错误是先抄一组参数。堆大小、Region 大小、暂停目标都跟具体应用的内存分配模式强相关,别人的参数在你这里可能直接把吞吐砍掉三成。
正确的顺序是:先明确目标(是低延迟还是高吞吐),再打开 GC 日志把现状量出来,最后才动参数,并且一次只动一个。没有数据的调优,本质上是在猜。
要量的指标只有几个:Young GC 频率与平均停顿、Full GC 是否发生、单次最长停顿、堆使用曲线的锯齿形状、以及对象分配速率。分配速率尤其重要——它决定了年轻代该多大,而很多“GC 太频繁”的问题其实是分配速率太高,不是堆太小。
G1 的三个旋钮
G1 把堆切成大小相等的 Region,年轻代是一批 Eden Region,老年代是一批 Old Region。它的三个关键参数如下:
| 参数 | 默认 | 作用与误区 |
|---|---|---|
-XX:MaxGCPauseMillis |
200 | 停顿目标,不是承诺。调得太小会让 G1 缩小年轻代,GC 次数上升,总开销反而变大 |
-XX:G1HeapRegionSize |
自动 | 1–32MB 的 2 的幂。大对象超过 Region 一半就是 Humongous,直接进老年代 |
-XX:InitiatingHeapOccupancyPercent |
45 | 触发并发标记的堆占用阈值。分配快、Humongous 多的应用要适当降低 |
还有一个经常被忽略的 -XX:G1ReservePercent(默认 10):预留的 to-space,防止晋升时没地方放而触发 Full GC。日志里出现 to-space exhausted 就是这个预留不够。
Humongous 分配是 G1 最典型的性能问题。一个 3MB 的 byte 数组在 Region 为 4MB 时不算 Humongous,Region 为 2MB 时就算。大量这种对象会导致老年代快速膨胀、并发标记频繁触发,而年轻代看起来一切正常。判断方法是看 GC 日志里 G1 Humongous Allocation 的出现次数,或者直接查 Region 分布。
ZGC 的适用边界
ZGC 用染色指针加读屏障,把标记、转移的大部分工作都放在并发阶段,停顿时间基本不随堆大小增长,通常在 1ms 以内。JDK 21 起提供了分代 ZGC,需要显式打开:
java -XX:+UseZGC -XX:+ZGenerational -Xms16g -Xmx16g \
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=100m \
-jar order-api.jar
在 JDK 23 之后,分代已经是默认行为,ZGenerational 变成了废弃参数,升级 JDK 时要注意参数是否还合法。
ZGC 不是免费的:读屏障会带来额外的 CPU 开销,吞吐通常比 G1 低几个百分点;它需要更大的堆余量(并发转移期间要留出空间);对 JNI 临界区和大量 pin 住的对象也不友好。所以判断标准很实际:
- 堆在 8GB 以下、P99 停顿目标 100ms 量级:G1 完全够用,别折腾;
- 堆在 16GB 以上、停顿要求 10ms 以内、机器核数充足:ZGC 值得上;
- 堆很小(2GB 以下):ZGC 的收益基本体现不出来,反而损失吞吐。
一套可复用的启动参数
java \
-Xms8g -Xmx8g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=150 \
-XX:G1HeapRegionSize=8m \
-XX:G1ReservePercent=15 \
-XX:+ParallelRefProcEnabled \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/app/heap.hprof \
-Xlog:gc*,gc+heap=info:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=50m \
-jar order-api.jar
-Xms 和 -Xmx 设成一样,避免运行期堆伸缩带来的额外停顿,也让容器内存预算可预测。-XX:+ParallelRefProcEnabled 能明显降低软引用、弱引用多时的 Young GC 停顿,几乎无副作用。GC 日志一定要开,事后复盘没有日志就只能靠猜。
排查步骤
# 1. 确认实际生效的 GC 与堆参数(容器里也适用)
jcmd 1 VM.flags | tr ' ' '\n' | grep -iE 'UseG1|UseZGC|MaxHeapSize|G1HeapRegionSize'
# 2. 实时看各代占用与 GC 次数,每秒一次共 10 次
jstat -gcutil 1 1000 10
# 3. 看堆内 Region 与元空间明细
jcmd 1 GC.heap_info
# 4. 从日志统计停顿分布:先看有没有 Full GC
grep -c 'Pause Full' /var/log/app/gc.log
grep -oE 'Pause Young \(G1 Evacuation Pause\).*[0-9.]+ms' /var/log/app/gc.log | tail -20
判断路径很清晰:有 Full GC 就先解决它(内存泄漏、晋升失败、Humongous 过多);没有 Full GC 但停顿超标,看是 Young GC 次数太多还是单次太长;次数多就加堆或降低分配速率,单次长就检查存活对象大小和引用处理。如果堆占用曲线在每次 Full GC 后仍然缓慢上升到同一水位,那多半是泄漏,参数调不动,需要 dump 堆去分析。
常见坑
把 MaxGCPauseMillis 当硬指标。 设置成 20ms 之后停顿没降,GC 次数翻了几倍,CPU 反而更高。
只看平均停顿。 一次 800ms 的停顿足以打穿所有超时,平均值会被大量 5ms 的 Young GC 稀释掉,必须看 P99 和最大值。
容器里不核对实际堆。 -Xmx 写了 8g,容器 limit 只有 6g,进程被 OOMKilled 而 GC 日志看起来一切正常。
ZGC 上还配 G1 的参数。 G1HeapRegionSize、MaxGCPauseMillis 对 ZGC 无效,日志里不报错但也没作用,容易误以为调优生效了。
升级 JDK 后没有重跑基线。 不同版本的 G1 默认行为(比如自适应 IHOP、Region 大小推导)会变,同样的参数在新版本上表现可能完全不同,升级前先压一遍。
评论