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)。 无论年轻代还是老年代、无论堆多大,对象在内存中的搬移与整理全部与业务线程并发执行。
核心特性
- 亚毫秒级极致停顿: STW 阶段仅包含极短的 GC Roots 根扫描和标记结束修正,停顿时间通常在 $0.1\text{ms} \sim 1\text{ms}$ 之间,真正实现软实时服务。
- 动态分页管理(ZPage,非固定大小 Region): 摒弃了 G1 固定大小 Region 的限制,ZGC 划分为 Small Page(2MB,分配 $\le 256\text{KB}$ 对象)、Medium Page(32MB,分配 $\le 4\text{MB}$ 对象)和 Large Page($N \times 2\text{MB}$ 动态浮动,仅容纳单个特大对象),完全不产生大对象跨块碎片。
- 无 RSet 内存负担: 不依赖 G1 那样庞大且沉重的 Remembered Set(避免了 $5% \sim 15%$ 的内存开销),借助全局着色指针与轻量转发表(Forwarding Table)记录跨页引用。
- NUMA 架构亲和(NUMA-Aware): 默认支持 NUMA 内存架构,内存分配优先绑定在当前 CPU Socket 的本地内存通道,避免跨总线访存瓶颈。
核心技术支撑:着色指针与读屏障自愈
ZGC 之所以能做到“对象一边在内存里搬运,业务线程一边无感读写”,依赖两大核心工程技术:着色指针(Colored Pointers) 与 读屏障自愈(Load Barrier & Self-Healing)。
着色指针(Colored Pointers)
为什么 64 位地址根本用不完?
在理论上,64 位指针可以寻址 $2^{64} = 16\text{EB}$(Exabytes)的超大内存空间,在可预见的未来没有任何单机服务器能配备如此庞大的物理内存。因此,硬件与操作系统对虚拟地址总线做了物理剪裁:
- 硬件总线限制: 主流 64 位 CPU(如 x86_64 与 ARM64)硬件层面通常只实现了 48 位虚拟地址总线(部分较新的微架构如 Intel Ice Lake、ARMv8.2 扩展至 52 位)。
- 寻址范围: 48 位虚拟地址足以寻址 $2^{48} = 256\text{TB}$ 的内存空间。这意味着在标准 64 位指针中,高 16 位在硬件和操作系统层面原本就是被闲置忽略的(默认全为 0)。
ZGC 对 64 位指针的位划分与演进
既然高位空闲,ZGC 便将这部分比特位“借”来作为垃圾回收的元数据标记位(着色位):
- JDK 11 ~ JDK 15 时期(最大支持 4TB):
[ 0 ~ 41 位 ](共 42 位):对象物理地址空间,$2^{42} = 4\text{TB}$,即早期 ZGC 支持的最大堆内存。[ 42 ~ 45 位 ](共 4 位):着色元数据位(Finalizable、Remapped、Marked0、Marked1)。[ 46 ~ 63 位 ](共 18 位):固定为 0,预留给系统。
- JDK 16+ 及当前架构(扩展至 16TB):
[ 0 ~ 43 位 ](共 44 位):对象物理地址空间,$2^{44} = 16\text{TB}$,将单堆寻址能力提升到了 16TB。[ 44 ~ 47 位 ](共 4 位):着色元数据位(布局同上,整体左移 2 位)。[ 48 ~ 63 位 ](共 16 位):保留未使用。
状态位语义详解
- Marked0 / Marked1(活跃对象标记): 两轮交替使用的存活标记位。同一周期内只有一种颜色是合法的活跃状态(如本轮使用 Marked0,下一轮使用 Marked1),用于并发标记区分新老存活对象。
- Remapped(重映射标记): 表示该引用已经指向了对象转移后的最终新地址,或者对象根本不需要移动。当引用被染上 Remapped 色彩时,后续所有读屏障可以直接走极速快路径(Fast Path)。
- Finalizable: 仅可达通过
finalize()方法访问的对象标记。
操作系统不认高位地址怎么办?内存多重映射(Multi-Mapping)
如果直接将高 4 位的着色位涂上颜色(变成 1),当 CPU 或操作系统进行寻址解引用时,会将其当成一个高达数十甚至数百 TB 之外的非法虚拟地址,引发段错误(Segmentation Fault, SIGSEGV)。
为了解决指针染色“欺骗”操作系统的问题,ZGC 引入了**内存多重映射(Memory Multi-Mapping)**技术:
- 物理内存与虚拟视图: ZGC 在向操作系统申请同一块物理内存(匿名内存页或基于
memfd_create的共享内存)时,在虚拟内存空间中通过mmap系统调用同时挂载映射 3 个不同的虚拟地址视图(Virtual Address Views),分别对齐Marked0、Marked1和Remapped的高位基地址。 - 底层统一: 无论指针的着色位如何在
Marked0、Marked1或Remapped之间变换,通过操作系统的多级页表(Page Table)转换后,最终都会落到同一块完全相同的物理内存地址上。由此完美解决了硬件/OS 寻址与指针元数据染色的冲突问题。
读屏障(Load Barrier)与自愈机制(Self-Healing)
不同于传统 GC 频繁依赖写屏障(Write Barrier),ZGC 的关键灵魂在于读屏障(Load Barrier):
- 快路径拦截(Fast Path):
当业务代码从堆中读取对象引用(如
o = obj.field)时,JIT 插入的读屏障先以一条极短的汇编指令(如test测试指针标志位)检查引用的着色状态。如果引用已经处于当前周期的期望颜色(例如已是Remapped),说明该引用安全指向最新地址,直接无锁返回,额外开销仅几个纳秒。 - 慢路径重定位与自愈(Slow Path & Self-Healing):
如果指针颜色显示对象正处于“待迁移”或“已迁移但引用未更新”状态:
- 读屏障切入慢路径,在页面的转发表(Forwarding Table)中查找该对象的新地址;
- 若对象尚未搬移,当前读线程先协作完成对象的复制落盘并写入转发表;
- 自愈(Self-Healing):读线程会顺手利用 CAS 将栈/堆中的旧指针原地更新为新地址,并将其高位置为
Remapped颜色! - 收益: 只要有任意一个业务线程读过一次该对象,该引用立即被彻底自愈。后续成千上万次访问将全部走入快路径,不再触发任何慢查。
ZGC 垃圾回收生命周期流程
ZGC 的一个完整回收周期主要包含以下四个核心阶段:
- 初始标记(Pause Mark Start, STW 极短): 仅仅停顿扫描所有线程栈中的 GC Roots 变量。由于根集合大小只取决于线程数与调用深度,与堆大小毫无关联,耗时通常只需数十微秒($\le 0.5\text{ms}$)。
- 并发标记与对象图遍历(Concurrent Mark & Segment): GC 线程与应用线程并发执行,从 Roots 出发遍历对象图。如果发现对象的引用颜色不匹配,则进行染色并入队,标记存活对象。
- 再标记(Pause Mark End, STW 极短): 微秒级短暂 STW,用于处理并发标记末期尚未处理完的零星引用变动及弱引用处理。此时完成整个堆的存活对象判定,并筛选出垃圾比例高、需要搬移压缩的 Page 放入重分配集(Relocation Set / CSet)。
- 并发准备与并发重定位(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 轮次交替翻转
在着色指针的高位中,存在两个专门的存活标记位:Marked0 和 Marked1。
在任意一个 GC 周期内,只有其中一种颜色代表本轮的“活跃(Active)”状态,两轮交替轮转:
- 第 $N$ 轮 GC 周期:
- 当前活跃色为
Marked0(Marked0 = 1, Marked1 = 0); - 凡是遍历到的对象,其引用指针都会被染成
Marked0颜色。
- 当前活跃色为
- 第 $N+1$ 轮 GC 周期(颜色翻转):
- 当前活跃色切换为
Marked1(Marked1 = 1, Marked0 = 0); - 此时上一轮残留的
Marked0指针在逻辑上自动失效并被视为“旧标记/未访问”,无需停机清空全堆标记位!
- 当前活跃色切换为
为什么必须两套 Marked 位交替?
如果只有单一标记位,新一轮 GC 开始前就必须全堆扫描清空上一轮的标记,否则新老标记会产生混淆。ZGC 引入两套轮次交替染色位,只需在周期开始时翻转活跃颜色,就能实现“逻辑上的全堆标记瞬间重置”,彻底规避了重置标记带来的全局 STW。
2. 识别垃圾的并发遍历流水线
- 初始标记(Pause Mark Start, STW 约 0.1ms):
抓取线程栈上的所有 GC Roots,将它们直接引用的第一层对象指针染上当前活跃颜色(如
Marked0),并将这些对象压入并发标记队列(Marking Queue)。 - 并发标记(Concurrent Mark, 业务线程全速运行):
- GC 线程不断从队列中取出对象,遍历其持有的所有下游对象引用:
- 若下游引用的颜色尚未匹配当前活跃色:使用 CAS 将该引用指针染色为
Marked0,并在该对象所在 ZPage 的 Live Map(存活位图) 中将对应 bit 置为1,随后推入队列继续向下扫描; - 若已经是当前活跃色:说明已被遍历过,直接跳过(防止循环引用死循环)。
- 若下游引用的颜色尚未匹配当前活跃色:使用 CAS 将该引用指针染色为
- 并发读写防漏标(读屏障介入):
在并发标记期间,如果业务线程尝试读取一个尚未染色的对象引用,读屏障(Load Barrier)会强制介入拦截,当场协作将其染色为Marked0并推入标记队列,确保并发运行期间绝不漏标任何存活对象!
- GC 线程不断从队列中取出对象,遍历其持有的所有下游对象引用:
- 再标记(Pause Mark End, STW 约 0.2ms): 短暂暂停清空队列末端由于并发竞争产生的零星残余引用,完成全局存活标记闭环。
3. 垃圾判定与空间释放:Live Map(存活位图)与整页回收
虽然存活标记在指针上,但为了评估每个内存页的垃圾密度,ZGC 在每个 ZPage 内部维护了一张 Live Map(存活位图):
- 垃圾的最终认定: 并发标记结束后,在当前 ZPage 中,所有在 Live Map 中对应 bit 为 0 的对象,正式被判定为不可达的垃圾!
- 全垃圾页即时释放: 若某个 ZPage 的 Live Map 统计发现存活对象数为 0(全是垃圾),ZGC 在并发标记阶段结束时,无需等待重定位阶段,直接将整个物理内存页清空回收!
- 重分配集(CSet)高收益回收: 若页面中仅有少量存活对象(如 99% 是垃圾),该页面被纳入 CSet。后续并发迁移阶段,GC 线程仅将 Live Map 中为 1 的那几个存活对象复制搬运到新页面;存活对象搬空后,整页直接注销重置,成千上万的垃圾对象无需任何遍历和析构成本,直接连同物理页一并抹除!
ZGC 到底有没有 STW?为什么常被称为“全并发回收器”?
核心结论:ZGC 绝对是有 STW 的!宣称 ZGC“零 STW / 100% 全程并发”是一种误读。
在一个标准垃圾回收周期中,严格来说有 3 次极短的 STW 暂停:
- Pause Mark Start(初始标记): 耗时约 $0.1\text{ms}$,只扫描线程栈帧中的 GC Roots 指针,绝不沿着引用链往下遍历对象图,耗时仅与线程数和调用栈深有关,与堆大小(无论是 4GB 还是 16TB)完全无关;
- Pause Mark End(再标记): 耗时约 $0.2\text{ms} \sim 0.5\text{ms}$,处理并发标记结束时极少量的引用更新与弱引用清理;
- Pause Relocate Start(初始重定位): 耗时约 $0.1\text{ms}$,刷新直接存在于栈帧上的根指针。
为什么业界将 ZGC 定性为“全并发(Fully Concurrent)”?
对比传统 GC(如 CMS、G1),传统回收器的 STW 停顿之所以会随堆增大而线性暴涨到数百毫秒甚至秒级,主要是因为以下两大核心耗时大头:
- 存活对象内存搬移(Evacuation): G1 回收一个存活数据量为 20GB 的大堆时,必须把所有业务线程完全冻结(STW),调用多核 CPU 将这 20GB 数据在内存中硬生生复制到新 Region;
- 全堆旧引用指针修正: G1 必须在 STW 期间把所有指向旧地址的引用全部更新完毕才能放行业务线程。
ZGC 的质变突破在于:它把传统 GC 耗时占 90% 以上的“对象拷贝搬迁”与“全堆指针更新修正”,彻底剥离成了与业务线程并发执行!
- 存活对象的拷贝搬移:GC 线程在后台慢慢搬,业务线程继续跑;
- 旧引用的修正:借助读屏障自愈(Load Barrier & Self-Healing),业务线程读到哪个旧指针就顺手在纳秒级自愈哪个指针,完全省去了全堆 STW 批量改指针的沉重负担。
因此,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$ 最大 16TB | G1 超大堆下 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 出来生命周期只有几微秒的临时对象,还是存活几个小时的长生命周期缓存,都混在同一片页面中被同等对待扫描。
- 痛点(Allocation Stall): 如果业务分配速率极高(高频产生大量短命对象),单代 ZGC 必须不间断地全堆标记,一旦标记与清理速度追赶不上分配速度,就会触发 分配停顿(Allocation Stall),造成业务线程被挂起阻塞。
- JDK 21 LTS 分代 ZGC(JEP 439):
- 重新将堆划分为逻辑年轻代与老年代,绝大多数 GC 线程全力高频并发收集年轻代,显著降低 CPU 消耗并以极快的速度回收新对象;
- 彻底解决了高并发突发流量下单代 ZGC 易发生 Allocation Stall 的问题;
- 经 Oracle 官方基准测试,分代 ZGC 在保持 $< 1\text{ms}$ 极致延迟的同时,吞吐量已与甚至超越 G1。
生产选型与调优建议
选型决策树
- 选择 ZGC 的场景:
- 对延迟与 SLA 有强要求: 如金融交易、证券行情、游戏服务器、实时广告竞价、核心在线 RPC 接口,对 $P99$ / $P99.9$ 抖动极其敏感;
- 超大堆应用: 堆内存超过 $32\text{GB}$ 甚至上百 GB(如大规模 Elasticsearch、Kafka Broker、在内存缓存服务),使用 G1 无法将停顿压制在 100ms 以内。
- JDK 版本推荐: 强烈建议使用 JDK 17 LTS(单代优化版) 或直接上 JDK 21 LTS(分代 ZGC,生产首选)。
- 继续选择 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