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→55,G1ReservePercent 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。