☰
JVM ZGC 垃圾回收器深度解析
2026/10/2 8:28:48 网站建设 项目流程

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 要解决什么问题

回顾回收器演进的主线矛盾:

回收器停顿量级停顿与堆大小关系根本限制
CMS10~200ms基本无关碎片、Concurrent Mode Failure
G110~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):

64 位对象指针(x86-64)

未使用
18 bit
63~46

M1
1 bit
45

M0
1 bit
44

R
1 bit
43

F
1 bit
42

对象地址
42 bit
41~0

说明: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里还存着旧地址(旧色):

  1. 业务线程读到旧色指针 → 触发慢路径
  2. 慢路径查转发表找到 X 的新地址
  3. 把obj.field原地更新为新地址(好色),然后返回新地址
  4. 下一次再有人读obj.field,读到的已是好色指针,零开销通过

这就是"自愈":坏指针每被读一次就被修复一次,修复成本被摊销在访问流中,且越热的字段修复得越早。整个系统不需要"停下来统一修一遍指针"。

3.3 读屏障的成本与 JIT 优化

  • 每次加载堆内引用多了 ~几条机器指令(一次比较 + 分支预测几乎全命中)
  • JIT 会做积极优化:同一方法内对同一引用的重复加载不重复插屏障;指针已确认为好色时不插屏障(见下述 STW 期间的"预染色")
  • 实测整体吞吐损失约 5%~15%(与工作负载的指针加载密度相关)
  • ZGC 中没有卡表、没有 RSet(分代前)——这些结构与写屏障的成本被整体省掉了,部分对冲了读屏障的开销

与 G1 写屏障对比(面试高频):

  • 写屏障:在写时记录"引用关系变更"(服务于标记完整性,增量更新/SATB)
  • 读屏障:在读时校验"指针是否过期"(服务于对象搬迁的正确性,自愈)
  • 一个解决"谁引用了谁"的记录问题,一个解决"对象搬走了引用怎么办"的访问问题

四、回收周期:全程只有两个亚毫秒 STW

非分代 ZGC 的单个周期:

并发搬迁(无停顿)

STW 停顿 ②(<1ms)

并发阶段(无停顿)

STW 停顿 ①(<1ms)

初始标记
Pause Mark Start
扫描 GC Roots + 预染色

并发标记 + 并发重映射
Concurrent Mark + Remap
遍历对象图 + 顺路修复过期指针

最终标记
Pause Mark End
重扫根 + 统计存活率 + 选搬迁集

并发搬迁
Concurrent Relocate
复制存活对象 + 登记转发表 + 读者自愈

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 的阶段:

  1. GC 线程逐个把 Relocation Set 中页面的存活对象复制到新页面
  2. 每搬完一个对象,在旧页面所在 ZPage 的**转发表(Forwarding Table)**里登记:旧地址 → 新地址
  3. 转发表按页组织(复用页面头部空间),查找是 O(1)
  4. 不修正任何现存引用——修正工作交给后续:
    • 业务线程下次读到旧色指针时,读屏障查转发表自愈
    • 下一轮并发标记(+Remap)遍历时顺路把指针改写为新地址
  5. 搬空的旧页面直接整体回收(归还或复用),零碎片

注意一个反直觉的设计: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:ZAllocationSpikeTolerance2分配速率容忍系数。应用分配波动大(批处理/大促)时调大,避免分配尖峰打爆堆
-XX:ZCollectionInterval0定时强制启动周期(秒)。固定节奏回收,用于对齐低峰期
-XX:ZFragmentationLimit25页面碎片率超过此值才纳入搬迁候选
-XX:ConcGCThreads动态并发线程数(自适应,JDK 17+ 由 JVM 按负载调节)
-XX:ZProactivetrue主动 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=100m

8.4 排障信号

现象含义动作
Allocation Stall(日志Allocation Stall)分配等待——堆余量不足或分配速率超预期提高 Xmx / SoftMax;调大 ZAllocationSpikeTolerance;排查分配洪峰来源
高频 GC 但回收量小存活对象多、分配速率高分代 ZGC 已大幅缓解;检查是否缓存类泄漏
吞吐明显低于 G1读屏障 + 无分代(旧版)升 JDK 21+ 用分代 ZGC

下面是 ZGC 故障排查决策树,从两个典型信号出发,逐步定位根因:

不足

充足

是

否

是

否

否

是

出现哪种
排障信号?

Allocation Stall
日志关键字: Allocation Stall

高频 GC 但回收量小
日志关键字: GC(.*) Pause

检查堆余量
命令: jstat -gcutil / jcmd GC.heap_info

堆余量是否充足?

提高 Xmx / SoftMaxHeapSize
参数: -XX:SoftMaxHeapSize

分析分配速率
命令: jstat -gc / jcmd GC.class_histogram

分配速率是否超预期?

调大 ZAllocationSpikeTolerance
参数: -XX:ZAllocationSpikeTolerance

排查分配洪峰来源
工具: async-profiler / JFR

排查存活对象
命令: jmap -histo:live / jcmd GC.class_histogram

是否存在缓存类泄漏?

定位并修复缓存泄漏
工具: JFR / heap dump 分析

JDK 版本是否 ≥ 21?

升级 JDK 21+ 启用分代 ZGC
参数: -XX:+UseZGC(默认分代)

检查读屏障开销与并发线程
参数: -XX:ConcGCThreads

8.5 与容器/监控的兼容性

染色指针要求保留高位地址位:早期(JDK 11~14)与某些观测工具/内存映射库冲突;JDK 15+ 移除多重映射后兼容性良好。容器内需注意ConcGCThreads与 CPU limit 的配合,避免并发线程抢占业务配额。


九、ZGC vs G1 vs Shenandoah

维度G1ZGCShenandoah
停顿目标10~200ms<1ms<10ms(宣称亚毫秒实际约几 ms)
停顿与堆关系弱相关完全无关基本无关
核心机制SATB + RSet + 写屏障染色指针 + 读屏障Brooks 转发指针 + 读写屏障(后来也演进为载荷屏障)
并发整理✗(Evacuation STW)✓✓
额外内存开销RSet(堆的 1%~20%)转发表 + 堆余量(隐性)每对象多 1 个转发指针字
支持方OracleOracleRed Hat(OpenJDK 社区,非 Oracle JDK 默认构建)
分代是(设计即分代)JDK 21 起JDK 21 起实验性
适用堆4~32G8G~16T8G~数百 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 的工作一项一项搬到并发世界。

  1. CMS 把"标记"并发化,但清除是并发、整理不存在 → 碎片无解
  2. G1 把"回收范围"碎片化(Region + Mixed GC),但 Evacuation 依然 STW → 停顿压不进几十毫秒
  3. ZGC 把最后的硬骨头"对象搬迁"并发化,靠染色指针 + 读屏障 + 转发表 + 自愈,让 STW 只剩根扫描 →停顿与堆彻底解耦
  4. 分代 ZGC(JDK 21)补上分代假设的吞吐短板,宣告"低延迟与高吞吐不可兼得"的时代结束

选型建议落到一句话:JDK 21+、堆 >32G、停顿 <10ms 是 ZGC 的主场;4~32G、停顿 100ms 级仍是 G1 的主场;吞吐型离线任务用 Parallel。面试叙事线:从"为什么对象移动必须 STW"这个根问题出发,讲读屏障自愈如何打破它——这是把 ZGC 讲透的最短路径。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询