分代 ZGC 深度解析:从染色指针到双代并发回收
适用版本:JDK 21+(分代模式 JEP 439)/ JDK 23+(默认模式 JEP 474)
关键词:染色指针、读屏障、并发转移、记忆集、弱分代假设
一、版本演进时间线
| 版本 | 变化 | JEP |
|---|---|---|
| JDK 11 | ZGC 实验性引入,单代架构 | JEP 333 |
| JDK 13 | 最大堆从 4TB 扩展到 16TB | — |
| JDK 15 | ZGC 正式转正(Production) | JEP 377 |
| JDK 21 | 分代 ZGC 引入,需-XX:+ZGenerational显式开启 | JEP 439 |
| JDK 23 | 分代成为默认,非分代模式废弃 | JEP 474 |
| JDK 24 | 非分代实现代码彻底移除 | — |
从 JDK 24 起,“ZGC” 就只指分代 ZGC。
二、为什么单代 ZGC 需要分代
单代 ZGC 的目标是:停顿时间不超过 10 ms(实际通常 <1 ms),且停顿与堆大小无关。它通过染色指针 + 读屏障把标记、转移、指针修正全部并发化,确实做到了亚毫秒停顿。但它有一个结构性软肋:
单代 ZGC 的两大痛点
每轮 GC 都要处理全堆
不管对象死活,所有存活对象每轮都要重新标记一遍。堆越大、存活集越大,每轮 GC 的 CPU 开销越高——这些 CPU 本可以跑业务线程。分配停顿(Allocation Stall)
这是最疼的一个。业务线程分配速度一旦超过并发回收释放内存的速度,分配请求就会被强制等待——这个等待不是 STW,但对请求延迟的影响和 STW 一样致命,而且可能持续几百毫秒。单代大堆 + 高分配速率的服务的典型症状就是 P99.9 毛刺。
分代的解法:弱分代假设
绝大多数对象朝生夕死。把堆切成两个代:
- 年轻代(小):高频回收,回收时只扫年轻代 + 记忆集,单轮成本极低,能快速释放内存跟上分配速率;
- 老年代(大):低频回收,老年代里垃圾比例低、且增长慢,全堆并发标记的次数大幅减少。
结果就是:单轮 GC 变小变快、GC 总 CPU 开销显著下降、分配停顿大幅减少——官方 JEP 439 给出的典型数据是堆占用约减半、吞吐约提升 10%。
三、三大基石机制(单代与分代共享)
3.1 染色指针(Colored Pointers)
ZGC 的对象引用本身携带 GC 元数据。64 位指针的布局(JDK 13+):
- Marked0 / Marked1:两轮标记交替使用,区分"本轮标记过"与"上轮标记过";
- Remapped:指针已完成转移后的修正状态;
- Finalizable:仅与 Finalizer 可达对象相关。
配合多重映射(multi-mapping):同一份物理内存被映射到三个虚拟地址视图(Marked0 视图、Marked1 视图、Remapped 视图)。GC 阶段切换 = 切换"当前好颜色",指针数值不变、语义改变,从而避免全堆遍历改写引用。
代价:RSS 虚高(同一物理内存映射多次)、仅支持 64 位、不支持压缩指针(CompressedOops)。
3.2 读屏障(Load Barrier)
JIT 在每次加载对象字段引用的代码处内联插入几条检查指令:
关键设计是自愈(self-healing):慢路径把指针修好后回写,同一线程对同一引用的后续访问全部回到快路径,修复成本被摊薄。正是读屏障让标记、转移、指针修正能与应用线程真正并发执行——这是 ZGC 停顿与堆大小无关的根源。
3.3 并发转移(Concurrent Relocation)
- 每轮 GC 选取垃圾密度高的页组成转移集(Relocation Set);
- 存活对象被并发复制到新页,旧页整页丢弃——天然完成碎片整理;
- 转发表(Forwarding Table)记录对象"旧地址 → 新地址",读屏障据此完成引用修正。
四、分代架构设计
4.1 堆结构
- 全堆仍由**等大 Region(页)**组成,页的类型(小/中/大)与代独立;
- 小对象经 TLAB 在年轻代无锁快速分配;大对象直接进对应页;
- Young GC 判定死亡的页整页回收复用,不做逐对象清理。
4.2 晋升策略:极简主义
这是与 G1/CMS 最大的概念差异之一:
| G1 / CMS | 分代 ZGC | |
|---|---|---|
| 新生代细分 | Eden + 两个 Survivor | 无细分 |
| 年龄计数 | Age table,逐代复制 | 无年龄计数 |
| 晋升条件 | 达到-XX:MaxTenuringThreshold | 存活过一轮 Young GC 即整体晋升 |
| 动态年龄推断 | 有 | 无 |
ZGC 的哲学:与其在年轻代里反复倒腾短命对象,不如让年轻代保持极小,一次复制直接到位。
4.3 跨代引用与记忆集(Remembered Set)——分代的核心代价
分代后,Young GC 面临“老年代里哪些对象指着年轻代”的问题。朴素解法是扫全堆——那就白分代了。ZGC 的方案:
- 写屏障 + 记忆集:JIT 在字段写入处插入 store barrier。当检测到“老年代对象的字段被写入了一个年轻代引用”时,将该对象所在的页登记进记忆集(按页粒度,记忆集本身分配在堆里);
- 允许假阳性:记忆集里可能登记了已经不再指向年轻代的页——多扫一点无妨,只影响效率不影响正确性;
- Young GC 的根集合= GC roots + 年轻代对象图 + 记忆集指向的老年代页,绝不扫整个老年代。
代价:写屏障带来少量运行时开销,记忆集带来额外内存占用——这是分代 ZGC 相对单代的主要 trade-off。
4.4 Young GC 并发周期
- 仅有的两个 STW 只处理根集合扫描,时长只与线程数/根数量相关,与堆大小、存活集大小无关;
- 标记根集合 = GC roots + 年轻代 + 记忆集;
- Old GC 是独立的全堆并发周期,频率低,可与 Young GC 穿插进行。
五、分代 ZGC vs 单代 ZGC 全面对比
| 维度 | 单代 ZGC | 分代 ZGC |
|---|---|---|
| 引入版本 | JDK 11(JEP 333) | JDK 21(JEP 439) |
| 当前状态 | JDK 23 废弃,JDK 24 移除 | JDK 23+ 唯一形态 |
| 开启方式 | -XX:+UseZGC(JDK ≤ 22) | JDK 21/22 需加-XX:+ZGenerational;JDK 23+ 直接-XX:+UseZGC |
| 堆划分 | 单一代,全堆 Region | 年轻代 + 老年代,比例自适应 |
| 单轮 GC 范围 | 全堆 | Young GC 只扫年轻代 + 记忆集 |
| GC 频率 | 每轮都是全量 | Young GC 高频小额 + Old GC 低频全量 |
| 每轮 GC CPU 开销 | 高(全堆标记转移) | 低(大多数轮次只碰年轻代) |
| 存活对象处理 | 每轮全部重新标记 | 年轻代存活一轮即晋升,老年代极少重复标记 |
| 跨代引用 | 不存在该问题 | 写屏障 + 页粒度记忆集 |
| 写屏障开销 | 无 | 有(少量运行时成本) |
| 记忆集内存开销 | 无 | 有 |
| 晋升机制 | 不适用 | 存活过一轮即整体晋升,无 survivor、无年龄阈值 |
| 停顿时间 | 亚毫秒 | 亚毫秒(同样与堆大小无关) |
| 分配停顿风险 | 高分配速率下明显 | 大幅减少(年轻代高频回收跟上分配速率) |
| 堆内存占用 | 高(垃圾需等全堆 GC 才释放) | 约减半(官方典型值) |
| 吞吐量 | 基准 | 约 +10%(官方典型值) |
| 调优复杂度 | 参数少但可用手段有限(大堆下基本靠加堆) | 基本免调,代际比例自适应 |
| JDK 21 限制 | 功能完整 | 不支持ZAllocationSpikeTolerance、ZProactive、ZUncommit等(后续版本补齐) |
| 适用场景 | 已无理由选择 | 大堆 + 低延迟 + 高分配速率 |
收益可视化(归一化,官方 JEP 439 典型值)
| 指标 | 单代 ZGC | 分代 ZGC |
|---|---|---|
| 堆内存占用 | ████████████████████ 100 | ██████████ ~50 |
| 吞吐量 | ████████████████████ 100 | ██████████████████████ ~110 |
一句话总结差异
单代 ZGC 解决了“停顿与堆大小无关”,分代 ZGC 在此基础上解决了“GC 的 CPU 开销与堆大小成比例”的问题——用弱分代假设把绝大多数回收工作压缩到一个小得多的年轻代里完成。
六、参数与实战
# JDK 21 / 22:显式开启分代模式java-XX:+UseZGC-XX:+ZGenerational-jarapp.jar# JDK 23+:默认即分代java-XX:+UseZGC-Xms32g-Xmx32g-XX:+UseLargePages-jarapp.jar# 常用配套-XX:SoftMaxHeapSize=24g# 软上限:GC 尽力把用量压回该值以下-XX:ConcGCThreads=4# 并发 GC 线程数(默认约核数的 1/4)-XX:ParallelGCThreads=8# STW 阶段并行线程数实战注意事项:
- 不要手动设代际比例——ZGC 自适应,这是与 G1 使用习惯最大的差异;
- JDK 21 首发的分代模式部分调优参数不可用,升级前用
-XX:+PrintFlagsFinal核对; - 生产环境强烈建议开大页(
-XX:+UseLargePages),多重映射对 TLB/页表压力敏感; - 监控不要裸看进程 RSS(多重映射虚高),用 JFR 的 ZGC 事件或
jstat -gcutil; - 选型:堆 < 8GB 或追求极致吞吐 → G1;数十 GB 大堆 + P99 延迟敏感(交易、风控、实时行情)→ 分代 ZGC。
七、面试要点速记
- 一句话定位:分代 ZGC = 染色指针 + 读屏障 + 并发转移(单代三基石)之上,叠加弱分代假设 + 写屏障记忆集;
- 为什么停顿与堆大小无关:STW 阶段只扫根集合;标记、转移、指针修正全部并发,外包给读屏障;
- 为什么单代不够:全堆标记 CPU 开销随堆线性增长 + 分配停顿风险;
- 分代的新问题:跨代引用 → 页粒度记忆集,允许假阳性、只影响效率不影响正确性;
- 有数字:堆占用约减半、吞吐约 +10%、停顿持续亚毫秒;
- 有版本:21 引入、23 默认、24 移除非分代。