设想一个在线交易系统:每日处理数亿笔订单,堆内存高达 1TB,99.9% 的请求必须在 10ms 内响应。若垃圾收集(GC)暂停超过 10ms,便直接违背服务等级协议(SLA)。传统的分代收集器如 G1 在大堆上常常因整理(compaction)阶段触发数百毫秒甚至秒级的停顿,这迫使开发者转向更激进的并发方案——Z Garbage Collector(ZGC)。ZGC 的设计目标极为明确:无论堆有多大,GC 暂停不能超过 10ms(后续版本更将目标压缩至亚毫秒),且吞吐量降低不超过 G1 的 15%。实现这一目标的核心便是**彩色指针(colored pointers)与并发重定位(concurrent relocation)**的巧妙配合。本文将用一个贯穿全文的在线交易场景,深入剖析 ZGC 如何利用这两项技术,在 TB 级堆上保持稳定的低延迟表现,并给出与 G1、Shenandoah 的对比及调优建议。
彩色指针:将元数据嵌入地址
直观上,若能在每个对象引用中直接附带“状态标签”,并发操作时就可根据标签快速判断是否需要额外处理。这就是彩色指针的基本思想:利用 64 位虚拟地址空间中实际用于寻址的高位部分,存储数个比特的元数据。在 x86-64 架构上,当前硬件仅使用低 48 位地址(Linux 默认使用 48 位),ZGC 则要求操作系统只使用低 42 位(对应 4TB 堆空间),从而解放出 22 个高位。这 22 位中的 4 位被用作“颜色”,分别编码 different 状态:
- finalizable:对象可被终结(适用于终结器场景)
- remapped:表明该引用已指向对象的最新位置(无需再更新)
- marked0 / marked1:用于并发标记阶段的交替标记位
一个 64 位指针的结构示意如下:
| 未使用(18位)| finalizable (1位) | remapped (1位) | marked1 (1位) | marked0 (1位) | 对象地址(42位)|
实际的位分配和顺序依赖于 JVM 实现细节,这里仅展示概念。关键在于,颜色的存在使得**负载屏障(load barrier)**能在每次引用加载时执行廉价检查:若指针颜色为 remapped,则正常使用;若处于其他状态,屏障便触发修正动作。这种设计将状态信息从对象头(如传统的 Mark Word)移到了引用本身,从而避免了修改对象头所需的昂贵同步开销。
在我们的交易系统中,当一个订单对象被创建后,其引用被多个线程并发使用。通过彩色指针,线程在读取该订单时,load barrier 只需检查指针最高几位,成本仅为几条 CPU 指令,远低于传统的写屏障或对象头加锁。
并发重定位:对象搬家与引用自愈
垃圾收集的重要一步是整理:将存活对象挪到一起,消除碎片。传统做法是停止所有应用线程(STW),移动对象然后更新全部引用,这在大堆上暂停极长。ZGC 将这一过程完全并发化,只在线程本地握手(thread-local handshakes)等微小 STW 阶段执行根扫描。核心步骤如下:
- 并发标记:与多数并发收集器类似,ZGC 使用多着色标记算法区分存活对象。通过 marked0 和 marked1 位交替使用,无需额外清除标记位。
- 预备重定位:选择一个待回收的堆区域(region),确定其中存活对象的目标位置,并为每个存活对象在旧位置保存转向指针(forwarding pointer)。ZGC 将转向信息存放在对象头的预留字段中,而非独立数据结构。
- 根重定位(STW):非常短暂的暂停,仅更正根集合(如线程栈、JNI 句柄等)中指向被移动对象的引用,将其更新为新地址并设颜色为 remapped。
- 并发重定位:应用线程继续运行,GC 线程也在后台搬运对象。此时,若应用线程访问一个指向旧位置的引用,负载屏障会检测到该引用颜色非 remapped,于是检查对象头中的转向信息,更新引用为新地址并设置颜色为 remapped,这一过程称为自愈(self-healing)。
下面的流程图展示了一个 GC 周期内各阶段的衔接关系:
flowchart TD
A[STW: 根扫描与标记初始化] --> B[并发标记/重映射]
B --> C[STW: 标记终止与再标记]
C --> D[并发预备重定位]
D --> E[STW: 根重定位]
E --> F[并发重定位]
F --> G[并发清理与回收]
在交易系统中,假设一批陈旧的报价单对象被判定为垃圾,它们所在的区域将被回收。当订单处理线程试图加载某个报价单的引用时,load barrier 侦测到该引用未 remapped,于是自动跟进转向指针,将报价单的新地址写入变量。此后同一次 GC 周期内,其他线程再加载该引用时,由于指针颜色已是 remapped,屏障不会触发额外工作。这种单次修正的方式避免了冗余的引用更新开销。
与 Brooks 指针对比(Shenandoah 所用),ZGC 不需要为每个对象额外分配一个指针字段来保存转向信息,节省了 1 个字的内存,且屏障逻辑更简单——仅 load barrier 而无需 write barrier。代价是必须依赖虚拟地址空间的紧缩(42 位),在非 Linux 平台上需要额外的操作系统支持。
与 G1 和 Shenandoah 的对比
下面对比三个低延迟收集器的关键特性:
| 特性 | ZGC | G1 | Shenandoah |
|---|---|---|---|
| 暂停目标 | 亚毫秒(JDK 16+) | 200ms 以内 | 10ms 以内 |
| 最大堆尺寸 | 16TB | 4TB | 4TB |
| 并发整理 | 是(全并发) | 否(整理需 STW) | 是(Brooks 指针) |
| 屏障类型 | Load barrier(仅读) | SATB + 写屏障 | 读写屏障(引用更新屏障) |
| 内存开销 | 极低(颜色在指针内) | 中等(记忆集) | 每对象 1 字转发指针 |
| 吞吐量影响 | 相比 G1 约降低≤15% | 基准 | 通常牺牲更多吞吐换取低暂停 |
| 并发阶段数 | 多个,只有少量 STW | 部分并发,整理需暂停 | 全部并发,仅初始标记暂停 |
从交易系统的视角出发:
- G1 在堆增长到 TB 级后,其整理暂停可能失控(实测中曾达到 500ms 以上),不满足 SLA。
- Shenandoah 暂停稳定在 10ms 左右,但为了维持每对象 1 字的 Brooks 指针,大堆内存开销显著,且读写屏障带来固定的吞吐量损失。
- ZGC 凭借染色指针将暂停压到 1ms 以下,128G 堆的 SPECjbb 测试中最大暂停仅 1.68ms(JEP 333 公布数据),即使扩展到 TB 级,暂停也不会随堆大小增长。吞吐量方面,同一测试中 ZGC 的 max-jOPS 优于 G1(100% vs 91.2%),但这并非通用结论,实际部署中 ZGC 吞吐通常略低于 G1,差距在 5%~15% 之间。
工程实现的关键细节
ZGC 在 HotSpot 中的实现凸显了“并发优先”的设计:
- NUMA 感知:堆被划分为多个物理内存区域,GC 线程优先在靠近所在 CPU 的内存上工作,减少远程内存访问。
- 区域化管理:类似 G1,堆被分成三种尺寸的区域(小、中、大),但 ZGC 不维护复杂的分代记忆集。大对象直接使用大区域,避免碎片。
- 线程局部握手:JEP 312 引入的机制允许对单个线程执行轻量级停顿,替代全局 STW。ZGC 的根扫描和某些同步点依赖此特性。
- 染色指针的虚拟地址映射:JVM 通过 mmap 将同一个物理内存映射到多个虚拟地址,每个地址对应不同的颜色位组合,从而让硬件帮助完成一部分颜色检查。
- 负载屏障的 JIT 优化:C2 编译器对屏障进行内联,在常见情况(指针为 remapped)下只增加一条 TEST 指令和一条条件跳转,命中率极高。
在交易系统实践中,这些工程优化使得 ZGC 即使在 1TB 堆、每秒 10 万笔并发的压力下,GC 线程与业务线程的相互干扰仍然可控。
具体示例:一次 GC 周期中的引用修正
我们以交易系统的一次典型 GC 周期为例,跟踪一个订单对象 Order 的引用修正过程:
- 初始状态:Order 对象位于区域 A,其引用 orderRef 存放在线程栈中,指针颜色为 remapped。
- 并发标记阶段:GC 线程遍历对象图,将 Order 标记为存活。同时,由于指针颜色可能被修改为 marked0 或 marked1,但 orderRef 仍然 remapped(因为尚未移动)。
- 预备重定位:区域 A 被选为回收目标,GC 在 Order 的旧对象头写入新位置的地址(转向指针)。此时,orderRef 仍然指向旧 Order,颜色 remapped,但对象头内的转向指针已设置。
- 根重定位 STW:短暂暂停期间,GC 扫描线程栈,发现 orderRef 指向旧 Order。它通过对象头转发指针找到新地址,将 orderRef 更新为新地址,并将指针颜色设为 remapped。
- 并发重定位:GC 并发拷贝 Order 内容到新区域。同时,另一个线程通过某个缓存引用 oldCacheRef 访问旧 Order。它的 load barrier 发现 oldCacheRef 颜色不是 remapped,于是读取对象头转发地址,将 oldCacheRef 更新并设为 remapped,然后使用新对象。
- 清理:旧区域被重用,整个流程结束。
在此过程中,任何时刻读取未 remapped 引用的线程都能自愈,而大多数引用(如已被根修正)直接视为 remapped,屏障几乎无开销。这就实现了“在运行中搬家,且不影响应用线程”的效果。
权衡与失败模式
ZGC 并非万能,实际部署需认清其边界:
- 吞吐量妥协:尽管目标是不超过 G1 的 15%,但并发阶段的 CPU 占用(多个 GC 线程)仍然会与业务线程争抢核心。交易系统需预留足够 CPU 资源,或通过 -XX:ConcGCThreads 控制并发线程数。
- 浮动垃圾容忍度:并发标记期间产生的垃圾可能无法被当场回收,导致内存占用量略高于同等等级的分代收集器。若堆设置过小,可能因浮动垃圾累积而触发持续并发周期,反而降低效率。
- 分配尖峰处理:突发大对象分配可能瞬间占满堆,ZGC 提供 -XX:ZAllocationSpikeTolerance 参数(默认 2.0)以允许临时超出分配速率,避免 Allocation Stall。交易系统在节假日前夕的订单洪峰下,合理设置此值至关重要。
- 平台限制:早期版本仅支持 Linux/x64,现在已扩展至 macOS 和 Windows,但 AArch64 等平台的支持仍在完善。
- 类卸载及其他特性:JEP 333 初期不支持类卸载(JDK 13 后实现),对于大量动态类加载的应用(如某些中间件)需确保 JDK 版本足够新。
与 G1 和 Shenandoah 的选择建议:
- 堆小于 16GB,且对暂停不敏感,G1 通常是平衡吞吐与暂停的最佳选择。
- 堆在 32GB~512GB,追求低暂停且可接受稍低的吞吐,Shenandoah 和 ZGC 均可。但 Shenandoah 的内存开销更明显。
- 堆超过 512GB 或需要亚毫秒暂停,ZGC 是唯一方案。交易系统若采用 1TB 堆并追求极致延迟,ZGC 几乎必选。
前沿方向与总结
自 JDK 11 以实验特性引入以来,ZGC 的演进从未停止:
- 分代 ZGC(JEP 439,JDK 21 预览):引入年轻代和年老代,进一步提升吞吐量,同时保留亚毫秒暂停。交易系统中有大量朝生夕灭的对象,分代收集可大幅减少并发标记的工作量。
- 动态堆大小:JDK 16 后支持未提交内存的回收(JEP 351),对云环境或内存受限的场景有价值。
- 大页支持与优化:利用透明大页减少 TLB miss,提升屏障效率。
ZGC 的彩色指针 + 负载屏障方案为并发整理提供了一种优雅且高效的解法。它的核心洞察在于:与其在对象头或数据结构里维护状态,不如让引用本身携带自我描述的元数据。这不仅化解了 TB 级堆的整理暂停难题,更为异构内存管理、持久化指针等未来特性预留了扩展空间。对于在线交易这类延迟极度敏感的系统,ZGC 已是生产首选,其设计哲学也必将影响未来垃圾收集设施的演进方向。
(本文所引性能数据除特别标注外,均来自 JEP 333 的 SPECjbb 2015 测试结果,实际表现因负载和硬件而异。)