ZGC 分代回收深度解析:为什么它是低延迟服务的首选 GC

从一次线上毛刺说起

很多团队第一次认真考虑换 GC,不是因为看了某篇技术博客,而是因为线上出了事。

ZGC 分代回收深度解析:为什么它是低延迟服务的首选 GC

典型场景是这样的:一个交易量不大的微服务,平时接口 P99 在 20ms 以内,突然某一刻飙到 800ms 甚至超时。排查后发现既不是数据库慢查询,也不是下游依赖超时,而是 GC 停顿——G1 的 Mixed GC 在堆涨到 16GB 后,单次停顿动辄 100ms 以上,赶上 Full GC 更是灾难。更头疼的是,这个服务对延迟极其敏感,哪怕 50ms 的毛刺都会触发上游熔断。

这类问题的根因不复杂:G1 虽然在设计上追求可预测停顿,但它的标记、转移、引用处理仍然有大量 STW(Stop-The-World)阶段,停顿时间随活对象数量增长。当堆变大、对象存活率高时,G1 的停顿目标几乎无法守住。

而 ZGC 从设计第一天起就奔着一个目标去的:把 GC 停顿压到亚毫秒级,且与堆大小完全解耦。JDK 21 引入分代回收后,ZGC 不仅在延迟上保持了原有优势,吞吐量和内存效率也大幅提升,成为低延迟 Java 服务的事实首选。

ZGC 的底层武器:染色指针与读屏障

要理解 ZGC 为什么能做到亚毫秒停顿,绕不开两个核心机制——染色指针和读屏障。它们不是两个独立功能,而是一套协同体系:染色指针把 GC 状态编码进指针本身,读屏障则在每次对象访问时解读这些状态并自动参与 GC 过程。

染色指针:把 GC 状态塞进地址高位

传统的 GC 标记方式要么在对象头里写标记位,要么维护独立的 bitmap。ZGC 走了一条不同的路——直接复用 64 位虚拟地址中空闲的高位来编码 GC 状态。

在 Linux x86-64 平台上,虚拟地址实际只用低 46–48 位寻址,ZGC 拿出高 4 位作为状态位:

  • Marked0 / Marked1:交替使用,表示对象在当前 GC 周期中是否已标记
  • Remapped:表示指针是否指向对象的最新物理地址(即转移后是否已更新)
  • Finalizable:表示对象仅通过 finalizer 可达(JDK 18 起已标记为废弃)

这意味着任何一个 Object 引用的底层 long 值,本身就携带了当前对象所处的 GC 阶段信息。提取真实地址只需位运算,状态判断也是零延迟的位与操作。

读屏障:每次对象访问都是 GC 参与点

染色指针只有被正确解读才有意义。ZGC 在 JIT 编译期就把一小段逻辑内联到每次从堆中加载对象引用的位置——这就是读屏障。它的工作流程大致是:

// 伪代码:读屏障的核心逻辑
Object load_barrier(ObjectRef ref) {
    if (ref.color == GOOD_COLOR) {
        return ref;  // 快速路径,绝大多数情况走这里
    }
    // 慢速路径:指针可能过期
    if (ref.is_relocated()) {
        ObjectRef new_ref = forwarding_table.lookup(ref);
        new_ref.color = GOOD_COLOR;
        atomic_update_reference(ref_slot, new_ref);  // 指针自愈
        return new_ref;
    }
    // 如果对象还没被转移,但颜色不对,参与并发标记
    mark_object(ref);
    return ref;
}

快速路径只有一次比较和一个分支预测,在 JIT 内联后几乎可以忽略不计。这也回应了一个常见误区——读屏障的开销远没有想象中大,常规业务场景下吞吐量损耗在 5%–15% 之间,换来的却是消除了绝大部分 GC 停顿。

堆多重映射:让旧地址仍然有效

ZGC 在转移对象时不会立即更新所有指向旧地址的引用,而是通过操作系统的虚拟内存多重映射机制,把同一物理页映射到三个不同的虚拟地址视图,分别对应 Marked、Remapped 等颜色。这样旧指针仍然能访问到对象,读屏障在检测到颜色过期时再按需修正——这种”自愈”行为避免了引用更新风暴,尤其利于高分配率场景。

一个副作用是系统工具(如 toppmap)可能报告 ZGC 进程的虚拟内存远超实际物理内存,看到几十甚至上百 GB 的虚拟内存占用不必慌张,这是多重映射的正常表现。

为什么需要分代:不分代 ZGC 的瓶颈在哪里

从 JDK 11 到 JDK 20,ZGC 一直采用不分代设计——所有对象不分年龄混在一个堆里,每次 GC 都要扫描整个堆的对象图。这个设计在延迟表现上无可挑剔,但在吞吐量和内存效率上有明显短板。

核心问题在于弱代际假说(Weak Generational Hypothesis):绝大多数对象朝生夕死,只有少数对象长期存活。不分代 ZGC 把大量短命对象和长期存活对象一视同仁地标记、扫描,白白浪费了大量 CPU 周期。一个 API 网关类服务,每秒可能创建数十万个临时对象(请求上下文、中间结果、序列化缓冲区),其中 95% 以上在下次 GC 前就已经不可达。但非分代 ZGC 不得不在全堆范围内处理它们。

具体来说,非分代 ZGC 面临三个瓶颈:

  • 每次 GC 周期的并发标记需要遍历全堆对象图,活对象越多标记开销越大
  • 转移集选择(Relocation Set Selection)需要评估全堆所有 Region,即使大部分 Region 里都是垃圾
  • 元数据结构(转发表、标记位等)按全堆规模维护,大堆场景下内存开销显著

这就是 JDK 21 引入分代 ZGC 的根本动机——不是要改掉 ZGC 的低延迟特性,而是要在保持低延迟的前提下,让 GC 的工作面聚焦到真正需要回收的区域。

分代 ZGC 做了什么:从全堆扫描到分代回收

分代 ZGC(通过 -XX:+ZGenerational 开启,JDK 21 起为默认)把堆划分为年轻代和老年代,各自维护独立的回收策略。但要注意,它和 G1 的分代实现方式完全不同——ZGC 的分代是建立在染色指针和读屏障基础上的并发分代,而不是 G1 那种以 STW 为主的分代。

年轻代回收:高频、轻量、不碰老年代

年轻代由一组 ZPage 组成,主要存放刚分配的对象。年轻代 GC 的典型特征是:

初始标记阶段只扫描年轻代相关的 GC Roots 加上跨代引用(通过记忆集定位),不需要遍历老年代对象图。并发标记和转移也局限在年轻代 Region 范围内。这使得年轻代 GC 的工作量大幅缩小,停顿时间进一步压缩——实测 99% 的停顿稳定在 200–400 微秒区间。

写屏障与记忆集:分代的关键代价

分代 ZGC 引入了一个非分代版本没有的机制——写屏障(Store Barrier)。当老年代对象引用年轻代对象时(即 oldObj.field = youngObj),写屏障会拦截这次赋值,把老年代对象所在内存页标记为”脏卡”(Dirty Card)。年轻代 GC 时只需扫描这些脏卡区域,而不需要遍历整个老年代来找跨代引用。

写屏障和记忆集是分代 GC 的标配,G1、Shenandoah 也都有类似机制。区别在于 ZGC 的写屏障是在染色指针体系上增量实现的,与读屏障共享部分基础设施,避免了重复的屏障开销。

记忆集(Remembered Set)是写屏障记录结果的聚合结构——以年轻代 Region 为键,记录”哪些老年代卡页可能指向它”。这样年轻代 GC 开始前,GC 线程直接扫描记忆集指向的脏卡,过滤出真正有效的跨代引用,加入根集合,避免漏标。

晋升与老年代回收

对象在年轻代经历一定次数的回收后晋升到老年代,大对象可以直接分配到老年代。ZGC 会根据分配速率动态调整代边界,不需要手动设置年轻代大小。老年代回收保持原有的并发能力——标记、转移、引用处理全部并发执行,只在几个同步点做极短的 STW。

分代后整体效果如何?根据公开基准测试数据,相比非分代 ZGC:

指标 非分代 ZGC 分代 ZGC(JDK 21)
平均 GC 停顿 ~1ms 以内 0.2–0.5ms
99% 停顿 ~1ms 200–400μs
吞吐量 基准 提升 20%–50%
堆元数据开销 较高(全堆维护) 降低约 40%
写屏障开销 有(跨代引用记录)
适用堆范围 几百 MB–16TB 同左

停顿时间和内存开销的改善来自一个简单的事实:年轻代 GC 只需要扫描小部分堆,CPU 开销大幅降低,GC 线程占用减少,留给了应用线程更多执行时间。

ZGC vs G1 vs Shenandoah:到底选谁

目前 Java 生态中低延迟方向上三个主要竞争者是 ZGC、Shenandoah 和 G1。它们的定位差异很明显,选错比不选更糟糕。

维度 G1 Shenandoah ZGC(分代)
平均停顿 100–200ms <10ms <1ms
最大停顿 500ms+ <20ms <5ms
停顿与堆大小关系 正相关 弱相关 几乎无关
吞吐量 中偏高 中(分代后大幅提升)
核心技术 Region + SATB Brooks 指针 + 读写屏障 染色指针 + 读/写屏障
大堆支持 建议 <32GB 支持但非主打 原生支持到 16TB
成熟度 JDK 9+ 默认 JDK 12+ 生产可用 JDK 15+ 生产可用,JDK 21 分代

选型的核心判断不在于谁更强,而在于你的服务对延迟的真实容忍度:

  • 延迟要求在百毫秒以内、吞吐优先的中后台服务——G1 仍然是合理选择,尤其堆小于 32GB 时
  • 延迟要求在 10ms 级别、需要全平台支持——Shenandoah 更合适,尤其在非 x86 平台上
  • 延迟要求在毫秒甚至亚毫秒级别,或堆超过 32GB——ZGC 是目前唯一可靠的选择

一个实际场景:某支付风控服务跑在 64GB 堆上,要求接口 P99 不超过 10ms。G1 在堆使用率超过 70% 后 Mixed GC 停顿频繁突破 200ms,Shenandoah 能压到 10ms 以内但偶发 15–18ms 的毛刺,切换到分代 ZGC 后 P99 停顿稳定在 1ms 以下。

落地实践:从切换到稳定运行

启动参数:从简到精

分代 ZGC 的好消息是调优门槛很低。JDK 21+ 上最简配置只需要三个参数:

# JDK 21+ 启用分代 ZGC(分代为默认,ZGenerational 可省略)
-XX:+UseZGC -XX:+ZGenerational
-Xms16g -Xmx16g

# 可选:设置并发 GC 线程数(默认等于 CPU 核心数的 1/4 左右)
-XX:ConcGCThreads=4

# 可选:开启 GC 日志(生产环境建议至少开启摘要级别)
-Xlog:gc*:file=/var/log/gc/gc.log:time,uptime,level,tags:filecount=5,filesize=20m

大多数场景下不需要太多调参。ZGC 的设计哲学是”开箱即用”,堆大小设好后基本可以放手不管。但有几个实践要点值得注意。

避免踩的坑

坑一:把 -Xms-Xmx 设成不同的值。 ZGC 支持动态扩缩堆,但在生产环境中频繁扩缩堆会带来不必要的停顿和内存碎片。建议两者设成相同值,让 ZGC 在固定堆上运行。

坑二:在 JDK 21 上误关分代模式。 有些从 JDK 17 迁移过来的团队习惯性地加 -XX:-ZGenerational 回到非分代模式,这会丢掉 20%–50% 的吞吐提升。除非有明确的兼容性原因,否则不要关闭。

坑三:忽视 Unsafe 和 JNI 的直接内存操作。 ZGC 的读屏障假设所有对象引用都经过 JVM 的引用加载路径。如果代码中通过 Unsafe.getLong 或 JNI 直接读取对象地址绕过了读屏障,可能漏标或读到已转移的旧地址。排查时留意第三方库是否使用了这类 API。

监控什么指标

切换到 ZGC 后,监控重点应该从”GC 停顿时长”转移到”分配速率”和”晋升速率”上。GC 停顿本身已经不是主要矛盾,但高分配速率会导致年轻代 GC 频繁触发,虽然每次停顿很小,但累积的 CPU 占用仍然影响吞吐。

建议关注以下指标:

  • 分配速率:正常服务在 100MB/s–500MB/s,超过 1GB/s 说明存在对象分配热点
  • 晋升速率:大量对象快速晋升到老年代通常意味着年轻代过小或对象生命周期被拉长
  • 并发标记线程 CPU 占比:如果 GC 线程持续高 CPU,说明分配速率过高或存活对象过多

可以用 JFR(Java Flight Recorder)采集这些数据,配合 GC 日志做综合分析。

分代 ZGC 适合什么场景,不适合什么场景

分代 ZGC 的优势在高分配率、短生命周期对象密集的服务上最为明显。典型场景包括:

  • API 网关和BFF层服务——每秒大量请求、大量临时对象、延迟敏感
  • 实时风控和反欺诈——需要在毫秒内完成决策,GC 毛刺直接影响业务结果
  • 金融交易撮合——对尾延迟零容忍,P99.9 的毛刺都可能导致损失
  • 大堆缓存服务——几十甚至上百 GB 堆的缓存服务,传统 GC 在这个规模上停顿难以接受

但也有不适合的情况。如果服务是 CPU 密集型的计算任务(如图像处理、批量数据转换),分配速率低且对象生命周期长,ZGC 的吞吐优势发挥不出来,G1 甚至 Parallel GC 可能更合适。另外,如果堆很小(如 2GB 以下),非分代 ZGC 或 G1 的差异不明显,切换的收益相对有限。

还有一个常被忽略的因素:ZGC 目前仅支持 64 位平台,且对操作系统有要求。Linux x86-64 是最佳支持平台,macOS 和 Windows 也已支持但生态成熟度略低。如果你的服务部署在 ARM 平台上,Shenandoah 的覆盖面更广。

结语

分代 ZGC 不是银弹,但它确实是目前 Java 低延迟服务在 GC 层面的最优解。它的价值不在于把停顿从 100ms 降到 1ms 这个数字本身,而在于让堆大小不再是延迟的制约因素——你可以放心地把堆开到几百 GB,不用再在内存容量和 GC 停顿之间做痛苦的权衡。

从工程实践角度看,如果团队在 JDK 17 及以上、运行的是 I/O 密集型或请求密集型的延迟敏感服务,升级到 JDK 21 并开启分代 ZGC 几乎是一个零成本高收益的改动。核心参数只有三个,迁移兼容性良好,监控指标也从”GC 停顿是不是又超了”变成了”分配速率是不是过高”——这个转变本身就说明问题被解决到了什么程度。

当然,GC 永远不是性能优化的终点。切换 ZGC 解决了停顿问题之后,对象分配热点、大对象分配、JNI 内存泄漏这些更深层的性能问题才会真正浮出水面。但至少,你不用再半夜被 GC 告警叫醒了。

原创文章,作者:,如若转载,请注明出处:https://fudengji.cn/article/306/

(0)
上一篇 2天前
下一篇 2天前

相关推荐