分代 ZGC:把停顿压到亚毫秒
学完你要能自己回答
- ZGC 的停顿为什么能做到亚毫秒?哪些阶段仍然是 STW?
- 分代 ZGC 相比非分代 ZGC,解决了什么吞吐问题?
- 着色指针和读屏障分别负责什么?为什么 ZGC 不支持压缩 oops?
- JDK 21 里如何开启分代 ZGC?JDK 23 之后有什么变化?
- 什么情况下不应该选 ZGC,而应该继续用 G1?
分代假设:大多数对象活不过第一次 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 |
三个心智锚点02
锚点一:ZGC 的并发不是「无停顿」
标记、转移、重定位大部分与应用线程并发执行;但根扫描、某些阶段的初始/最终同步仍需要短暂的 STW。ZGC 的卖点是停顿与堆大小基本无关,通常小于 1ms,而不是绝对零停顿。
锚点二:着色指针把状态编码进地址
ZGC 在 64 位指针的高位中编码对象的标记/转移状态(colored pointers)。应用线程读取引用时,读屏障检查颜色,必要时修正指针或协助转移。因为地址位被占用,ZGC 不能使用压缩 oops,需要 64 位平台。
锚点三:分代增加的是「跨代引用管理」
年轻代回收不需要扫描整个老年代,但必须知道哪些老年代对象引用了年轻代。ZGC 用 remembered set 记录这些跨代引用;写屏障/读屏障在并发期间维护它。这个额外记账就是分代带来的开销。
着色指针 + 读屏障:并发转移的关键01
传统 GC 在转移对象时,需要暂停应用线程,否则应用可能读到旧地址。ZGC 的做法是:对象地址带颜色,读屏障在每次加载引用时检查颜色;如果对象已被转移,就返回新地址,并在必要时帮助完成转移。
- 标记阶段:并发遍历对象图,把可达对象标记为 live。
- 转移准备:选择要回收的区域(region),计算转移集。
- 并发转移:把存活对象复制到新区域,旧地址通过转发表(forwarding table)指向新地址。
- 重映射:修正指向旧地址的引用,使它们直接指向新地址。
读屏障的代价是每次引用加载都有少量指令开销,换来的是应用线程几乎不用停。分代模式在此之上增加跨代引用的记账。
哪些阶段仍然 STW02
| 阶段 | 是否 STW | 说明 |
|---|---|---|
| 初始标记(Initial Mark) | 是,极短 | 扫描根集合,时间与根数量相关,与堆大小关系小 |
| 并发标记(Concurrent Mark) | 否 | 与业务线程并发 |
| 并发转移准备 | 否 | 选择 region |
| 并发转移(Relocate) | 否 | 复制存活对象 |
| 最终标记/重映射同步 | 是,极短 | 收尾同步,通常远小于 1ms |
因此「ZGC 停顿小于 1ms」指的是这些 STW 阶段的总和,而不是整个 GC 周期。GC 总时长仍可能达到数百毫秒,只是应用线程几乎一直在跑。
分代模式下的年轻代与老年代03
- 年轻代回收:频繁发生,只扫描年轻代和 remembered set 中的跨代引用,成本低。
- 老年代回收:当老年代占用达到阈值时触发,工作量更大,但频率低。
- 晋升:对象在年轻代存活足够多次后晋升老年代;大对象可能直接分配在老年代。
- remembered set:记录老年代到年轻代的引用,避免年轻代回收时扫描整个老年代。
分代 ZGC 不再使用多重映射(multi-mapped memory)来访问堆,这让它在容器和某些平台上更友好,也减少了虚拟地址空间占用。
开启与观测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、追求吞吐 | G1 | ZGC 的屏障和内存开销不划算 |
| 批处理、离线计算 | Parallel / G1 | 吞吐优先,停顿不敏感 |
| 容器内存受限、延迟一般 | G1 | G1 更成熟,内存开销更低 |
| 需要稳定亚毫秒停顿 | 分代 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
先猜再看ZGC 停顿小于 1ms,是不是意味着一次 GC 总共只花 1ms?
先猜再看堆越大,ZGC 的停顿会线性变长吗?
先猜再看分代 ZGC 一定比非分代 ZGC 快吗?
费曼自检:机制与选型02
01. 为什么 ZGC 需要读屏障?
02. 分代 ZGC 为什么需要 remembered set?
03. JDK 21 如何确认分代 ZGC 已生效?
-XX:+UseZGC -XX:+ZGenerational,并用 -Xlog:gc 查看日志中的分代信息,或 -XX:+PrintFlagsFinal 检查标志。