Skip to content

GC 现代化:ZGC 与 Shenandoah ​

JDK9 把默认收集器换成 G1,JDK14 正式移除 CMS——「并发低停顿」的接力棒交给了 ZGC 和 Shenandoah。面试问 GC 现代化,本质考三件事。一是 CMS 为什么死(停顿模型演进的动因)。二是 ZGC/Shenandoah 靠什么做到亚毫秒停顿(并发整理的两大技术路线)。三是 G1 为什么仍是默认(选型判断)。基础概念(分代/标记算法/各收集器简介)先回看 垃圾收集器 与 垃圾收集算法。

1 从 CMS 退役说起:为什么 CMS 死了 ​

CMS(Concurrent Mark Sweep)的思路曾很先进:标记和清除大部分与用户线程并发,停顿降到百毫秒级。但它有原罪——只清除、不整理:

  • 碎片化:标记-清除后老年代千疮百孔,大对象/promotion 找不到连续空间;
  • concurrent mode failure:并发回收期间用户线程还要分配,预留空间不够时并发回收失败。退化为 Serial Old 单线程 Full GC,大堆上停顿秒级甚至分钟级,一次就打爆 SLA。为了躲这个,调 CMSInitiatingOccupancyFraction 成了一门玄学;
  • 浮动垃圾:并发标记期间新死亡的对象只能下轮再收,进一步压缩预留空间;
  • 并发抢 CPU:并发阶段与业务争核,吞吐让位。

时间线:JDK5 引入 → JDK9 弃用(JEP 291)→ JDK14 移除(JEP 363)。死因总结一句话:不整理碎片,停顿就永远有失控下限。所以后继者的共同目标都是「并发整理」:G1 用 region 复制回避碎片,ZGC/Shenandoah 直接把「对象移动」也做成并发。停顿模型演进:

收集器世代停顿模型
Serial / Parallel1.3+全程 STW,停顿随堆增长(秒级)
CMSJDK5标记/清除并发,停顿百 ms 级,但有退化 Full GC 风险
G1JDK7 / 9 默认region 化增量复制,停顿可配置(默认目标 200ms),与堆部分相关
ZGC / ShenandoahJDK11 / 12标记、转移全并发,停顿 <1ms 且与堆大小无关

2 ZGC:着色指针 + 读屏障 ​

ZGC(JDK11 实验、JDK15 生产可用、JEP 333/377)的目标:停顿亚毫秒、与堆大小/存活集无关,堆上限 16 TB(JDK13 起)。两大核心技术:

着色指针(colored pointer):把 GC 状态直接编码进 64 位指针的高位空闲位。用 Marked0/Marked1/Remapped 等颜色表示「这个引用处于哪个视图」(是否已标记、是否已重映射)。低 42+ 位才是对象地址:

 64 位引用被拆成两段:
┌───────────────────┬───────┬───────┬───────┬───────┬──────────────────┐
│     高位空闲        │Finaliz│Remapped│Marked1│Marked0│   对象地址(低位)  │
└───────────────────┴───────┴───────┴───────┴───────┴──────────────────┘
                      └──────── 4 个元数据位 = 4 种「颜色」 ────────┘

读屏障(load barrier)+ 自愈(self-healing):并发移动对象时,旧引用不可能立刻全改完。ZGC 在每次引用读取后插一小段检查(几条机器指令),颜色不对就现场修正——这叫自愈:

用户线程执行 ref = obj.field:
┌── load barrier(编译进业务代码的几条指令)──┐
│ 检查 ref 的颜色是否为当前「好视图」            │
│   ├─ 是 → 直接用(快路径,绝大多数走这里)      │
│   └─ 否 → 慢路径:对象若已转移则取新地址,        │
│          把 field 修正为最新引用再返回            │
│          (顺带完成重映射,即 self-healing)      │
└──────────────────────────────────────────┘

整理(对象转移)因此可以完全并发:GC 移动对象,用户线程读到旧引用时自己愈合。整个周期只有 3 次极短 STW:

STW①初始标记 ─► 并发标记 ─► STW②再标记 ─► 并发预备转移 ─► STW③初始转移 ─► 并发转移
  (根, <1ms)     (不停顿)    (根处理)      (不停顿)        (根, <1ms)     (不停顿)

分代 ZGC:无分代 ZGC 的软肋是分配速率。所有对象一起并发标记,对象分配越快,GC 越追不上,只能堆并发线程,吞吐崩掉。JDK21 引入分代(JEP 439,-XX:+ZGenerational):

  • 年轻代走独立的小周期:绝大多数对象朝生夕死。只标记转移年轻代便宜得多,老年代大周期解耦跑;
  • 代价是引入跨代引用的记忆集(card table 维护成本),以及两代各自的元数据开销——仍是「空间换时间」的老套路。

JDK23 起分代成为默认且非分代模式移除(JEP 490)。参数与调优要点:

bash
-XX:+UseZGC                 # 启用 ZGC(JDK23 起自带分代)
-XX:+ZGenerational          # JDK21 显式开分代
-XX:SoftMaxHeapSize=12g     # 软上限:GC 尽量把堆压回该值内,物理上限仍是 Xmx
-XX:ConcGCThreads=4         # 并发 GC 线程,默认约核数 1/4
-Xlog:gc*                   # GC 日志

调优哲学是「少调参」。给足内存(live set 的 1.5~2 倍余量,用 SoftMaxHeapSize 约束软上限)。给足 CPU(并发线程要核)。盯分配速率而不是停顿目标。它没有 G1 那种 MaxGCPauseMillis 模型,停顿天生就低。

两个常见误判。一是把 ZGC 当银弹切到小于 8G 的小堆。读屏障开销占比变大、并发整理余量不足,效果反而不如 G1(甜点区基本在大于 32G)。二是迁移验证只盯停顿数字。要同流量对比 G1 与 ZGC 的吞吐和 CPU 占用,两边一起看再下结论。

3 Shenandoah:转发指针 + 引用读屏障 ​

Shenandoah(Red Hat 主导,JDK12,JEP 189)目标与 ZGC 相同:并发整理、亚毫秒停顿。但技术路线不同——转发指针(forwarding pointer,Brooks pointer)。每个对象多出一个字段,平时指向自己;并发转移后指向新副本。旧引用不用立刻改写,访问时顺着转发指针找到对象本体:

┌────────┬────────────────┬─────────┐
│ 对象头  │ forwarding →┐  │ 数据... │      转移前:指向自己
└────────┴─────────────┼──┴─────────┘
                         │  并发转移后:指向新副本
                         ▼
              ┌────────┬──────────────┬─────────┐
              │ 对象头  │ forwarding → │ 新数据.. │   新副本(旧内存稍后回收)
              └────────┴──────────────┴─────────┘

配套的是引用读屏障(load reference barrier)+ SATB 并发标记。读引用时检查并转发/更新,和 ZGC 思路同源。只是状态存在对象头侧而非指针颜色里。STW 也只剩初始/最终标记两次,均 <1 ms。

一个常被忽略的演进细节:早期 Shenandoah 用写屏障维护转发指针,吞吐损失太大。2.0(JDK13 起)改为引用读屏障模型才把吞吐拉回可用区间。这也是它和 ZGC 在屏障技术上最终趋同的原因。与 ZGC 对比:

维度ZGCShenandoah
状态存放着色指针(指针高位 4 bit)转发指针(每对象 +1 字)
屏障读屏障(load barrier)引用读屏障 + SATB
内存开销分代前用多视图多重映射,分代后单一映射每对象一个转发指针字(百分之几级)
堆上限16TB实际数百 GB 量级
分代21 引入、23 默认21 加入分代模式(默认关)
发行版Oracle JDK / OpenJDK 均包含Oracle JDK 不包含,Corretto/Red Hat 等包含
出身Oracle(Per Liden)Red Hat(Roman Kennke)

两者停顿/吞吐同数量级。选型更多看发行版支持与运维熟悉度,而不是纸面参数。国内业务绝大多数默认落在 Oracle 系发行版上,实际落地以 ZGC 居多,Shenandoah 多见于 Red Hat 生态。

追问时若被问「为什么 JVM 不把低停顿做成默认」,标准答案就是第 4 节的权衡。默认配置要照顾所有堆规模与业务形态,G1 是折中的最大公约数。

4 G1 仍是默认:场景判断与选型 ​

JDK9 起默认 G1(垃圾收集器 有详细介绍)。region 化(1~32 MB)+ 停顿预测模型,把每次收集控制在 MaxGCPauseMillis 目标内,兼顾吞吐与停顿。对 4~32 GB 常规堆、百 ms 级停顿可接受的业务,G1 成熟度、工具链、调优资料都最厚。ZGC 的低停顿不是白来的。读屏障是常驻开销(吞吐略降)、并发整理需要内存余量、并发线程吃 CPU。小堆上得不偿失。选型表:

场景推荐理由
堆 <4G,批处理/离线计算,不在乎停顿Parallel吞吐优先,无屏障开销
通用 Web 服务,堆 4~32G,停顿百 ms 内可接受G1(默认)成熟可控,无需激进调优
大堆(32G~百 G+),要求 P99 停顿 <10msZGC亚毫秒停顿且与堆无关
同上,但发行版/团队栈在 Red Hat 系Shenandoah同级别停顿能力
TB 级超大堆、低延迟金融/风控ZGC16TB 堆上限

判断口诀:先问停顿预算,再问堆大小,最后看资源余量。停顿不敏感要吞吐选 Parallel/G1。停顿敏感 + 大堆 + 有 CPU/内存余量才上 ZGC/Shenandoah。换 ZGC 前先确认分配速率与 live set(堆结构见 Java内存结构),并准备好 JFR 观测。留一份 G1 常用参数做对照(详见 垃圾收集器):

bash
-XX:+UseG1GC                            # JDK9+ 默认已开
-XX:MaxGCPauseMillis=200                # 停顿软目标(不是硬承诺)
-XX:InitiatingHeapOccupancyPercent=45   # 并发标记启动阈值(IHOP,自适应)
-XX:G1HeapRegionSize=4m                 # region 大小 1~32MB,大对象超半 region 即 humongous

观测口径两种模型不一样。G1 看 Mixed GC 频率、转移速率与停顿分布(-Xlog:gc*、G1 Evacuation Pause 事件)。ZGC/Shenandoah 重点盯分配速率和并发阶段 CPU 占比。停顿通常稳定到不需要天天看——这也是「现代化」的运维收益之一。

面试追问链 ​

  • CMS 为什么被移除?→ 标记-清除碎片化 → concurrent mode failure 退化 Serial Old;不只是"旧",是停顿失控的结构性问题;
  • ZGC 停顿为什么与堆大小无关?→ STW 只做根集合的初始标记/转移,标记与整理主体全并发;
  • 着色指针是什么?为什么需要?→ GC 状态编码进指针高位,配合读屏障判断视图;状态跟引用走,转移时无需全局同步;
  • 读屏障的代价?→ 每次引用读取多几条指令,吞吐让位(几个百分点到十几个百分点),所以小堆别用;
  • ZGC 怎么调优?→ 少调参:内存给 1.5~2 倍 live set、CPU 给足并发线程、盯分配速率;没有 MaxGCPauseMillis 模型;
  • Shenandoah 和 ZGC 区别?→ 转发指针(状态在对象侧)vs 着色指针(状态在引用侧);发行版差异;
  • 既然 ZGC 这么强,为什么默认还是 G1?→ 绝大多数堆 4~32G、停顿要求不极端,G1 更省 CPU/内存且生态最成熟。

面试官视角小结 ​

GC 现代化是展示「理解演进逻辑」的最好话题。及格线是背出「G1 默认、ZGC 低停顿」。良好是讲清 CMS 的死因链条(碎片 → 并发失败 → 退化 Full GC)和停顿模型演进表。资深信号是能展开着色指针/读屏障/转发指针的并发整理原理。并给出「停顿预算 + 堆大小 + 资源余量」三步选型论证,同时知道 ZGC 低停顿的吞吐/内存代价。能顺口说出 JDK21 分代 ZGC(JEP 439)与 JDK23 默认分代,说明持续跟进版本演进。

2026-10 新增(P1 时效升级)。