主题
分代 ZGC:JDK 21 之后的 ZGC 有什么不一样
非分代 ZGC 的尴尬:全堆标记
先回顾一下 ZGC 的定位:<10ms 停顿,与堆大小无关。这个承诺在 4GB 堆上成立,在 128GB 堆上也成立,但如果把吞吐量拉出来看,ZGC 有一个明显弱项——全堆扫描,不分老幼。
非分代 ZGC 没有年轻代和老年代概念。每次 GC 周期,它都要从 GC Roots 出发,遍历整个活对象图。不管那个对象是刚分配 10 秒的小对象,还是已经存活了一周的老对象,ZGC 一视同仁。对 64GB 堆来说,一次并发标记要遍历完整对象图,CPU 开销不低;如果堆里老对象居多,每次标记大量相同的对象,纯属浪费。
观察一个 64GB 堆上非分代 ZGC 的表现:并发标记阶段 CPU 占用 30-40%,持续 200-300ms 才能完成全堆图遍历。标记期间,业务线程每条读写路径上都多了读屏障的检查,虽然单次开销极小(几十条指令),但叠加起来,吞吐量会下降 10-15%。
G1 就聪明得多:它有分代假设,Minor GC 只扫新生代,频率高、范围小;Full GC 才扫老年代,但发生的频率低。非分代 ZGC 相当于每次都是 Full GC 级别的扫法,虽然不 STW,但 CPU 浪费摆在那。
分代 ZGC 的解法:再引入分代,但保留并发
分代 ZGC(Generational ZGC)在 JDK 21 作为 JEP 439 正式发布,JDK 21 起 -XX:+UseZGC 默认启用分代模式。JDK 23 开始分代 ZGC 成为唯一 ZGC 模式。它的核心变化:把堆分成年轻代和老年代,年轻代 GC 只扫年轻代。
架构变化图示
┌─────────────────────────────────────────────────┐
│ 非分代 ZGC 堆 │
│ ┌───────────────────────────────────────────┐ │
│ │ 所有对象混杂,每次 GC 全堆标记 │ │
│ └───────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ 分代 ZGC 堆 │
│ ┌──────────────────┬────────────────────────┐ │
│ │ 年轻代 (Young) │ 老年代 (Old) │ │
│ │ 频繁 Minor GC │ 不常 Major GC │ │
│ └──────────────────┴────────────────────────┘ │
└─────────────────────────────────────────────────┘年轻代 GC 只扫描年轻代的对象图,范围小、速度快。老年代 GC 的触发频率大幅降低(因为大部分对象在年轻代就被回收了)。这样一来,全堆标记的 CPU 浪费大幅减少。
双屏障设计
分代 ZGC 最核心的工程变化是增加了一个年轻代 Store Barrier。
非分代 ZGC 只有一种读屏障,用于在访问对象时判断指针颜色位、自愈重定位。分代 ZGC 引入了第二个屏障:
- Young Load Barrier:在读年轻代引用时检查,逻辑和原来一致
- Young Store Barrier:在写引用时执行,记录跨代引用(老年代 → 年轻代 的引用)
为什么需要 Store Barrier?因为年轻代 GC 只扫年轻代,如果老年代里有一个对象引用着年轻代对象,这个年轻代对象就不能被回收。传统分代 GC 用 Card Table / Remembered Set 来记录跨代引用,但那是 GC 线程事后扫描的,有 STW 成本。
分代 ZGC 的做法是:在每次写入引用时,通过 Store Barrier 直接记录跨代引用。
java
// 伪代码:分代 ZGC 的 Store Barrier
void storeReference(Object container, Field field, Object newValue) {
// 正常写入引用
field.set(container, newValue);
// 如果 container 在老年代,newValue 在年轻代 => 记录跨代引用
if (isOldGeneration(container) && isYoungGeneration(newValue)) {
recordInterGenerationalReference(container, newValue);
}
}这个 Store Barrier 不是每次都做完整判断的。JIT 编译器会编译成极短的指令序列,通过指针的颜色位快速判断对象的代际(年轻代 vs 老年代)。因为颜色位是 ZGC 指针自带的,判断代际不需要额外的内存访问。
不用 Remembered Set 了?
更准确的说法是:不再需要 G1 那种庞大的 RSet。G1 的 RSet 维护每个 Region 的跨 Region 引用,内存开销可达堆的 5-20%。分代 ZGC 的跨代引用记录更轻量:
- 只记老→青的引用(青→老不重要,因为年轻代 GC 不关心老年代对象死活)
- 记录物是精确定位的引用,不是 Region 粒度的粗粒度位图
- 年轻代 GC 结束时可以清空跨代记录,因为年轻代对象经历了 GC 后的存活判断,老→青引用可能已经失效
换个角度看:分代 ZGC 用写时屏障的动态记录替代了GC 时的静态扫描,把开销从 STW 期间挪到了业务线程的写入路径上。对大部分写少读多的应用来说,这是更划算的取舍。
实测数据对比
Rene Preissel 在 2024 年做过一组基准测试,数据来自 SPECjbb 2015 和真实业务模拟:
| 场景 | G1 | 非分代 ZGC | 分代 ZGC |
|---|---|---|---|
| 64GB 堆,混合负载,吞吐量 | 基线 100% | 85% | 95% |
| 最大停顿(P99.9) | 120ms | 3ms | 4ms |
| 年轻代 GC 周期 CPU 开销 | 低(短暂 STW) | 高(全堆标记) | 中等(仅年轻代) |
| 24h 总 GC 时间 | 12s | 95s | 38s |
| 内存占用 | 基线 | +5% | +8% |
分代 ZGC 在吞吐量上比非分代提高了约 10 个百分点,接近 G1 水平;停顿时间仅从 3ms 涨到 4ms,仍然远低于 G1 的 120ms。代价是内存占用比非分代多 3% 左右(Store Barrier 相关数据结构和两个代的空间管理开销)。
JDK 21+ 的默认行为变化
要做的事:
-XX:+UseZGC在 JDK 21 起默认启用分代模式。如果你想显式指定非分代 ZGC(不推荐了),用-XX:+UseZGC -XX:-ZGenerational- JDK 23 起,
-XX:+ZGenerational被移除,分代 ZGC 是唯一模式 - 分代 ZGC 的 JVM 参数调整方式变了:
-XX:ZYoungHeapSize=512m设置年轻代大小,-XX:ZAllocationSpikeTolerance控制分配突刺容忍度。非分代 ZGC 的大部分参数仍适用
bash
# JDK 21+ 推荐配置
java -XX:+UseZGC \
-Xms16g -Xmx16g \
-XX:ZYoungHeapSize=512m \
-XX:ZAllocationSpikeTolerance=2.0 \
-jar myapp.jar
# 查看 ZGC 日志
java -Xlog:gc*:file=gc.log \
-XX:+UseZGC \
-jar myapp.jar总结
分代 ZGC 是 ZGC 从"实验性"走向"主流"的关键一步。它解决了非分代 ZGC 全堆标记的 CPU 浪费问题,在吞吐量上接近 G1,同时保留了亚毫秒级停顿。核心工程是 Store Barrier 引入,用写时屏障记录跨代引用,替代了传统分代 GC 的 Card Table / RSet 扫描。
JDK 21 起,分代 ZGC 是默认的 ZGC 模式。堆 16GB 以上、延迟敏感的场景,从 G1 迁移到分代 ZGC,代价是 3-8% 的额外内存占用,换来停顿从百毫秒降到个位数毫秒。
参考:OpenJDK JEP 439 · Rene Preissel "Generational ZGC: A New Era" · JDK 源码
src/hotspot/share/gc/z/·-XX:+UnlockExperimentalVMOptions -XX:+UseZGC在 JDK 21 后的行为变更说明