问题引入

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 的角色,但物理上不再连续。

graph LR subgraph "G1 堆布局" E1[Eden Region] E2[Eden Region] S1[Survivor Region] O1[Old Region] O2[Old Region] H1[Humongous Region] end style E1 fill:#f9f,stroke:#333,stroke-width:2px style E2 fill:#f9f,stroke:#333,stroke-width:2px style H1 fill:#bbf,stroke:#333,stroke-width:2px

读图导引:粉色节点是新生代 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
graph TD A[64位指针] --> B[高16位: 未使用] A --> C[第42-45位: 颜色元数据] A --> D[低42位: 实际地址] C --> E[Marked0] C --> F[Marked1] C --> G[Remapped] C --> H[Finalizable] style C fill:#f9f,stroke:#333,stroke-width:2px style D fill:#bbf,stroke:#333,stroke-width:2px

读图导引:粉色节点是 ZGC 的魔法——把 GC 状态编码进指针本身,不需要访问对象就能判断对象是否已标记或已重映射。蓝色节点是实际地址,42 位可寻址 4TB,已覆盖绝大多数场景。

术语锚定

  • 染色指针(Colored Pointers):在指针中嵌入元数据位,通过指针颜色判断对象状态;优势是不需访问对象即可获取 GC 信息,且多个映射视图可共存
  • Remapped:表示对象已完成重映射(Relocation),指向最新地址;未设置 Remapped 的指针需经过转发表(Forwarding Table)解析
  • 读屏障(Load Barrier):在加载对象引用到寄存器之前执行的逻辑;ZGC 的读屏障检查指针颜色,必要时触发重映射或标记

原理分析

G1 的回收流程

G1 的 Young GC 和 Mixed GC 都围绕 CSet 展开:

flowchart TD A[开始GC] --> B[选择CSet] B --> C[更新RSet] C --> D[根扫描 STW] D --> E[并发标记] E --> F[重新标记 STW] F --> G[清理 STW] G --> H[复制存活对象到空Region] H --> I[释放CSet Region] style D fill:#f9f,stroke:#333,stroke-width:2px style F fill:#f9f,stroke:#333,stroke-width:2px style G fill:#f9f,stroke:#333,stroke-width:2px

读图导引:粉色节点是 G1 的停顿点。注意 G1 的停顿集中在"根扫描""重新标记""清理"三个阶段,而存活对象的复制(最耗时的操作)与标记一样,可以与用户线程并发。

预测模型:G1 根据历史数据估算每个 Region 的回收价值(垃圾比例 / 回收耗时),在满足 -XX:MaxGCPauseMillis(默认 200ms)的前提下,选择性价比最高的 Region 组成 CSet。

暗面:这个模型依赖统计规律,如果应用行为突变(如突然涌入一批大对象),预测会失效,导致实际停顿超过目标。-XX:+UseAdaptiveSizePolicy 可以部分缓解,但无法根治。

ZGC 的并发整理

ZGC 的核心突破是并发整理——在用户线程运行的同时,把对象从旧位置复制到新位置。

传统 GC(包括 G1)的整理/复制必须 STW,因为用户线程可能正在读取被移动对象的地址。ZGC 通过染色指针 + 读屏障解决这个一致性问题:

sequenceDiagram participant USER as 用户线程 participant LB as 读屏障 participant OLD as 旧地址 participant FT as 转发表 participant NEW as 新地址 USER->>LB: 加载对象引用 LB->>LB: 检查指针颜色 alt Remapped已设置 LB-->>USER: 直接返回新地址 else Remapped未设置 LB->>FT: 查询转发表 FT-->>LB: 返回新地址 LB->>LB: 修正指针为Remapped LB-->>USER: 返回新地址 end Note over LB: 这个过程是原子且快速的

读图导引:注意分支判断——如果指针已经是 Remapped 状态,读屏障几乎无开销;如果尚未重映射,则需要查转发表并修正指针。关键在于"修正"发生在读屏障内部,对用户代码透明。

并发整理的实际流程

  1. 标记:遍历 GC Roots,通过染色指针设置 Marked0/Marked1(交替使用两色区分不同 GC 周期)
  2. 重定位准备:选择需要整理的 Region(通常是垃圾最多的)
  3. 并发重定位:GC 线程复制对象到新 Region,旧位置留下转发表条目;用户线程通过读屏障自动路由到新位置
  4. 并发重映射:遍历所有对象引用,将旧指针批量更新为 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)
graph LR subgraph ZGC["ZGC"] Z1[引用] -->|染色指针| Z2[对象] end subgraph Shenandoah["Shenandoah"] S1[引用] -->|直接指针| S2[Brooks Pointer] S2 -->|间接指针| S3[对象] end style Z2 fill:#f9f,stroke:#333,stroke-width:2px style S2 fill:#bbf,stroke:#333,stroke-width:2px

读图导引:粉色节点展示 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 时不需要扫描整个老年代
graph TD subgraph "单代 ZGC" U1[统一Region] --> U2[所有对象一起处理] end subgraph "分代 ZGC" Y[年轻代Region] --> Y1[高频回收<br/>低停顿] O[老年代Region] --> O1[并发整理<br/>低停顿] end style Y fill:#f9f,stroke:#333,stroke-width:2px style O fill:#bbf,stroke:#333,stroke-width:2px

读图导引:粉色节点(年轻代)是性能提升的关键——新对象"朝生夕灭"的特性在分代模型下被充分利用,回收效率远超单代的统一处理。

暗面:分代引入了跨代引用追踪的复杂度,需要维护额外的 Remembered Set;年轻代和老年代的 Region 比例需要动态调整,调优比单代更复杂。

实战与选型

GC 选型决策树

graph TD A[选择GC] --> B{停顿要求} B -->|< 1秒| C{堆大小} B -->|< 10ms| D[ZGC / Shenandoah] C -->|< 4GB| E[G1] C -->|> 4GB| F[G1 / ZGC] D --> G{JDK版本} G -->|JDK 21+| H[分代ZGC] G -->|JDK 17| I[单代ZGC] style D fill:#f9f,stroke:#333,stroke-width:2px style H fill:#bbf,stroke:#333,stroke-width:2px

读图导引:粉色节点是低延迟场景的必经之路;蓝色节点是 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 已经是更优的答案。