GC 选型的争论:Shenandoah 还是 ZGC
我们一个时延敏感的交易网关,之前用 G1,业务高峰 P99 偶尔冲到 400 ms,排查发现是 G1 的 Mixed GC 有 100~200 ms 的停顿。组里就"换哪个低延迟 GC"吵开了:有人挺 Shenandoah,有人挺 ZGC。我干脆在预发环境把两者都跑了 48 小时压测,用数据说话。
两者的算法差异
虽然目标都是"亚毫秒级停顿、并发回收",但实现路径完全不同。
Shenandoah:Brooks 指针 + 并发压缩
Shenandoah 的核心创新是并发压缩(concurrent compaction)——在应用还在跑的时候就把存活对象搬到新位置。为了做到这点,它给每个对象加了一个转发指针(Brooks pointer),对象移动后旧地址的指针会指向新地址。读对象时要经过一层"读屏障"检查是否已被转发。代价是每个对象多一个指针的额外内存,且读屏障有运行时开销。
ZGC:着色指针 + 染色内存
ZGC(JDK 11 引入、JDK 17 成为生产可用)走的是另一条路:着色指针(colored pointers)。它把对象状态(是否被标记、是否重定位中)编码进指针本身的高位比特,配合"加载屏障(load barrier)"在解引用时修正指针。最大的好处是不需要额外的 Brooks 指针,不需要对象头部改结构,且能处理 TB 级堆。它把堆划分成多个小区域(region),并发地做标记-重定位。
# 开启 ZGC(JDK 17)
-XX:+UseZGC -XX:+ZGenerational # 17 起支持分代 ZGC(Generational ZGC)
# 开启 Shenandoah
-XX:+UseShenandoahGC -XX:ShenandoahGCHeuristics=adaptive
实测:吞吐与延迟的取舍
压测环境:JDK 17,8 核 16G,堆 12G,模拟交易网关,固定 1500 QPS,消息体含较多中文字符串(对象分配压力大)。
| 指标 | G1 | Shenandoah | ZGC |
|---|---|---|---|
| GC 最大停顿 | 180 ms | 1.8 ms | 1.2 ms |
| P99 延迟 | 392 ms | 118 ms | 96 ms |
| 吞吐(相对 G1) | 100% | 92% | 88% |
| 堆内存开销 | 基准 | +12%(Brooks 指针) | +8%(着色元信息) |
结论很清楚:低延迟目标俩都达成了,最大停顿都压到 2 ms 以内;代价是吞吐量比 G1 低 8~12%,且堆要多出约 10% 的额外开销。ZGC 在停顿和吞吐上略胜一筹,Shenandoah 紧随其后。
选型建议:看堆大小和运维成熟度
- 堆 < 几十 GB、追求极致简单:ZGC 在 JDK 17 已经生产可用,分代 ZGC(
ZGenerational)进一步降低年轻代回收代价,是我们的最终选择。 - 超大堆(百 GB 到 TB 级):ZGC 的着色指针设计对大堆更友好,停顿不随堆增长而恶化。
- 想更早用上并发压缩、且 JDK 版本受限:Shenandoah 在较早的 JDK(12+)就能用,历史兼容性更好。
- 吞吐优先、对几十毫秒停顿不敏感:其实 G1 就够,别为了低延迟牺牲那 10% 吞吐。
一个踩到的细节
ZGC 对显式 GC(System.gc())默认忽略,我们一个老依赖里调了 System.gc() 想"促进回收",在 ZGC 下毫无作用;加上 -XX:+ExplicitGCInvokesConcurrent 让它转为并发 GC 才符合预期。Shenandoah 同理要看 -XX:+DisableExplicitGC 的默认。
小结
- Shenandoah 用 Brooks 指针做并发压缩;ZGC 用着色指针 + 加载屏障,都不需要 Stop-The-World 做压缩。
- 实测两者停顿都在 2 ms 内,吞吐比 G1 低约 10%,堆多开销约 10%。
- JDK 17 下 ZGC 生产可用且略优,我们选了它;超大堆场景 ZGC 优势更明显。
- 注意
System.gc()在两者下的行为差异,避免依赖显式 GC。
那场争论最后用两张压测表平息了。GC 没有"最好",只有"对当前堆规模和时延要求最合适"。我们网关上了 ZGC 后,P99 从 392 ms 降到 96 ms,但这 10% 的吞吐损失,是用更贵的机器和更大堆换来的——选型从来是笔账。