G1、ZGC、Shenandoah 应该如何选择?从延迟目标到生产验证
垃圾收集器选型不是“谁更新就用谁”,也不是背一张吞吐量与暂停时间对比表。真正的问题是:在给定堆大小、对象分配速率、存活集、CPU 配额和延迟 SLO 下,哪一种收集器能以可接受的资源成本稳定达标。
1. 先把业务目标翻译成 GC 指标
选型前至少确认:最大堆、常态与峰值分配速率、对象存活率、可用 CPU、容器内存上限、允许的 p99/p999 暂停,以及吞吐下降能否接受。
例如“接口不能卡”无法测试,下面的目标才可以:
堆:16 GiB
峰值分配速率:2.5 GiB/s
业务 p99:150 ms
GC 单次暂停 p99:小于 20 ms
CPU 配额:8 核
峰值持续时间:15 分钟
GC 日志中的暂停并不等于请求延迟,但长暂停会直接推高尾延迟。应用锁竞争、数据库、Safepoint 到达耗时也会制造类似现象,因此不能只看平均 GC 时间。
2. G1:分区化的代际收集器
G1 把堆切成多个 Region,优先回收收益较高的区域。年轻代回收会 Stop-The-World,老年代标记大部分并发进行,之后通过 Mixed GC 同时回收年轻代和部分老年代。
它的优势是成熟、代际假设有效、吞吐表现通常较好,并能通过暂停目标影响每轮回收工作量:
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-Xlog:gc*,safepoint:file=gc.log:time,uptime,level,tags
MaxGCPauseMillis 是软目标,不是承诺。对象复制量过大、巨型对象、引用处理、记忆集更新或 evacuation failure 都可能突破目标。若堆余量太小,并发标记来不及完成,G1 甚至可能退化到代价很高的 Full GC。
G1 通常适合数 GB 到数十 GB 堆、吞吐与延迟都重要、暂停容忍度在几十到数百毫秒的通用服务。
3. ZGC:把绝大多数重工作移出暂停
ZGC 通过有色指针、加载屏障和并发转移等机制,让标记、重定位等主要工作与应用线程并发进行。它的核心价值是暂停时间通常不会随堆大小线性增长,适合大堆和严格尾延迟目标。
版本演进必须说清楚:JDK 21 提供分代 ZGC,但需要显式开启;JDK 23 将分代模式设为默认,JDK 24 移除了非分代 ZGC。因此 Java 21 与 Java 25 的参数和日志不能混着解释。JDK 25 上通常只需:
-XX:+UseZGC
-Xlog:gc*,safepoint:file=gc-zgc.log:time,uptime,level,tags
低暂停并不免费。并发回收需要 CPU,屏障会产生运行时开销,且必须保留足够堆余量,让回收速度追上分配速度。当容器 CPU 被严格限流、分配速率极高时,ZGC 可能出现 allocation stall;这时增加堆只是延后问题,仍要减少分配或增加回收资源。
ZGC 更适合数十 GB 甚至更大堆、要求毫秒级暂停、愿意用部分吞吐和资源换取尾延迟稳定性的服务。
4. Shenandoah:并发压缩的另一条路线
Shenandoah 同样将对象转移与引用更新并发化,目标也是让暂停与堆大小弱相关。它使用转发指针与读/写屏障完成并发疏散,并提供不同启发式策略。
-XX:+UseShenandoahGC
-Xlog:gc*,safepoint:file=gc-shenandoah.log:time,uptime,level,tags
其可用性取决于具体 JDK 发行版:不是每个厂商或构建都包含 Shenandoah。JDK 25 引入了实验性的分代 Shenandoah,不能把它与成熟的非分代模式或某个发行版的回移植版本混为一谈;实验能力进入生产前还需检查供应商支持承诺。选择它之前要确认生产 JDK、长期支持策略、容器镜像以及诊断工具链一致。
5. 不要只凭收集器标签做判断
| 维度 | G1 | ZGC | Shenandoah |
|---|---|---|---|
| 主要定位 | 吞吐与可预测暂停平衡 | 极低暂停、大堆 | 极低暂停、大堆 |
| 代际能力 | 成熟 | JDK 23+ 默认,JDK 24+ 仅分代 | JDK 25 提供实验性分代模式,发行版可能不同 |
| 并发转移 | 否,疏散阶段暂停 | 是 | 是 |
| CPU/屏障代价 | 相对较低 | 较明显 | 较明显 |
| 发行版覆盖 | 广 | 主流 OpenJDK 可用 | 需确认发行版 |
| 常见风险 | 长 Young/Mixed GC、退化 Full GC | allocation stall、CPU 不足 | degenerated/full GC、CPU 不足 |
表格只能用来提出候选。真实结果还受 JDK 版本、对象图、NUMA、容器限额和应用代码影响。
6. 设计公平的选型压测
压测必须固定应用版本、JDK、机器、流量模型和堆大小,仅替换收集器及必要参数。至少覆盖:
- 常态流量足够长时间运行;
- 超过预期峰值的持续压力;
- 缓存预热和冷启动;
- 批任务、全量查询、大报文等最坏路径;
- 容器 CPU 限流和实例滚动发布;
- 故障恢复后的流量回灌。
同时记录业务 p50/p99/p999、吞吐、错误率、CPU、RSS、分配速率、GC 暂停分位数、并发周期、GC 后存活集和 Safepoint。不要用几分钟的平均值掩盖周期性退化。
7. 常见错误调优
- 为追求“零 GC”把堆设得极大,结果故障现场 dump 困难、容器成本上升。
- 看到 G1 长暂停就无限降低
MaxGCPauseMillis,导致回收更频繁且吞吐下降。 - 使用 ZGC 后只看暂停,不看 CPU、allocation stall 与业务吞吐。
- 从旧博客复制大量实验性参数,忽略参数在新 JDK 中已废弃或语义改变。
- 堆上限贴近容器内存上限,未给 Metaspace、线程栈、直接内存和本地库留空间。
8. 一个实用决策路径
如果当前 G1 已满足 SLO,就没有必要仅为“先进”切换。若主要问题是大堆下偶发长暂停,先确认不是内存泄漏、巨型对象或 CPU 饥饿;修复应用问题后,再把 ZGC 或 Shenandoah 纳入同机压测。
若延迟目标在几十到数百毫秒、吞吐和成本更重要,优先从 G1 开始。若堆很大且暂停必须稳定在毫秒级,优先验证 ZGC;已有特定发行版和 Shenandoah 经验的团队也可将其作为候选。
9. 上线检查清单
- 明确业务延迟 SLO,而非只定义 GC 目标。
- 使用生产相同的 JDK 发行版与容器限制压测。
- 给并发收集器预留 CPU 和足够堆余量。
- 监控暂停分位数、分配速率、存活集和退化事件。
- 灰度期间保留同流量的旧收集器实例作为对照。
- 准备快速回滚参数,并保留完整 GC 日志。
收集器没有绝对排名。G1、ZGC 和 Shenandoah 分别代表不同的资源交换方式:用暂停换吞吐,或用 CPU 与屏障成本换低暂停。可靠选型的终点不是参数表,而是生产流量模型下可重复的证据。