ElIauk

JVM GC 调优:G1 与 ZGC

先量再调,不要先改参数 GC 调优最常见的错误是先抄一组参数。堆大小、Region 大小、暂停目标都跟具体应用的内存分配模式强相关,别人的参数在你这里可能直接把吞吐砍掉三成。 正确的顺序是:先明确目标(是低延迟还是高吞吐),再打开 GC 日志把现状量出来,最后才动参数,并且一次只动一个。没有数据的调

评论
JVM GC 调优:G1 与 ZGC

先量再调,不要先改参数

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 大小推导)会变,同样的参数在新版本上表现可能完全不同,升级前先压一遍。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者