JDK JDK 学习手册8 → 27 · JDK 专用学习站
DEEPDIVE · RUNTIME · JEP 439

分代 ZGC:把停顿压到亚毫秒

JDK 21 · 深钻主题 04 / 08 · 预计 50 分钟
进阶四步深钻JEP 439
一句话JDK 21 的 ZGC 增加了分代模式:年轻代频繁回收、老年代偶尔回收,在保持亚毫秒级停顿的同时显著改善吞吐。它是大堆、低延迟服务的选项,不是所有场景的默认答案。
本质:ZGC 用着色指针和读屏障把标记、转移、重定位都做成并发;分代再用「朝生夕死」的假设减少每次要扫描的范围,让低延迟不再以吞吐为代价。

学完你要能自己回答

  1. ZGC 的停顿为什么能做到亚毫秒?哪些阶段仍然是 STW?
  2. 分代 ZGC 相比非分代 ZGC,解决了什么吞吐问题?
  3. 着色指针和读屏障分别负责什么?为什么 ZGC 不支持压缩 oops?
  4. JDK 21 里如何开启分代 ZGC?JDK 23 之后有什么变化?
  5. 什么情况下不应该选 ZGC,而应该继续用 G1?
01 分代假设:大多数对象活不过第一次 GC02 三个心智锚点

分代假设:大多数对象活不过第一次 GC01

弱分代假说(Weak Generational Hypothesis)说:绝大多数对象朝生夕死。把新对象放在年轻代,频繁、廉价地回收它们;少数晋升到老年代的对象再偶尔扫描。G1、Parallel、CMS 都建立在这个假设上,ZGC 在 JDK 21 也补上了。

非分代 ZGC 每次都要扫描整个堆。堆越大,并发标记和转移的总工作量越大,虽然停顿仍然很短,但吞吐和分配停滞会变差。分代 ZGC 让年轻代独立回收,老年代只在需要时处理,减少了每次的工作量。

非分代 ZGC(JDK 21 默认的 ZGC 模式)分代 ZGC(-XX:+ZGenerational)
回收范围每次整个堆年轻代频繁;老年代按需
停顿亚毫秒亚毫秒
吞吐大堆下并发工作量大通常更好,尤其是高分配率
内存开销较低需要 remembered set 追踪跨代引用
开启方式-XX:+UseZGC-XX:+UseZGC -XX:+ZGenerational
分代 ZGC 的目标不是「更低的停顿」——两者停顿都在亚毫秒级——而是让 ZGC 在保持低停顿的同时,吞吐更接近 G1。

三个心智锚点02

锚点一:ZGC 的并发不是「无停顿」

标记、转移、重定位大部分与应用线程并发执行;但根扫描、某些阶段的初始/最终同步仍需要短暂的 STW。ZGC 的卖点是停顿与堆大小基本无关,通常小于 1ms,而不是绝对零停顿。

锚点二:着色指针把状态编码进地址

ZGC 在 64 位指针的高位中编码对象的标记/转移状态(colored pointers)。应用线程读取引用时,读屏障检查颜色,必要时修正指针或协助转移。因为地址位被占用,ZGC 不能使用压缩 oops,需要 64 位平台。

锚点三:分代增加的是「跨代引用管理」

年轻代回收不需要扫描整个老年代,但必须知道哪些老年代对象引用了年轻代。ZGC 用 remembered set 记录这些跨代引用;写屏障/读屏障在并发期间维护它。这个额外记账就是分代带来的开销。

01 着色指针 + 读屏障:并发转移的关键02 哪些阶段仍然 STW03 分代模式下的年轻代与老年代

着色指针 + 读屏障:并发转移的关键01

传统 GC 在转移对象时,需要暂停应用线程,否则应用可能读到旧地址。ZGC 的做法是:对象地址带颜色,读屏障在每次加载引用时检查颜色;如果对象已被转移,就返回新地址,并在必要时帮助完成转移。

  1. 标记阶段:并发遍历对象图,把可达对象标记为 live。
  2. 转移准备:选择要回收的区域(region),计算转移集。
  3. 并发转移:把存活对象复制到新区域,旧地址通过转发表(forwarding table)指向新地址。
  4. 重映射:修正指向旧地址的引用,使它们直接指向新地址。

读屏障的代价是每次引用加载都有少量指令开销,换来的是应用线程几乎不用停。分代模式在此之上增加跨代引用的记账。

ZGC 不是「零成本」:读屏障会降低峰值吞吐,内存占用也高于 G1。低延迟是用吞吐和内存换来的。

哪些阶段仍然 STW02

阶段是否 STW说明
初始标记(Initial Mark)是,极短扫描根集合,时间与根数量相关,与堆大小关系小
并发标记(Concurrent Mark)否与业务线程并发
并发转移准备否选择 region
并发转移(Relocate)否复制存活对象
最终标记/重映射同步是,极短收尾同步,通常远小于 1ms

因此「ZGC 停顿小于 1ms」指的是这些 STW 阶段的总和,而不是整个 GC 周期。GC 总时长仍可能达到数百毫秒,只是应用线程几乎一直在跑。

分代模式下的年轻代与老年代03

  • 年轻代回收:频繁发生,只扫描年轻代和 remembered set 中的跨代引用,成本低。
  • 老年代回收:当老年代占用达到阈值时触发,工作量更大,但频率低。
  • 晋升:对象在年轻代存活足够多次后晋升老年代;大对象可能直接分配在老年代。
  • remembered set:记录老年代到年轻代的引用,避免年轻代回收时扫描整个老年代。

分代 ZGC 不再使用多重映射(multi-mapped memory)来访问堆,这让它在容器和某些平台上更友好,也减少了虚拟地址空间占用。

01 开启与观测02 选型:什么时候用 ZGC03 可运行示例:制造分配压力

开启与观测01

# JDK 21:显式开启分代 ZGC
java -XX:+UseZGC -XX:+ZGenerational -Xmx8g -Xlog:gc*:file=gc.log:time,uptime,level,tags -jar app.jar

# 查看最终生效的 GC
java -XX:+PrintFlagsFinal -version | grep -E "UseZGC|ZGenerational"

# 容器中建议显式设置堆上限,避免按宿主机内存计算
java -XX:MaxRAMPercentage=75 -XX:+UseZGC -XX:+ZGenerational -jar app.jar
  • JDK 21 中 -XX:+UseZGC 默认仍是非分代模式,必须加 -XX:+ZGenerational 才启用分代。
  • JDK 23 起分代模式成为 ZGC 默认(JEP 474),-XX:+ZGenerational 变为废弃选项;迁移时注意版本差异。
  • ZGC 需要 64 位平台,不支持压缩 oops;不要同时配置与之冲突的 G1/Parallel 参数。

选型:什么时候用 ZGC02

场景推荐理由
堆 32GB+、P99 延迟敏感分代 ZGC停顿与堆大小基本无关
堆小于 4GB、追求吞吐G1ZGC 的屏障和内存开销不划算
批处理、离线计算Parallel / G1吞吐优先,停顿不敏感
容器内存受限、延迟一般G1G1 更成熟,内存开销更低
需要稳定亚毫秒停顿分代 ZGC低延迟是硬指标时

选型不要只看「谁的停顿最低」。先明确延迟目标、堆大小、分配率和吞吐要求,再用真实流量压测。GC 日志里的 Allocation Stall、Pause 分布、并发周期耗时比参数名更有说服力。

可运行示例:制造分配压力03

import java.util.ArrayList;
import java.util.List;

public class ZgcDemo {
    public static void main(String[] args) {
        List<byte[]> live = new ArrayList<>();
        for (int i = 0; i < 200; i++) {
            byte[] block = new byte[1_000_000];
            if (i % 10 == 0) {
                live.add(block); // 保留一部分,制造晋升
            }
            if (i % 50 == 0) {
                System.out.println("allocatedMB=" + (i + 1));
            }
        }
        System.out.println("liveMB=" + live.size());
    }
}
javac ZgcDemo.java
java -XX:+UseZGC -XX:+ZGenerational -Xmx256m -Xlog:gc ZgcDemo
allocatedMB=1
allocatedMB=51
allocatedMB=101
allocatedMB=151
liveMB=20
[0.123s][info][gc] GC(0) Garbage Collection (Warmup) ...
[0.456s][info][gc] GC(1) Garbage Collection (Allocation Rate) ...

日志中的具体时间和 GC 次数因机器而异。重点观察:停顿时间是否稳定在亚毫秒、是否出现 Allocation Stall、年轻代回收频率是否过高。把 -Xlog:gc 换成 -Xlog:gc* 可以看到分代与阶段细节。

01 先猜再看02 费曼自检:机制与选型

先猜再看01

先猜再看ZGC 停顿小于 1ms,是不是意味着一次 GC 总共只花 1ms?
不是。1ms 指的是 STW 停顿总和;并发标记和转移可能持续数百毫秒,只是应用线程在此期间继续运行。
先猜再看堆越大,ZGC 的停顿会线性变长吗?
不会。ZGC 的 STW 阶段主要与根集合和同步收尾相关,与堆大小关系很小;堆变大主要影响并发阶段的总时长和 CPU 占用。
先猜再看分代 ZGC 一定比非分代 ZGC 快吗?
不一定。分代增加了 remembered set 的记账开销;对存活率极高、跨代引用多的极端负载,收益可能变小。但对大多数「朝生夕死」的负载,分代通常吞吐更好。

费曼自检:机制与选型02

01. 为什么 ZGC 需要读屏障?
对象可能在并发转移中被移动,应用线程读到旧地址时,读屏障根据指针颜色发现并修正到新地址,避免 STW。
02. 分代 ZGC 为什么需要 remembered set?
年轻代回收只扫描年轻代,但老年代可能引用年轻代对象;remembered set 记录这些跨代引用,保证不会漏掉存活对象。
03. JDK 21 如何确认分代 ZGC 已生效?
启动参数加 -XX:+UseZGC -XX:+ZGenerational,并用 -Xlog:gc 查看日志中的分代信息,或 -XX:+PrintFlagsFinal 检查标志。
04. 什么负载不适合 ZGC?
小堆、吞吐优先、内存受限、对 GC 内存开销敏感的批处理;这些场景 G1 或 Parallel 往往更合适。
05. ZGC 的「着色指针」和「压缩 oops」为什么不能共存?
着色指针占用了 64 位地址中的高位来编码状态,压缩 oops 则依赖把对象地址压缩到 32 位;两者对地址空间的用法冲突。
学习进度 记录保存在本机浏览器 · 不上传

JDK 学习手册 · JDK / Java 专用学习站 · 纯静态 · 双击 index.html 即可使用

本站专注 JDK 版本演进与 Java 教学,非 Oracle / OpenJDK 官方站点。版本数据来自 OpenJDK 官方 Features 表;课程示例已在对应本机 JDK 实测。构建日期 2026-11-12。