JVM ZGC 垃圾回收器深度解析
ZGC(Z Garbage Collector)是 Oracle 于 JDK 11 引入(实验特性)、JDK 15 转正(JEP 377)、JDK 21 实现分代(JEP 439)的新一代回收器。它的核心承诺只有一条:停顿时间与堆大小、对象存活率完全无关,稳定在亚毫秒级(<1ms)——16KB 到 16TB 的堆,停顿都是同一个量级。支撑这个承诺的是三项关键技术:染色指针(Colored Pointers)、读屏障(Load Barrier)、并发整理(Concurrent Relocation)。本文从底层机制讲到分代演进,覆盖面试与实战所需的全貌。
一、ZGC 要解决什么问题
回顾回收器演进的主线矛盾:
| 回收器 | 停顿量级 | 停顿与堆大小关系 | 根本限制 |
|---|---|---|---|
| CMS | 10~200ms | 基本无关 | 碎片、Concurrent Mode Failure |
| G1 | 10~200ms | 弱相关 | Root 扫描/Remset 仍随堆增长;停顿压不过几十毫秒 |
| ZGC | <1ms | 完全无关 | 吞吐损失(读屏障+转发表);不分代时吞吐偏低 |
CMS 和 G1 的并发标记解决了"标记"的并发性,但对象搬迁(整理/复制)始终是 STW 的——因为在对象被移动、引用未被修正的中间状态里,业务线程会读到"悬空"的旧地址。这是 G1 停顿压不进 10ms 以内的根本原因(Evacuation 必须暂停所有线程一起做)。
ZGC 的回答是:把"对象移动"本身也做成并发的。业务线程与 GC 线程同时读一个正在被搬运的对象,居然不崩、不阻塞、不需要 STW——这靠的是染色指针 + 读屏障 + 转发表的组合拳。
为什么对象移动必须停顿?业务线程访问对象地址的方式:
① 通过对象引用(指针)访问 → 搬走后指针失效
② 通过对象内部的字段访问(this.field)→ 对象整体搬走后 this 也失效
CMS/G1 的解法是"停下所有人,搬完、改完所有引用再放行";ZGC 的解法是"你先搬,我每次读引用时自己发现指针过期了,顺路修复(自愈)"。
二、染色指针(Colored Pointers):在指针里藏 GC 元数据
2.1 设计思路
传统 JVM 把 GC 元数据(是否可达、是否被标记过)存在堆外结构里(卡表、位图、RSet)。ZGC 反其道而行:把标记状态直接编码进 64 位对象指针的空闲高位。
JDK 11~14 的 ZGC 指针布局(x86-64):
说明:M0 = Marked0,M1 = Marked1,R = Remapped,F = Finalizable。颜色位共 4 bit,位于 42~45 位;对象地址占低 42 位(41~0),可寻址 4TB 堆空间。
四个颜色位及其含义:
| 位 | 含义 |
|---|---|
| Marked0 / Marked1 | 对象在本轮 / 上一轮标记周期中的存活状态(两个位交替使用,实现两轮标记的无缝衔接,无需周期之间清空) |
| Remapped | 该指针已被修正为指向对象搬迁后的新地址(重定位完成) |
| Finalizable | 该指针仅被 finalizer 引用(即将死亡的"缓冲"状态,不参与正常标记) |
为什么用 Marked0/Marked1 两个交替位?ZGC 的标记周期是连续循环的,轮到下一轮时上一轮的颜色还有效(对象可能还是活的)。用两个位交替表示"本轮活"和"上轮活",周期切换时无需全局清零任何颜色状态——一次位翻转就完成换代。
2.2 多重映射与后续演进(面试加分点)
x86-64 实际只用 48 位虚拟地址。JDK 11~14 的 ZGC 把同一块物理内存映射到虚拟地址空间的三个视图(View):Marked0 视图、Marked1 视图、Remapped 视图。写指针时按颜色落到对应视图,读时按视图天然区分颜色——利用了操作系统的虚拟内存重映射机制。
JDK 15 起(JDK-8225193)移除了多重映射:改为单一映射 + 读屏障中直接校验指针颜色,好处是降低了虚拟地址空间浪费、让堆上限从 4TB 扩展到 16TB、简化了与外国工具(profiler、容器)的交互。
2.3 染色指针的本质收益
- 元数据内联:标记状态跟着指针走,无需堆外位图,访问零额外开销
- 视图即状态:对同一物理内存的多个虚拟视图,让"颜色"成为指针天然的一部分
- 为读屏障自愈提供信息载体:读屏障检查的就是这几位颜色
- 对象可以瞬间被"判活":所有指向它的指针颜色一目了然
三、读屏障(Load Barrier):ZGC 的灵魂
3.1 为什么是"读"屏障
G1/CMS 用的是写屏障(在引用赋值时拦截,维护卡表/RSet),因为它们要解决的是"引用关系变了,我该记录谁"。ZGC 要解决的是另一个问题:对象可能已被搬走,我读到的指针可能是过期的——所以拦截点必须在读引用的动作上。
每当业务线程执行从堆中加载一个对象引用(obj.field中的 field 是引用类型时),JIT 生成的代码都会插入一小段屏障:
// 伪代码 ref = obj.field; // 1. 加载引用 if (ref.color != 预期颜色) { // 2. 检查染色位(一次寄存器位测试,几条指令) ref = slow_path(ref); // 3. 过期 → 进入慢路径 } // 继续使用 ref(保证一定是"好"颜色)3.2 自愈(Self-healing)
慢路径是 ZGC 设计最精妙的部分。假设对象 X 已被 GC 线程搬到新地址,而obj.field里还存着旧地址(旧色):
- 业务线程读到旧色指针 → 触发慢路径
- 慢路径查转发表找到 X 的新地址
- 把
obj.field原地更新为新地址(好色),然后返回新地址 - 下一次再有人读
obj.field,读到的已是好色指针,零开销通过
这就是"自愈":坏指针每被读一次就被修复一次,修复成本被摊销在访问流中,且越热的字段修复得越早。整个系统不需要"停下来统一修一遍指针"。
3.3 读屏障的成本与 JIT 优化
- 每次加载堆内引用多了 ~几条机器指令(一次比较 + 分支预测几乎全命中)
- JIT 会做积极优化:同一方法内对同一引用的重复加载不重复插屏障;指针已确认为好色时不插屏障(见下述 STW 期间的"预染色")
- 实测整体吞吐损失约 5%~15%(与工作负载的指针加载密度相关)
- ZGC 中没有卡表、没有 RSet(分代前)——这些结构与写屏障的成本被整体省掉了,部分对冲了读屏障的开销
与 G1 写屏障对比(面试高频):
- 写屏障:在写时记录"引用关系变更"(服务于标记完整性,增量更新/SATB)
- 读屏障:在读时校验"指针是否过期"(服务于对象搬迁的正确性,自愈)
- 一个解决"谁引用了谁"的记录问题,一个解决"对象搬走了引用怎么办"的访问问题
四、回收周期:全程只有两个亚毫秒 STW
非分代 ZGC 的单个周期:
4.1 初始标记(Pause Mark Start)—— STW,<1ms
- 只做一件事:扫描 GC Roots(线程栈、静态变量、JNI 引用),把它们直接指向的对象标记上颜色
- 根集合数量与堆大小无关(只与线程数、栈深度相关),所以停顿与堆无关
- 结束时顺手把根直接引用的指针全部"预染色"为好色——业务线程恢复后立刻读这些对象也不会触发慢路径
4.2 并发标记 + 并发重映射(Concurrent Mark + Remap)
- 从根出发并发遍历对象图(多线程,
-XX:ConcGCThreads) - 标记过程压栈不压栈都行——ZGC 用指针颜色而不是堆外位图记录存活,天然抗并发
- 遍历到坏色指针时,读屏障的慢路径会顺路重映射:标记的同时修复过期指针(把重映射摊销进标记阶段,这是 ZGC 与众不同的地方——G1 的 Remark 是独立 STW 修指针,ZGC 借业务线程的手在线修)
- 结束后,仍然指向"已被判定死亡对象"的引用不做处理(对象死了,读不读无所谓)
4.3 最终标记(Pause Mark End)—— STW,<1ms
- 处理并发标记期间根集合的少量变更(只扫根,不扫堆)
- 统计各页面(Region/Page)的存活率,选出搬迁集(Relocation Set):存活率低、回收收益高的页面集合
- 确定下一轮的标记颜色(Marked0 ↔ Marked1 翻转)
4.4 并发搬迁(Concurrent Relocate)—— 与业务线程并发
这是 ZGC 的"魔法时刻",也是所有前代回收器必须 STW 的阶段:
- GC 线程逐个把 Relocation Set 中页面的存活对象复制到新页面
- 每搬完一个对象,在旧页面所在 ZPage 的**转发表(Forwarding Table)**里登记:旧地址 → 新地址
- 转发表按页组织(复用页面头部空间),查找是 O(1)
- 不修正任何现存引用——修正工作交给后续:
- 业务线程下次读到旧色指针时,读屏障查转发表自愈
- 下一轮并发标记(+Remap)遍历时顺路把指针改写为新地址
- 搬空的旧页面直接整体回收(归还或复用),零碎片
注意一个反直觉的设计:ZGC 不需要"记住集"来定位谁引用了被搬的对象。它根本不主动找全引用者,而是被动等人来读——读者自愈。回收整理从"全局搜捕"变成了"守株待兔",省掉了 RSet 的全部内存与维护成本。
五、停顿为什么与堆大小完全无关
这是面试必须能自圆其说的一问。ZGC 的两个 STW 阶段只做:
- 初始标记:扫根集合(线程栈 + 全局变量)
- 最终标记:再扫一遍根 + 统计页面存活率(页面级别的粗粒度统计,不是对象级)
两者都不遍历堆、不搬运对象、不修指针、不建辅助数据结构。堆从 16GB 扩到 1TB,变化的只是并发阶段的工作量——而并发阶段不产生停顿。这就是"停顿与堆无关"的严格证明。
对比 G1:G1 的 Mixed GC 停顿虽可控,但 Root 扫描和 CSet 转移仍随堆/Region 数缓慢增长,且停顿预算靠缩小回收范围兑现,压到 ~50ms 以下就开始牺牲吞吐。
六、分代 ZGC(JDK 21,JEP 439)
6.1 为什么 ZGC 也要分代
分代假设(大多数对象朝生夕死)是经验真理,但 JDK 21 之前的 ZGC只用单一分代——每个周期都并发遍历全堆,大量短命对象也被反复标记、反复搬迁,吞吐明显低于 G1。实测常见 10%~20% 的吞吐劣势,让很多大吞吐场景不敢上 ZGC。
分代 ZGC 用一套极轻的机制补上分代,同时保住了核心承诺(停顿仍 <1ms):
- 堆分为 Young Gen(Eden + Survivor)与 Old Gen
- Young GC 只遍历新生代:根 + 老年代→新生代的引用
- 老年代 GC 逻辑与非分代 ZGC 基本相同,且可以与 Young GC 并发进行(互相不阻塞)
6.2 不用写屏障,用读屏障实现"记忆集"
传统分代需要写屏障维护卡表/RSet 来记录"老年代谁引用了新生代"。分代 ZGC 的做法(面试高价值差异点):
- 老年代对象引用新生代对象时,不记录;Young GC 把这些"老年代持有者"当作根的一部分处理
- 关键技巧:读屏障兜底。若业务线程读到"老年代 → 新生代"的引用且新生代 GC 正在进行/已完成,读屏障自愈时顺路处理
- 配合新生代对象地址域与老年代分开的染色约定,Young GC 的根集合可以精确定位,不需要全堆扫
- 换来的代价:分代 ZGC 的读屏障比非分代略重(多判断一层代际关系),但仍然没有写屏障、没有卡表、没有 RSet——这是与 G1 分代实现的根本区别
6.3 效果
官方与社区基准:分代 ZGC 在保留亚毫秒停顿的同时,吞吐达到与 G1 相当(±5%)的水平。从 JDK 21 开始,-XX:+UseZGC默认开启分代(-XX:+ZGenerational,JDK 23 起非分代 ZGC 被移除,JEP 490)。
七、参数速查
| 参数 | 默认值 | 说明 |
|---|---|---|
-XX:+UseZGC | — | 启用(JDK 21+ 默认分代) |
-XX:MaxHeapSize/-Xmx | — | ZGC 需要更多堆余量:并发搬迁期间新旧对象并存 + 转发表 + 峰值流量缓冲。经验:比 G1 方案多给 20%~50% |
-XX:SoftMaxHeapSize | = 0.75×Xmx | 软上限:超过此值 ZGC 会更激进地启动周期,但不 OOM。ZGC 最有价值的调优参数之一 |
-XX:ZAllocationSpikeTolerance | 2 | 分配速率容忍系数。应用分配波动大(批处理/大促)时调大,避免分配尖峰打爆堆 |
-XX:ZCollectionInterval | 0 | 定时强制启动周期(秒)。固定节奏回收,用于对齐低峰期 |
-XX:ZFragmentationLimit | 25 | 页面碎片率超过此值才纳入搬迁候选 |
-XX:ConcGCThreads | 动态 | 并发线程数(自适应,JDK 17+ 由 JVM 按负载调节) |
-XX:ZProactive | true | 主动 GC:CPU 空闲时提前启动周期(吞吐换余量,一般保留默认) |
-XX:+UseDynamicNumberOfGCThreads | — | 自适应并发线程数 |
八、实战要点
8.1 什么时候选 ZGC
- 延迟优先:P99/P999 停顿要求 <10ms(交易、撮合、实时风控、游戏服务端)
- 大堆:32GB 以上(数十 GB 到 TB 级),G1 停顿开始抬头
- 可用内存充裕(吞吐换延迟的第一原则:ZGC 吃堆)
8.2 什么时候不选
- 堆小(<4G):开销占比过高,G1/Parallel 更合适
- 吞吐绝对优先的离线批处理:Parallel 仍然是王者
- JDK 8/11/17 运行时限制(ZGC 需 JDK 15+ 才转正、21+ 才有分代版本)
8.3 典型部署(64G 堆,撮合类服务,P999 < 5ms)
java -Xms64g -Xmx64g -XX:SoftMaxHeapSize=48g \ -XX:+UseZGC \ -XX:ZAllocationSpikeTolerance=3 \ -XX:+ZUncommit \ -Xlog:gc*:file=gc.log:time,uptime,tags:filecount=10,filesize=100m8.4 排障信号
| 现象 | 含义 | 动作 |
|---|---|---|
Allocation Stall(日志Allocation Stall) | 分配等待——堆余量不足或分配速率超预期 | 提高 Xmx / SoftMax;调大 ZAllocationSpikeTolerance;排查分配洪峰来源 |
| 高频 GC 但回收量小 | 存活对象多、分配速率高 | 分代 ZGC 已大幅缓解;检查是否缓存类泄漏 |
| 吞吐明显低于 G1 | 读屏障 + 无分代(旧版) | 升 JDK 21+ 用分代 ZGC |
下面是 ZGC 故障排查决策树,从两个典型信号出发,逐步定位根因:
8.5 与容器/监控的兼容性
染色指针要求保留高位地址位:早期(JDK 11~14)与某些观测工具/内存映射库冲突;JDK 15+ 移除多重映射后兼容性良好。容器内需注意ConcGCThreads与 CPU limit 的配合,避免并发线程抢占业务配额。
九、ZGC vs G1 vs Shenandoah
| 维度 | G1 | ZGC | Shenandoah |
|---|---|---|---|
| 停顿目标 | 10~200ms | <1ms | <10ms(宣称亚毫秒实际约几 ms) |
| 停顿与堆关系 | 弱相关 | 完全无关 | 基本无关 |
| 核心机制 | SATB + RSet + 写屏障 | 染色指针 + 读屏障 | Brooks 转发指针 + 读写屏障(后来也演进为载荷屏障) |
| 并发整理 | ✗(Evacuation STW) | ✓ | ✓ |
| 额外内存开销 | RSet(堆的 1%~20%) | 转发表 + 堆余量(隐性) | 每对象多 1 个转发指针字 |
| 支持方 | Oracle | Oracle | Red Hat(OpenJDK 社区,非 Oracle JDK 默认构建) |
| 分代 | 是(设计即分代) | JDK 21 起 | JDK 21 起实验性 |
| 适用堆 | 4~32G | 8G~16T | 8G~数百 G |
Shenandoah 与 ZGC 目标相同、路径不同:ZGC 在指针上做文章(颜色 + 转发表),Shenandoah 在对象上做文章(每个对象头部/外部挂一个永远指向自身的转发指针,读写屏障都检查它)。ZGC 的方案零对象级元数据、自愈更优雅,但强依赖平台指针布局;Shenandoah 更可移植但每对象多一个字。
十、面试高频问答
Q1:ZGC 停顿为什么能低于 1ms?
STW 阶段只做根扫描和页面统计:根集合大小与堆无关;页面统计是 Region 级粗粒度计数;标记、搬迁、指针修正全部并发,且大量修正由业务线程的读屏障顺路完成。停顿只与线程数/栈深度相关,与堆大小、对象数、存活率无关。
Q2:读屏障和写屏障的区别?
拦截点不同、目的不同:写屏障在引用赋值时触发,维护"引用关系"元数据(卡表/RSet/SATB 队列),服务于标记完整性;读屏障在加载引用时触发,校验"指针颜色",服务于对象搬迁的正确性,实现自愈。ZGC 用读屏障后完全不需要写屏障和堆外记录结构。
Q3:并发搬迁时业务线程访问被搬对象会怎样?
分三种:① 访问的是旧指针且已过期 → 读屏障慢路径查转发表取新地址并原地修复;② 对象还没被搬 → 正常访问(下次再查);③ 对象正在被搬 → 依赖虚拟内存机制(旧页面在搬迁完成前保持可读,或由读屏障引导),不会读到半成品对象。关键在"修复后写回原槽位"——自愈保证每个槽位最多慢路径一次。
Q4:Marked0/Marked1 两个颜色位为什么需要两个?
ZGC 的周期是循环无缝衔接的。用两个位交替表示"本轮/上轮存活",切换周期时只是颜色语义翻转,无需清理任何堆外状态或堆内标记——直接开始下一轮。若只有一个位,则每轮开始前要清零全局标记,就得引入 STW 或复杂并发协议。
Q5:ZGC 为什么吞吐低?如何缓解?
三个来源:① 每次引用加载多几条指令(读屏障);② 不分代时全堆并发遍历(大量死对象反复被标);③ 搬迁期间新旧内存并存,堆余量需求大。缓解:升级 JDK 21+ 用分代 ZGC(官方基准已达到 G1 ±5% 吞吐)、加大堆余量、调大 ZAllocationSpikeTolerance 应对分配尖峰。
Q6:分代 ZGC 怎么在没有写屏障/卡表的情况下做 Young GC 的根?
Young GC 的根 = 线程栈 + 全局引用(照常扫)+ 老年代指向新生代的引用(关键)。后者不靠记录,而是依靠读屏障的兜底语义 + 新生代染色约定:老→新引用指针在读时被屏障校验/修复,配合年轻代对象地址可识别(分代后地址空间按代划分),Young GC 可直接精确定位,不需全堆遍历,也不需维护反向记忆集。这是"用读屏障的天然一致性维护替代写屏障的元数据维护"的教科书案例。
Q7:ZGC 和 Shenandoah 的本质区别?
ZGC:元数据在指针上(染色位),对象搬走后靠转发表 + 指针自愈;零对象级额外内存,依赖平台指针布局。Shenandoah:元数据在对象上(转发指针),每个对象多一个指针字,访问都要走一次间接;可移植性强。工程结论:两者停顿性能同档,选择更多取决于 JDK 发行版与团队运维生态。
十一、总结
ZGC 的技术演进可以概括为一条主线:把 STW 的工作一项一项搬到并发世界。
- CMS 把"标记"并发化,但清除是并发、整理不存在 → 碎片无解
- G1 把"回收范围"碎片化(Region + Mixed GC),但 Evacuation 依然 STW → 停顿压不进几十毫秒
- ZGC 把最后的硬骨头"对象搬迁"并发化,靠染色指针 + 读屏障 + 转发表 + 自愈,让 STW 只剩根扫描 →停顿与堆彻底解耦
- 分代 ZGC(JDK 21)补上分代假设的吞吐短板,宣告"低延迟与高吞吐不可兼得"的时代结束
选型建议落到一句话:JDK 21+、堆 >32G、停顿 <10ms 是 ZGC 的主场;4~32G、停顿 100ms 级仍是 G1 的主场;吞吐型离线任务用 Parallel。面试叙事线:从"为什么对象移动必须 STW"这个根问题出发,讲读屏障自愈如何打破它——这是把 ZGC 讲透的最短路径。