Java 垃圾回收调优:从 G1 到 ZGC

延迟敏感系统如何选择 GC?如何系统地读懂 GC 日志并做出有效调优?本文从 JVM 基础、STW 机制、垃圾回收算法、收集器演进到实战调优,给出可落地的方法与示例。

1. JVM 内存模型与 STW/Safepoint 基础

在 HotSpot 下,内存大体分为:

  • Java 堆(年轻代/老年代,G1/ZGC 采用 Region/分页结构)
  • 线程栈(每线程独立)
  • 元空间(Metaspace,用于类元数据)
  • 本地内存(如直接缓冲区、JIT 代码缓存等)

两个重要的分配/复制概念:

  • TLAB(Thread-Local Allocation Buffer):线程本地的堆分配缓冲,减少分配锁竞争。
  • PLAB(Parallel/Promotion LAB):年轻代向老年代晋升时的并行复制缓冲。

Stop-The-World(STW)是 GC 暂停所有 Java 线程的时刻。JVM 通过 Safepoint 实现可停位置控制(比如方法调用边界、循环回边、异常处理器等),在进入关键阶段(如初始标记、重新标记、对象移动/重定位)时触发短暂停顿。理解 STW 有助于判断“为什么延迟尖刺发生在这个阶段”。

观测 STW 的现代方式(JDK9+):

java -Xlog:safepoint,classhisto*=off:file=safepoints.log:tags,uptime -Xlog:gc*:file=gc.log:time,uptime,level,tags ...

关键指标:暂停时长(p95/p99/p999)、分配速率、晋升速率、RSet 扫描成本、引用处理(Reference Processing)、类卸载、字符串去重等耗时分布。

2. 垃圾回收算法与屏障技术

HotSpot 基础算法与实现要点:

  • 可达性分析:从 GC Roots(线程栈、静态引用、JNI 句柄等)做遍历。
  • 三色标记(白/灰/黑)+ 写屏障/读屏障:保证并发标记/移动时的正确性。
  • 标记-清除(Mark-Sweep):快,但会产生碎片。
  • 标记-整理(Mark-Compact):消除碎片,但需要对象移动(常伴随 STW 或并发移动)。
  • 复制(Copying):典型用于年轻代(Eden→Survivor),快且局部性好。
  • 分代假说:大多数对象“朝生夕死”,少数对象“越活越老”。

引用语义(强/软/弱/虚)与终结(finalize/Cleaner)在 GC 中有独立处理阶段。引用处理过重时常见“长尾暂停”,建议:避免 Finalizer、使用 java.lang.ref.Cleaner 并限制队列堆积。

3. 收集器演进路线

  • Serial/Parallel(Throughput 收集器)
    • 关注吞吐,允许较长 STW;适合批处理、计算密集型、少交互的服务。
    • 关键项:-XX:+UseParallelGC-XX:ParallelGCThreads-XX:NewRatio
  • CMS(Concurrent Mark Sweep)
    • 并发标记,降低暂停,但有碎片与“Concurrent Mode Failure”。
    • JDK9 起废弃,JDK14 移除。仅在历史系统中遇到,不建议新项目使用。
  • G1(Garbage-First)
    • 基于 Region 的分代收集器。分为 Young、Concurrent Marking、Mixed 周期,按收益选择回收集(Collections Set)。
    • 关键概念:Region(含 Humongous 大对象)、RSet/卡表(Remembered Set)、IHOP(Initiating Heap Occupancy Percent)。
    • 优点:更可预测的暂停目标;可并发标记与分阶段回收。
    • 常见瓶颈:RSet 扫描(卡表爆炸)、Humongous 对象回收不及时、Evacuation Failure(to-space/exhausted)。
  • Shenandoah(Red Hat)与 ZGC(Oracle)
    • 共同目标:并发压缩/移动,极低暂停(亚毫秒到个位数毫秒级)。
    • 屏障差异:Shenandoah 使用 Brooks Pointer + 写屏障;ZGC 使用“有色指针(Colored Pointers)”+ 读屏障,配合多阶段重标记/重定位。
    • ZGC 建议 JDK17+,JDK21 起支持分代 ZGC(-XX:+ZGenerational)。

4. 选择收集器的思路

  • 吞吐优先(批处理/离线计算):Parallel GC。
  • 延迟优先(在线服务/交易系统):G1(JDK11+),更高要求可选 ZGC(JDK17+/21+)。
  • 小堆(<4G)且并发不高:G1 也能给出稳定暂停;ZGC 在极小堆下优势不明显。

5. 调优方法论(可落地流程)

1) 设定 SLO:如 p99 暂停 < 200ms,或 CPU/吞吐目标。 2) 固定运行基线:容器/宿主 CPU/NUMA/THP 设置、JDK 版本、-Xms = -Xmx-XX:+AlwaysPreTouch。 3) 打开观测:GC/JFR/应用指标。

java \
  -Xms8g -Xmx8g -XX:+AlwaysPreTouch \
  -Xlog:gc*,safepoint:file=gc.log:time,uptime,level,tags \
  -XX:ActiveProcessorCount=8   # 容器 CPU 配额感知(必要时)

4) 建立压测基线:记录分配速率(Allocation Rate)、晋升速率(Promotion Rate)、混合回收频次、Humongous 分配率、RSet 扫描耗时。 5) 定位瓶颈:

  • 暂停超标多数发生在 Initial-Mark/Remark?→ 检查引用处理/类卸载/卡表维护。
  • Mixed 过于频繁?→ IHOP 偏低或老年代增长过快。
  • Evacuation Failure?→ to-space 不足,Region 预留或对象过大。 6) 逐步调整参数与代码,单一变量、对比压测,保留实验记录。

6. G1 实战参数与解释

常用启动模板(JDK11+/17+):

java \
  -XX:+UseG1GC \
  -Xms8g -Xmx8g \
  -XX:MaxGCPauseMillis=200 \
  -XX:InitiatingHeapOccupancyPercent=45 \
  -XX:G1ReservePercent=20 \
  -XX:G1HeapRegionSize=4m \
  -XX:G1NewSizePercent=20 -XX:G1MaxNewSizePercent=40 \
  -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=8 \
  -XX:+ParallelRefProcEnabled \
  -XX:+UseStringDeduplication \
  -Xlog:gc*,gc+heap=debug,gc+age=trace:file=gc.log:time,uptime,level,tags

调参要点:

  • MaxGCPauseMillis:目标暂停时间。过低会引发更频繁 GC 与更重的并发负担。
  • IHOP:老年代占用触发并发标记阈值。负载有爆发时可调高(如 45→55),减少并发标记重叠时间。
  • G1ReservePercent:预留 to-space,减少 Evacuation Failure 风险。
  • G1HeapRegionSize:Region 大小影响 Humongous 阈值(> 50% Region 即为 Humongous)。对象略大时可适当增大 Region,减少 Humongous 分配。
  • 新生代比例与晋升阈值:平衡吞吐与老年代压力,避免 Survivor 放不下导致早晋升。

常见告警与应对:

  • Mixed 过密:上调 IHOP、降低 G1MixedGCLiveThresholdPercent、限制 G1MixedGCCountTarget
  • Evacuation Failure(to-space exhausted):增大堆/预留比例、减少大对象、错峰分配高峰。
  • RSet 过大:排查跨区大量写入热点(缓存结构、共享对象),优化对象图或拆分。

7. ZGC 实战参数与解释(JDK17+/21+)

java \
  -XX:+UseZGC \
  -Xms8g -Xmx8g \
  -XX:ConcGCThreads=2 \
  -XX:ZUncommitDelay=300 \
  -Xlog:gc*,safepoint:file=gc.log:time,uptime,level,tags

要点:

  • ZGC 通过读屏障与有色指针实现并发移动,对暂停极其敏感的在线业务非常友好。
  • 内存回收的“拆借/归还”速度与分配速率密切相关。ZUncommitDelay 可控制未使用页面的回退时机。
  • JDK21+: -XX:+ZGenerational 以降低短命对象对并发标记的干扰(分代 ZGC)。

何时不必用 ZGC:小堆、高分配峰值但暂停目标在百毫秒级时,G1 往往足够且更易调参。

8. GC 日志快速解读示例(G1)

[3.456s][info][gc,start     ] GC(12) Pause Young (Normal) (G1 Evacuation Pause)
[3.456s][info][gc,heap      ] GC(12) Eden regions: 24->0(20)
[3.456s][info][gc,heap      ] GC(12) Survivor regions: 3->4
[3.456s][info][gc,heap      ] GC(12) Old regions: 120->123
[3.460s][info][gc,phases    ] GC(12) Evacuate Collection Set: 3.2ms
[3.462s][info][gc           ] GC(12) Pause Young (Normal) (G1 Evacuation Pause) 512M->498M(8G) 6.1ms

关注点:

  • 暂停类型(Young/Mixed)、暂停时长、堆前后使用、Region 变化。
  • Evacuation 耗时是否成为主因;Old 增长是否过快(晋升压力)。

更多细节可打开 gc+age=trace 观察对象年龄分布,辅助设置 MaxTenuringThreshold

9. 代码层面的可操作优化

  • 降低短命对象创建:复用 StringBuilder/ByteArrayOutputStream、批量处理、避免无谓装箱/拆箱与流式中间对象。
  • 控制大对象:避免一次性构造超大 byte[]/String,对网络/IO 使用分片与缓冲;必要时改用直接内存并限制 -XX:MaxDirectMemorySize
  • 减少跨代/跨区写入:将热点可变状态下沉到局部,避免共享大图结构被频繁修改导致卡表膨胀。
  • 善用 ThreadLocal 存放临时缓冲(谨防线程池泄漏,务必清理)。
  • 让逃逸分析生效:内联/标量替换通常受益于“简单可分析”的代码路径(避免过度反射、动态代理链)。

10. 两个简短实战案例

  • 案例 A:在线 API(G1,8C/16G)
    • 目标:p99 暂停 < 200ms;现状:Mixed 频繁、p99 380ms。
    • 调整:IHOP 45→55G1ReservePercent 10→20,限制 Humongous(将 2.5MiB 的 JSON 拼接拆分为流式写出)。
    • 结果:Mixed 降 35%,p99 降至 160ms。
  • 案例 B:低延迟撮合(ZGC,16C/32G)
    • 目标:单次暂停 < 10ms;现状:G1 在 Remark 尖刺 30ms。
    • 迁移 ZGC 并控制直接内存峰值,JDK17→21 开启 -XX:+ZGenerational
    • 结果:暂停 p99 约 1.2ms,尖刺消失;同时注意到读屏障开销,CPU 略增 4%。

11. 生产部署与容器注意事项

  • 固定堆并预触达:-Xms = -Xmx-XX:+AlwaysPreTouch,减少缺页与首次触达抖动。
  • 容器配额:JDK8u191+ 或 JDK10+ 对 cgroup 友好;必要时 -XX:ActiveProcessorCount 显式指定。JDK8 老版本需 -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap
  • 关闭透明大页(THP)、合理设置 NUMA(大型物理机)。
  • 日志与剖析:GC 日志、JFR(-XX:StartFlightRecording=...)、async-profiler 定位分配热点。

12. 快速“配方卡”

  • 吞吐优先:
    • -XX:+UseParallelGC -Xms16g -Xmx16g -XX:ParallelGCThreads=<cpu>
  • 稳定低延迟(通用在线服务):
    • -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:InitiatingHeapOccupancyPercent=45 -XX:G1ReservePercent=20
  • 极低暂停:
    • -XX:+UseZGC [-XX:+ZGenerational] -Xms/-Xmx 固定

13. 参考命令与工具

# 统一 GC 日志(JDK9+)
java -Xlog:gc*,gc+heap=debug,gc+age=trace:file=gc.log:time,uptime,level,tags ...

# 在线触发与诊断
jcmd <pid> GC.run
jcmd <pid> GC.heap_info
jcmd <pid> VM.uptime

# 快速观测分配/晋升(JDK8 仍常用)
jstat -gcutil <pid> 1000 20 | cat

如果要把一件事做对:先测量,再改变。GC 调优亦然。优先明确 SLO 与约束,打开观测,建立基线,然后用一两条假设驱动的改动去验证。让数据告诉你应该选 Parallel、G1 还是 ZGC。