问题引入
CMS 的并发标记让 GC 停顿从分钟级降到秒级,但它留下三个悬而未决的遗产:内存碎片(没有整理过程的 Mark-Sweep)、停顿不可预测(并发模式失败时直接退化 Serial Old)、大堆适应性差(老年代越大,Full GC 越痛苦)。
G1 的回应是可预测的停顿时间——"给我几百毫秒,我保证在这个窗口内完成"。它通过 Region 化把整堆拆成小块,优先回收垃圾最多的 Region,用数学模型估算每次回收的工作量。
但当堆走向上百 GB 甚至 TB 级别,G1 的"几百毫秒"也成了瓶颈。ZGC 的目标更激进:亚毫秒级停顿,与堆大小无关。它不再纠结于"如何减少标记时的 STW",而是从根本上改变问题——如果对象的移动和重映射可以完全并发,还需要停顿吗?
核心概念
G1 的 Region 化
G1(Garbage-First)将整堆划分为多个大小相等的 Region(默认 1~32MB,2 的幂次),每个 Region 在逻辑上扮演 Eden、Survivor 或 Old 的角色,但物理上不再连续。
读图导引:粉色节点是新生代 Region,蓝色节点是 Humongous Region(存放大对象,直接进老年代)。注意 Region 之间物理不连续——G1 用指针和 RSet 维护跨 Region 引用关系。
术语锚定:
- Region:G1 的最小管理单元,默认 2048 个;小对象(< Region 的 1/2)走正常分配,大对象(> Region 的 1/2)直接进入 Humongous Region
- CSet(Collection Set):本次 GC 要回收的 Region 集合,G1 根据停顿目标动态选择 CSet 大小
- RSet(Remembered Set):每个 Region 维护的"谁引用了我"的反向索引,避免全堆扫描
ZGC 的染色指针
ZGC 最激进的创新是把元数据存在指针里,而不是对象头中。
在 64 位系统中,指针只用了低 48 位(寻址 256TB)。ZGC 从中再借出高 4 位(第 42~45 位),用于编码对象的 GC 状态:
|11111111 11111111 11111111 11111111 11111111 11111100 00000000 00000000|
|<-------- 未使用 -------->|<-M->|<-R->|<-- 实际地址(42位)--->|
Mark0 Mark1 Remapped Finalizable
读图导引:粉色节点是 ZGC 的魔法——把 GC 状态编码进指针本身,不需要访问对象就能判断对象是否已标记或已重映射。蓝色节点是实际地址,42 位可寻址 4TB,已覆盖绝大多数场景。
术语锚定:
- 染色指针(Colored Pointers):在指针中嵌入元数据位,通过指针颜色判断对象状态;优势是不需访问对象即可获取 GC 信息,且多个映射视图可共存
- Remapped:表示对象已完成重映射(Relocation),指向最新地址;未设置 Remapped 的指针需经过转发表(Forwarding Table)解析
- 读屏障(Load Barrier):在加载对象引用到寄存器之前执行的逻辑;ZGC 的读屏障检查指针颜色,必要时触发重映射或标记
原理分析
G1 的回收流程
G1 的 Young GC 和 Mixed GC 都围绕 CSet 展开:
读图导引:粉色节点是 G1 的停顿点。注意 G1 的停顿集中在"根扫描""重新标记""清理"三个阶段,而存活对象的复制(最耗时的操作)与标记一样,可以与用户线程并发。
预测模型:G1 根据历史数据估算每个 Region 的回收价值(垃圾比例 / 回收耗时),在满足 -XX:MaxGCPauseMillis(默认 200ms)的前提下,选择性价比最高的 Region 组成 CSet。
暗面:这个模型依赖统计规律,如果应用行为突变(如突然涌入一批大对象),预测会失效,导致实际停顿超过目标。-XX:+UseAdaptiveSizePolicy 可以部分缓解,但无法根治。
ZGC 的并发整理
ZGC 的核心突破是并发整理——在用户线程运行的同时,把对象从旧位置复制到新位置。
传统 GC(包括 G1)的整理/复制必须 STW,因为用户线程可能正在读取被移动对象的地址。ZGC 通过染色指针 + 读屏障解决这个一致性问题:
读图导引:注意分支判断——如果指针已经是 Remapped 状态,读屏障几乎无开销;如果尚未重映射,则需要查转发表并修正指针。关键在于"修正"发生在读屏障内部,对用户代码透明。
并发整理的实际流程:
- 标记:遍历 GC Roots,通过染色指针设置 Marked0/Marked1(交替使用两色区分不同 GC 周期)
- 重定位准备:选择需要整理的 Region(通常是垃圾最多的)
- 并发重定位:GC 线程复制对象到新 Region,旧位置留下转发表条目;用户线程通过读屏障自动路由到新位置
- 并发重映射:遍历所有对象引用,将旧指针批量更新为 Remapped 新指针;也可以惰性处理(用户线程访问到旧指针时,读屏障顺便修正)
术语锚定:
- 转发表(Forwarding Table):记录旧地址到新地址的映射,通常放在被清空的 Region 中;重映射完成后,转发表被回收
- 重映射(Remapping):将指向旧地址的指针更新为指向新地址的过程;可以并发批量做,也可以读屏障惰性做
Shenandoah 的 Brooks Pointer
Shenandoah 是 OpenJDK 的另一款低延迟 GC,目标与 ZGC 类似(<10ms 停顿),但技术路线不同。
Shenandoah 在每个对象前面加一个Brooks Pointer(间接指针),指向对象当前实际位置:
|------------|--------------------------------|
| Brooks Ptr | 对象头 | 实例数据 | 对齐填充 |
| (8 bytes) | |
|------------|--------------------------------|
对象被移动时,更新 Brooks Pointer 即可,不需要修改所有引用该对象的指针。这与 ZGC 的"修正所有指针"形成对照:
| 维度 | ZGC | Shenandoah |
|---|---|---|
| 间接层 | 染色指针(无额外空间) | Brooks Pointer(每个对象 +8 字节) |
| 指针修正 | 读屏障惰性修正 | Brooks Pointer 原子更新 |
| 读屏障 | 需要(检查颜色) | 需要(检查转发标记) |
| 内存开销 | 无对象头开销 | 每个对象固定开销 |
| JDK 状态 | 生产就绪(JDK 15+) | 生产就绪(JDK 12+,非 Oracle JDK) |
读图导引:粉色节点展示 ZGC 的零额外开销——染色指针不修改对象布局;蓝色节点展示 Shenandoah 的间接层——Brooks Pointer 是对象前的固定头部,对象移动时只更新它。
暗面:Brooks Pointer 的内存开销在大对象上可以忽略,但在海量小对象场景下(如缓存系统)会显著增加内存 footprint。ZGC 的染色指针虽然在 64 位架构上免费,但严格依赖 CPU 的硬件虚拟地址支持,32 位系统无法使用。
JDK 21 的分代 ZGC
JDK 21 之前,ZGC 是单代设计——不分 Eden/Survivor/Old,所有对象一视同仁。这简化了实现,但牺牲了小对象高频分配场景的吞吐量。
JDK 21 引入分代 ZGC(JEP 439):
- 年轻代 Region:专用于新对象分配,使用高频低延迟的回收策略
- 老年代 Region:对象晋升后进入,使用完整的并发标记-整理策略
- 独立 Remembered Set:年轻代 GC 时不需要扫描整个老年代
读图导引:粉色节点(年轻代)是性能提升的关键——新对象"朝生夕灭"的特性在分代模型下被充分利用,回收效率远超单代的统一处理。
暗面:分代引入了跨代引用追踪的复杂度,需要维护额外的 Remembered Set;年轻代和老年代的 Region 比例需要动态调整,调优比单代更复杂。
实战与选型
GC 选型决策树
读图导引:粉色节点是低延迟场景的必经之路;蓝色节点是 JDK 21 的新选择——分代 ZGC 在大多数场景下优于单代。
关键参数速查
| GC | 关键参数 | 说明 |
|---|---|---|
| G1 | -XX:MaxGCPauseMillis=200 |
目标停顿时间,默认 200ms |
| G1 | -XX:G1HeapRegionSize=16m |
Region 大小,默认根据堆自动计算 |
| ZGC | -XX:+UseZGC |
开启 ZGC |
| ZGC | -XX:ZCollectionInterval |
强制 GC 间隔(秒) |
| ZGC | -XX:+ZGenerational |
JDK 21 开启分代 ZGC |
| Shenandoah | -XX:+UseShenandoahGC |
开启 Shenandoah |
常见问题
Q1:ZGC 的停顿真的与堆大小无关吗?
是的。ZGC 的 STW 阶段只有初始标记和最终标记的极短暂停(通常在 1ms 以内),这些操作的工作量只与 GC Roots 的数量有关,与堆大小无关。并发阶段(标记、重定位、重映射)的耗时与堆大小相关,但不触发 STW。
Q2:为什么 ZGC 不支持 32 位系统?
染色指针依赖 64 位地址空间的高位空闲位来编码元数据。32 位系统的地址位全部用于寻址(最多 4GB),没有多余的位可用。
Q3:G1 的 Mixed GC 和 Full GC 有什么区别?
Mixed GC 回收年轻代 + 部分老年代 Region(按停顿目标选择 CSet),是 G1 的常规操作。Full GC 是降级方案——当 Mixed GC 来不及回收、老年代空间耗尽时,退化为单线程 Serial Old 算法回收整堆,停顿极长。
Q4:ZGC 和 Shenandoah 哪个更好?
两者目标相似但适用场景略有不同:
- ZGC:Oracle/OpenJDK 官方主推,JDK 21 分代 ZGC 吞吐量已接近 G1,生态支持更好
- Shenandoah:Red Hat 主导,在 JDK 17 LTS 中已成熟,对旧 JDK 版本兼容更好(JDK 12+)
实际选择建议:JDK 21+ 优先分代 ZGC;JDK 17 且使用 Red Hat/Corretto 等发行版可考虑 Shenandoah。
总结
低延迟 GC 的演进,是从"减少 STW"到"消灭 STW"的技术长征。
- G1 用 Region 化和停顿预测模型,把 CMS 的"秒级不可控停顿"压缩到"百毫秒级可控停顿"
- ZGC 用染色指针和读屏障,实现了并发整理,把停顿压到亚毫秒级,且与堆大小解耦
- Shenandoah 用 Brooks Pointer 走了一条不同的并发整理路线,同样达成亚毫秒目标
- 分代 ZGC(JDK 21)补上了单代 ZGC 的吞吐量短板,成为新一代默认选择
选型没有银弹:几百毫秒可接受、堆不大、且使用 JDK 8/11 的场景,G1 仍是稳妥之选;但对延迟敏感(金融交易、实时游戏、高频缓存)、大堆(>32GB)、且能使用 JDK 21 的系统,分代 ZGC 已经是更优的答案。