ZGC(Z Garbage Collector)是 Java HotSpot 虚拟机自 JDK 11 作为实验特性引入、并在 JDK 15 中正式转正的下一代超低延迟垃圾回收器。在 **JDK 21 LTS 中,分代 ZGC(Generational ZGC, JEP 439)**进一步合并落地,彻底补齐了传统单代 ZGC 在高分配速率场景下的吞吐短板。

ZGC 的核心设计目标是:在任意堆内存规模(从几十 MB 到 16TB)下,将垃圾回收的 Stop-The-World(STW)停顿时间严格压制在毫秒级(通常 $< 1\text{ms}$),且停顿时间不随堆容量的增大而线性膨胀


ZGC 核心设计哲学与特性

传统的 G1 和 CMS 无论并发标记做得多好,在对象复制与内存整理(Evacuation)阶段都必须进行全量 STW,因为移动对象会改变其物理内存地址,如果业务线程同时并发访问,就会读写脏数据。这也是 G1 大堆下停顿时间不可避免上升至几十甚至数百毫秒的根本瓶颈。

ZGC 的颠覆性突破在于:实现了全并发的对象转移(Concurrent Evacuation / Relocation)。 无论年轻代还是老年代、无论堆多大,对象在内存中的搬移与整理全部与业务线程并发执行。

核心特性


核心技术支撑:着色指针与读屏障自愈

ZGC 之所以能做到“对象一边在内存里搬运,业务线程一边无感读写”,依赖两大核心工程技术:着色指针(Colored Pointers)读屏障自愈(Load Barrier & Self-Healing)

着色指针(Colored Pointers)

为什么 64 位地址根本用不完?

在理论上,64 位指针可以寻址 $2^{64} = 16\text{EB}$(Exabytes)的超大内存空间,在可预见的未来没有任何单机服务器能配备如此庞大的物理内存。因此,硬件与操作系统对虚拟地址总线做了物理剪裁:

ZGC 对 64 位指针的位划分与演进

既然高位空闲,ZGC 便将这部分比特位“借”来作为垃圾回收的元数据标记位(着色位):

D2 Diagram
qtopie.github.io

状态位语义详解

操作系统不认高位地址怎么办?内存多重映射(Multi-Mapping)

如果直接将高 4 位的着色位涂上颜色(变成 1),当 CPU 或操作系统进行寻址解引用时,会将其当成一个高达数十甚至数百 TB 之外的非法虚拟地址,引发段错误(Segmentation Fault, SIGSEGV)

为了解决指针染色“欺骗”操作系统的问题,ZGC 引入了**内存多重映射(Memory Multi-Mapping)**技术:

读屏障(Load Barrier)与自愈机制(Self-Healing)

不同于传统 GC 频繁依赖写屏障(Write Barrier),ZGC 的关键灵魂在于读屏障(Load Barrier)

  1. 快路径拦截(Fast Path): 当业务代码从堆中读取对象引用(如 o = obj.field)时,JIT 插入的读屏障先以一条极短的汇编指令(如 test 测试指针标志位)检查引用的着色状态。如果引用已经处于当前周期的期望颜色(例如已是 Remapped),说明该引用安全指向最新地址,直接无锁返回,额外开销仅几个纳秒
  2. 慢路径重定位与自愈(Slow Path & Self-Healing): 如果指针颜色显示对象正处于“待迁移”或“已迁移但引用未更新”状态:
    • 读屏障切入慢路径,在页面的转发表(Forwarding Table)中查找该对象的新地址;
    • 若对象尚未搬移,当前读线程先协作完成对象的复制落盘并写入转发表;
    • 自愈(Self-Healing):读线程会顺手利用 CAS 将栈/堆中的旧指针原地更新为新地址,并将其高位置为 Remapped 颜色!
    • 收益: 只要有任意一个业务线程读过一次该对象,该引用立即被彻底自愈。后续成千上万次访问将全部走入快路径,不再触发任何慢查。

ZGC 垃圾回收生命周期流程

ZGC 的一个完整回收周期主要包含以下四个核心阶段:

D2 Diagram
qtopie.github.io
  1. 初始标记(Pause Mark Start, STW 极短): 仅仅停顿扫描所有线程栈中的 GC Roots 变量。由于根集合大小只取决于线程数与调用深度,与堆大小毫无关联,耗时通常只需数十微秒($\le 0.5\text{ms}$)。
  2. 并发标记与对象图遍历(Concurrent Mark & Segment): GC 线程与应用线程并发执行,从 Roots 出发遍历对象图。如果发现对象的引用颜色不匹配,则进行染色并入队,标记存活对象。
  3. 再标记(Pause Mark End, STW 极短): 微秒级短暂 STW,用于处理并发标记末期尚未处理完的零星引用变动及弱引用处理。此时完成整个堆的存活对象判定,并筛选出垃圾比例高、需要搬移压缩的 Page 放入重分配集(Relocation Set / CSet)。
  4. 并发准备与并发重定位(Concurrent Prepare & Relocate):
    • 准备工作: 为需要释放的 Page 创建轻量 Forwarding Table;
    • 初始重定位(Pause Relocate Start, STW 极短): 再次对栈帧 GC Roots 指针做一次极短的同步刷新(约 0.1ms),随后立即恢复业务执行;
    • 并发迁移: GC 线程在后台将 CSet 中的存活对象逐个复制到新的空闲 ZPage;
    • 业务无感自愈: 如果业务线程在 GC 线程搬运前抢先访问了旧对象,通过读屏障机制触发即时自愈;
    • 物理内存归还: 一旦该 Page 上的所有存活对象全部迁移完毕,原 ZPage 立刻整体释放并重置,立即供新对象分配,完全无需等待所有指针更新完毕!剩余指向老地址的旧指针会在后续访问时自愈,或在下一轮并发标记阶段被批量覆写。

ZGC 是如何识别到垃圾的?

ZGC 判定对象存活与垃圾的核心算法依然是经典的根可达性分析(Root Set Tracing):从一组确定的 GC Roots(栈帧本地变量、静态引用、JNI 句柄等)出发遍历对象引用图。凡是能够遍历追溯到的对象判定为“存活”,无法被任何 GC Root 触达的对象最终判定为“垃圾”。

但 ZGC 的革命性创新在于:它不依赖对象头,也不依赖全堆暂停来重置标记,而是把存活状态直接编码在 64 位对象指针的着色位(Marked0 / Marked1)中,配合页面 Live Map(存活位图)与读屏障实现并发精准判定。

1. 核心判定依据:Marked0 与 Marked1 轮次交替翻转

在着色指针的高位中,存在两个专门的存活标记位:Marked0Marked1在任意一个 GC 周期内,只有其中一种颜色代表本轮的“活跃(Active)”状态,两轮交替轮转:

为什么必须两套 Marked 位交替?
如果只有单一标记位,新一轮 GC 开始前就必须全堆扫描清空上一轮的标记,否则新老标记会产生混淆。ZGC 引入两套轮次交替染色位,只需在周期开始时翻转活跃颜色,就能实现“逻辑上的全堆标记瞬间重置”,彻底规避了重置标记带来的全局 STW。

2. 识别垃圾的并发遍历流水线

  1. 初始标记(Pause Mark Start, STW 约 0.1ms): 抓取线程栈上的所有 GC Roots,将它们直接引用的第一层对象指针染上当前活跃颜色(如 Marked0),并将这些对象压入并发标记队列(Marking Queue)。
  2. 并发标记(Concurrent Mark, 业务线程全速运行):
    • GC 线程不断从队列中取出对象,遍历其持有的所有下游对象引用:
      • 若下游引用的颜色尚未匹配当前活跃色:使用 CAS 将该引用指针染色为 Marked0,并在该对象所在 ZPage 的 Live Map(存活位图) 中将对应 bit 置为 1,随后推入队列继续向下扫描;
      • 若已经是当前活跃色:说明已被遍历过,直接跳过(防止循环引用死循环)。
    • 并发读写防漏标(读屏障介入):
      在并发标记期间,如果业务线程尝试读取一个尚未染色的对象引用,读屏障(Load Barrier)会强制介入拦截,当场协作将其染色为 Marked0 并推入标记队列,确保并发运行期间绝不漏标任何存活对象!
  3. 再标记(Pause Mark End, STW 约 0.2ms): 短暂暂停清空队列末端由于并发竞争产生的零星残余引用,完成全局存活标记闭环。

3. 垃圾判定与空间释放:Live Map(存活位图)与整页回收

虽然存活标记在指针上,但为了评估每个内存页的垃圾密度,ZGC 在每个 ZPage 内部维护了一张 Live Map(存活位图)


ZGC 到底有没有 STW?为什么常被称为“全并发回收器”?

核心结论:ZGC 绝对是有 STW 的!宣称 ZGC“零 STW / 100% 全程并发”是一种误读。

在一个标准垃圾回收周期中,严格来说有 3 次极短的 STW 暂停

  1. Pause Mark Start(初始标记): 耗时约 $0.1\text{ms}$,只扫描线程栈帧中的 GC Roots 指针,绝不沿着引用链往下遍历对象图,耗时仅与线程数和调用栈深有关,与堆大小(无论是 4GB 还是 16TB)完全无关;
  2. Pause Mark End(再标记): 耗时约 $0.2\text{ms} \sim 0.5\text{ms}$,处理并发标记结束时极少量的引用更新与弱引用清理;
  3. Pause Relocate Start(初始重定位): 耗时约 $0.1\text{ms}$,刷新直接存在于栈帧上的根指针。

为什么业界将 ZGC 定性为“全并发(Fully Concurrent)”?

对比传统 GC(如 CMS、G1),传统回收器的 STW 停顿之所以会随堆增大而线性暴涨到数百毫秒甚至秒级,主要是因为以下两大核心耗时大头:

ZGC 的质变突破在于:它把传统 GC 耗时占 90% 以上的“对象拷贝搬迁”与“全堆指针更新修正”,彻底剥离成了与业务线程并发执行!

因此,ZGC 并非消灭了所有的 STW,而是消灭了所有与“堆大小 / 存活对象量”正相关的重型 STW。留下的 3 次 STW 仅用于轻量级的根集合元数据刷新,通常累计耗时 $< 1\text{ms}$,这就是为什么 ZGC 被业界公认为跨时代的全并发垃圾回收器。


动态分页面(ZPage)管理与无碎片保证

不同于 G1 固定大小的 Region 划分,ZGC 动态将堆划分为三类大小弹性的页面(ZPage):

ZPage 类型页面物理大小对象大小限制适用场景与生命周期特性
Small Page固定 $2\text{MB}$对象 $\le 256\text{KB}$承载绝大多数轻量业务实体,数量最多,回收收益极高
Medium Page固定 $32\text{MB}$$256\text{KB} < \text{对象} \le 4\text{MB}$存放中型数组、复杂报表实体
Large Page动态 $N \times 2\text{MB}$对象 $> 4\text{MB}$单个 Large Page 仅容纳一个特大对象。页面大小随对象按需对齐,页面在对象死亡后整体释放,从不与其他对象混放,彻底杜绝了大对象导致的堆碎片。

ZGC vs G1 深度全维度对比

很多工程师容易陷入“ZGC 延迟低,所以 ZGC 任何场景都全面吊打 G1”的认知误区。技术选型永远是关于 Latency(低停顿)Throughput(极限吞吐量) 的权衡:

比较维度G1 垃圾回收器ZGC 垃圾回收器深度技术根因
STW 停顿时间几十毫秒 $\sim$ 数百毫秒(随堆增大而上升)$< 1\text{毫秒}$(极稳定,与堆大小无关)G1 在对象复制(Evacuation)阶段必须全堆 STW;ZGC 实现读屏障并发迁移
最大支持堆容量通常建议 $4\text{GB} \sim 32\text{GB}$几十 MB $\sim$ 最大 16TBG1 超大堆下 RSet 维护开销爆炸;ZGC 基于着色指针与轻量转发表,扩展性极高
内存整理与碎片基于 Region 拷贝整理,老年代不足时会退化 Full GC并发重定位(Concurrent Relocate)ZGC 持续并发清理无离散碎片,几乎绝迹传统意义的长 STW Full GC
分代模型演进原生分代(Eden / Survivor / Old)JDK 11~20 单代;JDK 21+ 支持分代 ZGC单代 ZGC 对突发高分配速率抵御力弱;JDK 21 分代 ZGC 使得吞吐大幅反超 G1
业务线程吞吐量开销仅写屏障,JIT 编译开销低,极限吞吐高读屏障(Load Barrier)频繁触发,单核吞吐损失约 5%~15%每次对象解引用都走一条读屏障汇编,在密集计算与高频内存访问场景有轻微 CPU 惩罚
辅助内存开销RSet 占用堆约 $5% \sim 15%$ 内存着色指针占用虚拟空间,额外辅助内存开销 $< 2%$ZGC 去除了庞大的跨 Region 记忆集
指针压缩支持支持 Compressed OOPs($\le 32\text{GB}$ 节省内存)不支持 Compressed OOPs(必须占用完整 64 位)着色指针需要借用高 16 位元数据空间,无法进行压缩寻址偏移
架构亲和性无特殊感知原生 NUMA-Aware多 Socket CPU 拓扑下本地内存分配更快

单代 ZGC vs 分代 ZGC(Generational ZGC)

在 JDK 11 至 JDK 20 中,ZGC 都是单代(Single-Generation)收集器:不论是刚 new 出来生命周期只有几微秒的临时对象,还是存活几个小时的长生命周期缓存,都混在同一片页面中被同等对待扫描。


生产选型与调优建议

选型决策树

  1. 选择 ZGC 的场景:
    • 对延迟与 SLA 有强要求: 如金融交易、证券行情、游戏服务器、实时广告竞价、核心在线 RPC 接口,对 $P99$ / $P99.9$ 抖动极其敏感;
    • 超大堆应用: 堆内存超过 $32\text{GB}$ 甚至上百 GB(如大规模 Elasticsearch、Kafka Broker、在内存缓存服务),使用 G1 无法将停顿压制在 100ms 以内。
    • JDK 版本推荐: 强烈建议使用 JDK 17 LTS(单代优化版) 或直接上 JDK 21 LTS(分代 ZGC,生产首选)
  2. 继续选择 G1 的场景:
    • 仍运行在 JDK 8 / 11 生产基线,且堆内存适中($< 8\text{GB}$);
    • 纯后台离线批处理计算任务(如离线 ETL、科学计算),对停顿时间完全不敏感,只追求单核极限吞吐量和 CPU 利用率。

推荐生产参数配置

JDK 21+ 生产配置(启用分代 ZGC,强烈推荐)

# 启用 ZGC 并开启分代收集
-XX:+UseZGC
-XX:+ZGenerational
-Xms32g -Xmx32g

# 建议保留大约 15%~20% 的并发 GC 线程资源
-XX:ConcGCThreads=4

# 开启 NUMA 绑定
-XX:+UseNUMA

# 生产环境统一日志配置 (Unified JVM Logging)
-Xlog:gc*,gc+phases=debug:file=/var/log/app/gc.log:time,uptime,pid:filecount=5,filesize=100M

JDK 17 生产配置(单代 ZGC)

# 启用单代 ZGC
-XX:+UseZGC
-Xms16g -Xmx16g

# 针对单代 ZGC 预防 Allocation Stall:适当调高并发线程数
-XX:ConcGCThreads=4
-XX:+UseNUMA

# 避免内存返还给 OS 造成颠簸
-XX:-ZUncommit

参考与扩展