【JVM原理详解】29-ZGC与Shenandoah-低延迟收集器
29-ZGC 与 Shenandoah - 低延迟收集器
G1 把停顿控制在了百毫秒级,对多数 Web 服务够用。但有一类场景要求个位数毫秒甚至亚毫秒停顿——高频交易、实时风控、流式计算。G1 的 STW 阶段(初始标记、重新标记、整理)在大堆上仍可能几十毫秒。ZGC(Oracle)和Shenandoah(Red Hat)把停顿推到了亚毫秒级,它们的秘密是:把整理阶段也并发化。本篇深入剖析这两款当代前沿收集器的核心技术——染色指针、读屏障、转发指针,并给出对比选型建议。
为什么需要亚毫秒级 GC
G1 的瓶颈在于对象搬运(整理)阶段是 STW 的——因为移动对象后要更新所有指向它的引用,这个"更新引用"操作必须 STW 才能保证一致性。堆越大,要更新的引用越多,停顿越长。
G1 痛点: 标记 ── 已并发化(SATB) ✓ 清除 ── 已并发化 ✓ 整理 ── STW ✗ ← 这里是停顿根源ZGC 和 Shenandoah 的共同目标是:把整理(对象搬运 + 引用更新)也并发化。两者的实现路径不同——ZGC 用染色指针 + 读屏障,Shenandoah 用转发指针 + 写屏障。
ZGC 核心技术
染色指针
ZGC 运行在 64 位系统上,一个指针 64 位,但实际寻址只用了低位(堆通常 < 4TB 或 16TB)。高位空闲——ZGC 拿来存GC 元数据,这就是"染色指针"(Colored Pointers)。
64 位指针布局(ZGC,4TB 堆): 位: 63 45 44 43 42 41 40 16 15 0 ┌─────────┬────┬────┬────┬────┬──────┬──────────┐ │ 未使用 │ F │ M0 │ M1 │ R │ 偏移 │ 地址 │ └─────────┴────┴────┴────┴────┴──────┴──────────┘ 18位 1位 1位 1位 1位 24位 16位 染色位(4 位): F = Finalizable(可回收标记) M0 = Mark 0(标记位 0) M1 = Mark 1(标记位 1) R = Remapped(重映射,表示引用已修复)核心思想:把对象的 GC 状态(是否已标记、是否需重映射)直接编码进指针,而不是存在对象头里。这样修改对象状态时不用动对象本身,只需改指针的染色位——而改指针可以并发进行(配合读屏障)。
传统方案:GC 状态存在对象头 → 修改状态要动对象 → 要 STW ZGC 方案:GC 状态存在指针 → 改状态只动指针 → 读屏障并发修复读屏障
染色指针改变了对象状态的存储位置,那应用线程读取引用时就要检查并修复指针——这就是读屏障。
// 伪代码:ZGC 读屏障(由 JIT 插入,对应用透明)ObjectreadBarrier(Objectref){if(ref==null)returnnull;if(coloredBits(ref)!=expectedColor){// 指针染色不对,说明对象已被移动或待修复ref=fixPointer(ref);// 修复为正确地址}returnref;}// 每次读引用都过读屏障Objectobj=field.bar;// JIT 插入:obj = readBarrier(field.bar);读屏障的代价是每次引用读取多几条指令,但 JIT 优化后开销通常在 5-10%。读屏障的好处是让整理可以并发——即使对象被移动了,下次读取时读屏障会自动修复指针。
并发整理流程
ZGC 的 GC 周期(极简化):
1. 并发标记 ── 找存活对象,染色指针标记 M0/M1 2. 并发转移 ── 把存活对象从旧 Region 复制到新 Region 3. 并发重映射 ── 修复指针指向新地址(染色位 R)全部并发,STW 只在每次阶段的极短同步点(几微秒到几毫秒),用于线程协调:
应用线程: ████│█████████████████│██████████████│█████████████ ↑ ↑ ↑ 同步点1 同步点2 同步点3 (~0.1ms) (~0.1ms) (~0.1ms)注意是"亚毫秒级"停顿——ZGC 的 STW 和堆大小几乎无关,因为同步点只做线程协调,不做对象扫描。这就是 ZGC 能在TB 级堆上仍保持亚毫秒停顿的根本原因。
多重映射
染色指针在高 4 位存元数据,但硬件 MMU 不认这些位——直接解引用会段错误。ZGC 用多重映射(Multi-Mapping)解决:
同一物理内存,映射到多个虚拟地址空间,每个对应不同染色: 虚拟地址视图 A (F=0,M0=0,M1=0,R=0) ──┐ 虚拟地址视图 B (F=0,M0=1,M1=0,R=0) ──┼──→ 同一物理内存 虚拟地址视图 C (F=0,M0=0,M1=1,R=0) ──┤ 虚拟地址视图 D (F=0,M0=0,M1=0,R=1) ──┘JVM 在启动时建立这些映射,对象在不同染色状态下通过不同虚拟地址访问。这依赖操作系统的mmap支持,是 ZGC 平台受限的原因之一(早期只支持 Linux x64)。
ZGC 的演进
| JDK 版本 | 状态 | 特性 |
|---|---|---|
| JDK 11 | 实验 | -XX:+UnlockExperimentalVMOptions -XX:+UseZGC |
| JDK 13 | 实验 | 增加归还未使用内存(JEP 351) |
| JDK 15 | 正式 | 移除实验标记,可直接启用 |
| JDK 16 | 优化 | 并发栈扫描(JEP 376) |
| JDK 21 | 优化 | 分代 ZGC(JEP 439) |
分代 ZGC 是重大改进——早期 ZGC 不分代,所有对象一视同仁回收,对小对象多的应用吞吐量偏低。JDK 21 的分代 ZGC 显著提升了吞吐量,缩小了和 G1 的差距。
Shenandoah 核心技术
Shenandoah 由 Red Hat 开发,2014 年立项,JDK 12 进入主线(实验),JDK 15 转正。它的目标和 ZGC 一致——并发整理,但实现路径完全不同:用转发指针(Brooks Pointer)代替染色指针。
转发指针
每个对象头额外存一个指针,指向自己的"正式副本"。对象移动时,先复制到新地址,再更新转发指针指向新地址——旧引用通过转发指针仍能找到对象。
对象头布局(Shenandoah): ┌────────────────────────┐ │ 转发指针 (Brooks) ──────┼──→ 新地址(移动后) ├────────────────────────┤ │ Mark Word │ ├────────────────────────┤ │ 实例数据 │ └────────────────────────┘对象未移动: 引用 ──→ [转发指针: 自身] [Mark] [数据] ↑ 指向自己 对象移动后: 引用 ──→ [转发指针: 新地址] [Mark] [数据(旧)] ← 旧引用 ↓ [转发指针: 自身] [Mark] [数据(新)] ← 新副本应用访问对象时,读转发指针拿到真实地址——即使对象被移动,旧引用通过转发指针仍能透明访问。这就是 Brooks Pointer 的"转发"机制。
写屏障
Shenandoah 用写屏障维护转发指针的正确性:
// 伪代码:Shenandoah 写屏障voidfieldWrite(Objectsrc,Fieldf,ObjectnewValue){Objectresolved=resolveForward(src);// 先解转发ObjectresolvedVal=resolveForward(newValue);resolved.f=resolvedVal;}每次写都要先解析转发指针,开销比 ZGC 的读屏障大(写比读少,但每次写都查)。Shenandoah 历经多次优化(JDK 13 的"负载参考屏障"、JDK 14 的弱记忆序优化),逐步降低了写屏障开销。
并发整理流程
Shenandoah 的 GC 周期:
1. 初始标记 ── STW(极短) 2. 并发标记 ── 找存活对象 3. 最终标记 ── STW,处理 SATB 队列 4. 并发清理 ── 回收无存活对象的 Region 5. 并发转移 ── 复制存活对象到新 Region,更新转发指针 6. 初始更新引用 ── STW(极短) 7. 并发更新引用 ── 修复所有指向旧地址的引用 8. 最终更新引用 ── STW(极短)STW 点有三个(初始标记、最终标记、更新引用),每个都只做线程协调,和堆大小无关。实测停顿通常在 1-10ms。
ZGC vs Shenandoah 对比
| 维度 | ZGC | Shenandoah |
|---|---|---|
| 整理机制 | 染色指针 + 读屏障 | 转发指针 + 写屏障 |
| 状态存储 | 指针高位(染色位) | 对象头(转发指针) |
| 堆开销 | 指针高位,无额外 | 每对象多一个指针(约 4-12% 堆) |
| 读开销 | 读屏障(5-10%) | 极低 |
| 写开销 | 极低 | 写屏障(较大,但优化后可接受) |
| 平台 | Linux/macOS x64/AArch64 | Linux x64/AArch64 |
| JDK 11 | 实验 | 不支持(Red Hat 移植版) |
| JDK 15 | 正式 | 正式 |
| JDK 17 | 正式 | 正式 |
| 分代 | JDK 21 起分代 | JDK 17+ 逐步分代 |
| 厂商 | Oracle | Red Hat |
| 超大堆(TB级) | 优秀(堆无关停顿) | 良好 |
| 开箱即用 | 好(JDK 15+ 直接启用) | 好(JDK 15+ 直接启用) |
选型建议
需求 → 推荐 ───────────────────────────────────────────── 超低延迟 + 大堆(> 32GB) → ZGC 超低延迟 + 中小堆 → ZGC 或 Shenandoah 低延迟 + 高吞吐平衡 → G1(仍是默认最优) 批量/吞吐优先 → Parallel JDK 8 遗留 → CMS(已废弃,尽快升级)两者差异不大时优先 ZGC,原因:
- Oracle 主推,生态支持更广。
- JDK 21 分代 ZGC 性能提升显著。
- 读屏障比写屏障对常见应用更友好(读多写少)。
Shenandoah 的优势在于分代更早成熟(JDK 17 就有较好分代支持)和对 NUMA 的亲和性。在 Red Hat OpenJDK 发行版上,Shenandoah 是一等公民。
代码示例:启用与观察
/** * 演示 ZGC 行为 * 适用 JDK 15+(JDK 11-14 需加 -XX:+UnlockExperimentalVMOptions) * * 运行: * java -Xms4g -Xmx4g -XX:+UseZGC * -XX:ZAllocationSpikeTolerance=2 * -Xlog:gc*=info -cp MyApp ZgcDemo */publicclassZgcDemo{staticfinalint_1MB=1024*1024;publicstaticvoidmain(String[]args)throwsException{java.util.List<byte[]>list=newjava.util.ArrayList<>();for(inti=0;i<1000;i++){list.add(newbyte[256*1024]);if(i%50==0)list.subList(0,list.size()/2).clear();Thread.sleep(1);}}}ZGC 日志:
[0.234s][info][gc,start] GC(0) Garbage Collection (Warmup) [0.234s][info][gc,task] GC(0) Using 4 workers [0.235s][info][gc,phases] GC(0) Pause Mark Start 0.023ms [0.235s][info][gc,phases] GC(0) Concurrent Mark 1.234ms [0.236s][info][gc,phases] GC(0) Pause Mark End 0.018ms [0.236s][info][gc,phases] GC(0) Concurrent Process Non-Strong 0.123ms [0.236s][info][gc,phases] GC(0) Concurrent Reset Relocation Set 0.045ms [0.237s][info][gc,phases] GC(0) Pause Relocate Start 0.019ms [0.237s][info][gc,phases] GC(0) Concurrent Relocate 0.234ms [0.237s][info][gc] GC(0) Garbage Collection (Warmup) 1500M->800M(4096M) 0.234ms关注几个点:
Pause Mark Start、Pause Mark End、Pause Relocate Start:这是 STW 点,每个都在0.0x 毫秒级别。Concurrent Mark、Concurrent Relocate:并发阶段,应用不停。- 总停顿:
0.234ms——这是在 4GB 堆上的表现,即使堆涨到 100GB,停顿仍在这个量级。
Shenandoah 日志(对比):
[0.234s][info][gc,start] GC(0) Pause Init Mark [0.235s][info][gc] GC(0) Pause Init Mark 0.234ms [0.235s][info][gc] GC(0) Concurrent marking [0.236s][info][gc,start] GC(0) Pause Final Mark [0.237s][info][gc] GC(0) Pause Final Mark 0.456ms [0.237s][info][gc] GC(0) Concurrent cleanup 1 [0.238s][info][gc] GC(0) Concurrent evacuation [0.240s][info][gc] GC(0) Pause Init Update Refs 0.023ms [0.240s][info][gc] GC(0) Concurrent update references [0.241s][info][gc] GC(0) Pause Final Update Refs 0.034msShenandoah 的 STW 点更多(5 个),但每个都极短。整体停顿在 1ms 量级,和 ZGC 在同一档次。
适用场景
ZGC / Shenandoah 适合
- 超大堆(32GB-16TB):停顿与堆无关,TB 级堆也能亚毫秒停顿。
- 极低延迟:交易、风控、游戏服务器,要求 P99 < 10ms。
- 延迟敏感的微服务:即使 GC 也不影响 SLA。
- 大数据 / 内存数据库:堆大且要交互式查询。
暂不适合
- 小堆(< 2GB):G1 已足够,ZGC/Shenandoah 的元数据开销不划算。
- 吞吐量优先的批处理:Parallel 仍是吞吐之王。
- JDK 8 遗留系统:两者都不支持 JDK 8,需升级。
实践要点
1. 启用方式
# JDK 11-14(实验)java-XX:+UnlockExperimentalVMOptions-XX:+UseZGC-cpMyApp com.example.Main# JDK 15+(正式)java-XX:+UseZGC-cpMyApp com.example.Main# Shenandoah(JDK 15+)java-XX:+UseShenandoahGC-cpMyApp com.example.Main2. 验证是否生效
java-XX:+PrintFlagsFinal-version2>&1|grep-E"UseZGC|UseShenandoah"确认UseZGC = true才是真的启用了,某些发行版(如 Oracle JDK)可能不包含 Shenandoah。
3. JDK 21 分代 ZGC 是分水岭
JDK 21 前 ZGC 不分代,吞吐量比 G1 低 10-30%。JDK 21 后分代 ZGC显著提升吞吐量,成为生产可用选择。升级到 JDK 21 是启用 ZGC 的好时机。
# JDK 21 分代 ZGC(默认启用)java-XX:+UseZGC-Xlog:gc*=info-cpMyApp com.example.Main# 日志会显示 "Generational ZGC"4. 容器化部署注意
ZGC/Shenandoah 需要正确的内存感知。在容器中:
java-XX:+UseZGC\-XX:+UseContainerSupport\-XX:MaxRAMPercentage=75\-cpMyApp com.example.Main-XX:MaxRAMPercentage限制堆占容器内存的比例,避免被 OOM Kill。
5. 监控停顿
用 JFR(Java Flight Recorder)记录 GC 事件:
java-XX:StartFlightRecording=duration=60s,filename=rec.jfr\-XX:+UseZGC-cpMyApp com.example.Main然后用 JMC 分析停顿分布,确认是否达到 SLA。
6. 不要过度调优
ZGC/Shenandoah 设计为"开箱即用",默认参数已针对多数场景优化。生产中通常只需设-Xmx和-XX:MaxGCPauseMillis(ZGC 可选),其余让 JVM 自适应。
小结
- ZGC用染色指针(指针高位存 GC 状态)+读屏障实现并发整理,停顿与堆大小无关,可达亚毫秒级,适合 TB 级超大堆。
- Shenandoah用转发指针(Brooks Pointer,对象头指向正式副本)+写屏障实现并发整理,停顿通常 1-10ms。
- 两者核心突破:把 G1 中 STW 的对象整理阶段并发化,通过屏障机制让引用修复与应用线程并发进行。
- JDK 11实验,JDK 15转正,JDK 21分代 ZGC 是生产可用的里程碑。
- 适用场景:超低延迟(< 10ms)、超大堆(> 32GB);小堆和吞吐优先场景仍用 G1/Parallel。
- 选型:两者性能接近时优先 ZGC(Oracle 主推、分代成熟);Red Hat 环境可用 Shenandoah。
下一篇我们将进入实战——如何读懂 GC 日志、用工具分析、通过真实案例完成一次完整的 GC 调优。
更多内容:JVM调优实战
