Skip to content

分代 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)120ms3ms4ms
年轻代 GC 周期 CPU 开销低(短暂 STW)高(全堆标记)中等(仅年轻代)
24h 总 GC 时间12s95s38s
内存占用基线+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 后的行为变更说明

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1