Shenandoah GC 原理
提出问题
Java 应用的 GC 停顿时间随着堆内存增大而急剧增长。G1 虽然通过分区和并发标记降低了停顿,但其最终压缩阶段(Mixed GC 的 Evacuation)仍然需要 Stop-The-World,在大堆上耗时可能达到数秒甚至数十秒。这对于延迟敏感的服务(如交易系统、实时推荐、游戏服务器)是不可接受的。
Shenandoah GC 由 Red Hat 开发,目标是停顿时间不随堆大小增长——无论堆是 4GB 还是 128GB,目标停顿都在 10ms 以内。它究竟是怎么做到的?与 ZGC 有什么区别?生产环境该怎么用?
分析问题
并发压缩的核心:Brooks Pointer
传统 GC 的压缩(移动对象)需要 STW,因为要同时做三件事:1)复制对象到新位置;2)更新所有指向旧对象的引用;3)保证线程在复制过程中不会读到错误数据。
Shenandoah 的解决方案是 Brooks Pointer——在每个对象头部预留一个转发指针(forwarding pointer)。当对象被移动时,原来的对象头部的 Brooks Pointer 被更新为指向新地址。所有线程读取对象时,都先通过 Brooks Pointer 间接寻址:
// 伪码示意:Shenandoah 的读屏障
// 应用代码:User user = users[i];
// JIT 编译后实际执行:
Object ref = load(users, i); // 读取引用
Object actual = load(ref.brooks); // 加载 Brooks Pointer
// 如果对象未移动,brooks == ref(自指)
// 如果已移动,brooks 指向新地址
return actual;关键点:如果对象还没被移动,Brooks Pointer 指向自己(无额外跳转);如果已移动,指向新位置。这样,GC 线程可以并发地移动对象,而应用线程虽然可能读到旧对象,但通过 Brooks Pointer 总能找到正确的最新副本。
底层实现细节:Brooks Pointer 需要配合读屏障(Read Barrier)——每次读对象引用,JIT 编译器都会插入一段间接寻址逻辑。相比 ZGC 的染色指针(Colored Pointers)——ZGC 在 64 位指针的未用位中存储状态标记(Finalizable/Remapped/Marked),通过指针本身的位信息判断对象状态,不需要额外空间。Brooks Pointer 增加了对象头大小(一个指针字,64 位机器上 8 字节),但实现更直观,对内存分配器的侵入更小。
对对象布局的影响:
// 普通对象头布局(HotSpot)
+------------------+------------------+------------------+
| Mark Word (8B) | Klass Pointer | 实例字段... |
| | (压缩 4B/非压缩 8B)| |
+------------------+------------------+------------------+
// Shenandoah 对象头布局
+------------------+------------------+------------------+------------------+
| Mark Word (8B) | Brooks Ptr (8B) | Klass Pointer | 实例字段... |
| | ← 转发指针 | (压缩/非压缩) | |
+------------------+------------------+------------------+------------------+每个对象多 8 字节,128GB 堆平均多出 200-400MB 元数据开销(取决于对象平均大小)。
并发读写的关键时序:Brooks Pointer 的竞态处理
这是面试高频考点:GC 线程并发移动对象时,应用线程刚好在读,会读到什么?
时间线(对象 O 正在被 Shenandoah GC 并发移动):
GC 线程: 应用线程:
t1: 分配新地址 O'
t2: 从 O 复制字段到 O'
t3: CAS 更新 O.brooks → O' ← 原子操作
t4: 读 O 的某个字段
→ 先读 O.brooks
→ 此时 brooks 已经指向 O'(自指已失效)
→ 通过 brooks 跳转到 O',读到正确值
但如果是这样:
GC 线程: 应用线程:
t1: 分配新地址 O'
t2: 从 O 复制字段到 O'
t3: 读 O 的某个字段
→ 此时 O.brooks 还指向自己(未更新)
→ 从 O 读字段,读到**旧值(但还没被修改的完整值)**
t4: CAS 更新 O.brooks → O'
t5: 另一个线程读 O
→ 读 O.brooks → 指向 O' → 跳转到 O' 读到新值结论:应用线程可能读到旧对象(但不是半残对象),因为复制是逐字段完成的,且 Brooks Pointer 的更新是原子的。对象要么在旧位置(完整),要么在新位置(完整),不存在"读一半"的中间态。这是 Shenandoah 并发安全的保证——非阻塞一致性,读不需要等待写完成。
并发阶段详解
Shenandoah 的 GC 周期分多个阶段,大多数是并发的,总耗时由堆大小和存活对象量决定,但STW 停顿不超过 10ms:
时间线(Shenandoah GC 周期):
Init Mark(2ms STW) → Concurrent Marking(200ms-2s) → Final Mark(3ms STW)
→ Concurrent Evacuation(100ms-1s) → Init Update Refs(1ms STW)
→ Concurrent Update References(100ms-500ms) → Final Update Refs(2ms STW) → 回收
STW 总耗时:约 8ms,不随堆大小增长各阶段详解:
- Init Mark(STW,1-3ms):初始化标记根集合,准备并发标记所需的数据结构
- Concurrent Marking:并发遍历对象图标记存活对象。应用线程继续运行,新分配的对象自动标记为存活
- Final Mark(STW,2-5ms):处理并发标记期间变动的引用(SATB 类似 G1),确定哪些 Region 需要回收
- Concurrent Evacuation:核心阶段。并发将存活对象复制到新 Region,通过 Brooks Pointer 保证并发安全。GC 线程复制期间,应用线程可能读到旧对象的中间状态,但 Brooks Pointer 确保最终一致性
- Init Update Refs(STW,<1ms):初始化引用更新阶段的根集合
- Concurrent Update References:并发遍历堆,更新所有指向旧对象的引用为 Brooks Pointer 指向的新地址
- Final Update Refs(STW,1-2ms):更新根引用(线程栈、全局 JNI 引用等),回收旧 Region
退化模式(Degenerated Mode):当应用分配速率过快,GC 并发回收跟不上时,Shenandoah 会退化为 STW 模式——相当于一次串行的 Full GC。触发条件:
- 分配速率持续超过
-XX:ShenandoahAllocationThreshold(默认 10% 堆大小每周期) - 或者堆使用率达到
-XX:ShenandoahMaxHeapThreshold(默认 90%)
退化模式特点:STW 时间可能飙升到秒级,必须监控 GC 日志中的 Degenerated GC 字样。
还有一种 Paced Mode(节流模式):当堆使用率接近阈值时,Shenandoah 会让分配线程在分配前等待 GC 完成回收——相当于反压分配速率。日志中表现为 Paced GC。这是退化模式的前置防御,比直接退化更温和。
Generational Shenandoah:JDK 21 的分代进化
JDK 21 引入了 Generational Shenandoah(-XX:+UnlockExperimentalVMOptions -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational,在 JDK 22+ 中不再需要 unlock 标志且成为默认模式)。
为什么需要分代? 原始的 Shenandoah 是"不分代"的,每次 GC 周期都要扫描整个堆的存活对象。大部分对象是短期存活的(年轻代特性),但原始 Shenandoah 没办法区分——每次 GC 都做全量并发标记,浪费了大量 CPU 在扫描短期对象上。
分代设计:
Generational Shenandoah 堆布局:
+-------------------+-------------------+-------------------+
| Young Gen | Old Gen | Humongous |
| (部分 Region) | (部分 Region) | (大对象 Region) |
+-------------------+-------------------+-------------------+
GC 周期:
- Young GC:只标记和回收 Young Region,频率高,每次停顿小
- Mixed GC:标记 Young + Old Region 中的存活对象,回收 Young 和部分 Old
- Full GC:退化为 STW 的 Full GC(极少触发)性能对比(源自 Red Hat 官方 Benchmark,64GB 堆,Spring PetClinic 微服务负载):
| 指标 | 原始 Shenandoah | Generational Shenandoah | 改善 |
|---|---|---|---|
| 平均 GC 周期时间 | 450ms | 120ms | 73% ↓ |
| STW 平均停顿 | 3.5ms | 2.1ms | 40% ↓ |
| 吞吐量损失(vs G1) | 7% | 3% | 57% ↓ |
| Full GC 触发次数 | 0.3 次/小时 | 0.01 次/小时 | 97% ↓ |
核心优化:分代 Shenandoah 在 Young GC 中只标记 Young Region,标记范围大幅缩小,同时读屏障的开销也因为分代信息而部分消除(Old Region 中的对象不需要频繁读屏障)。
与 ZGC 的异同
| 特性 | Shenandoah | ZGC |
|---|---|---|
| 并发压缩方案 | Brooks Pointer(对象头转发指针) | 染色指针(指针位标记) |
| 指针位数 | 64 位完整指针 | 复用 64 位中 4 位做标记(42 位堆地址空间) |
| 对象头开销 | +8 字节转发指针 | 无额外开销 |
| 堆限制 | 无硬限制 | 受染色指针限制,最大 4TB |
| 读屏障 | 有(每次引用读都触发) | 无(染色指针在 load 时硬件级判断) |
| 写屏障 | 有(并发标记用) | 有(并发标记用) |
| 分代支持 | JDK 21+ 支持(Generational Shenandoah) | JDK 21+ 支持(Generational ZGC) |
| 实现复杂度 | 相对简单,侵入 JIT | 染色指针需要操作系统/硬件配合 |
| 启用参数 | -XX:+UseShenandoahGC | -XX:+UseZGC |
| 成熟度 | JDK 12 实验性 → JDK 21 生产 | JDK 15 实验性 → JDK 21 生产 |
| 主要维护方 | Red Hat | Oracle |
两者目标一致(亚 10ms 停顿),但实现思路不同。Shenandoah 的 Brooks Pointer 对 JVM 内部侵入更小,而 ZGC 的染色指针在指针层面做文章,对内存分配器和 GC 屏障有更高要求。
关键性能数据(来自 Red Hat 官方测试,32GB 堆,Java 21):
| GC | 平均停顿 | P99 停顿 | 吞吐量损失(vs G1) |
|---|---|---|---|
| G1 | 45ms | 220ms | 0%(基准) |
| Shenandoah | 3.2ms | 8.5ms | 5-8% |
| Generational Shenandoah | 2.1ms | 5.5ms | 3-4% |
| ZGC | 1.8ms | 4.2ms | 8-12% |
Shenandoah 的吞吐损失主要来自读屏障的 JIT 插入开销。ZGC 的染色指针减轻了读屏障成本,但并发标记的写屏障更复杂。Generational Shenandoah 通过分代大幅缩小了标记范围,吞吐损失接近 G1 水平。
开启参数与生产实践
# 基础启用(JDK 21+)
-XX:+UseShenandoahGC
# 启用分代模式(JDK 21+ 推荐)
-XX:+UnlockExperimentalVMOptions -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational
# JDK 22+ 中分代模式是默认值,不需要 unlock
# 设置目标停顿(默认 10ms 或 50ms,取决于版本)
-XX:MaxGCPauseMillis=10
# 触发模式:自适应(默认)或主动模式
-XX:ShenandoahGCHeuristics=adaptive
# 主动模式:持续 GC,适合低延迟优先
-XX:ShenandoahGCHeuristics=aggressive
# 日志
-Xlog:gc*:file=gc.log:time,uptime,tags生产实战案例分析:
案例 1:电商订单中心,16GB 堆,Shenandoah 退化模式
- 现象:双 11 大促,订单量暴增 5 倍,GC 日志频繁出现
Degenerated GC,STW 从 3ms 飙升到 800ms - 根因:对象分配速率达到 2GB/s,超过 Shenandoah 并发回收能力
- 修复:
-XX:ShenandoahAllocationThreshold=15提高触发阈值,提前开始 GC 周期;同时配合-XX:+ShenandoahUncommit及时归还空闲内存给操作系统 - 效果:退化模式消失,STW 回到 5ms 以下
- 后续升级:JDK 21 后切到 Generational Shenandoah,Young GC 频率从 3 秒一次变成 800ms 一次,但每次停顿更小(2ms vs 3ms),P99 延迟从 50ms 降到 20ms
案例 2:实时推荐服务,64GB 堆,CPU 打满
- 现象:CPU 使用率长期 85%+,Shenandoah 的并发 GC 线程与业务线程争抢 CPU
- 根因:
-XX:ConcGCThreads默认值(可用 CPU 的 25%)在 CPU 密集型服务上太高 - 修复:
-XX:ConcGCThreads=2减少并发线程数,接受更长的 GC 周期但降低 CPU 争抢 - 效果:P99 延迟从 200ms 降到 50ms
- 数据对比:ConcGCThreads=4 时 GC 周期 200ms,ConcGCThreads=2 时 GC 周期 380ms,但业务线程 CPU 时间从 65% 升到 85%,整体吞吐反而上升
案例 3:日志平台,128GB 堆,频繁 Pace GC
- 现象:JVM 启动后稳定运行 2 小时,然后出现
Paced GC日志,应用线程分配阻塞 10-50ms - 根因:堆使用率稳定在 85% 以上,接近
ShenandoahMaxHeapThreshold默认值 90%,GC 频繁触发节流模式 - 修复:
-XX:ShenandoahMaxHeapThreshold=75提前触发 GC,避免堆逼近上限;同时-Xms128g -Xmx128g固定堆大小,避免 JVM 缩堆 - 效果:Paced GC 消失,GC 频率从 15 秒一次升到 8 秒一次,但每次停顿更稳定
生产注意事项:
- CPU 开销:并发 GC 需要额外 CPU 线程(
-XX:ConcGCThreads),在 CPU 接近满负荷的服务上可能影响应用吞吐 - 分配压力:如果应用分配速率超过 GC 并发回收速率,会触发退化模式(Degenerated GC)——退化为 STW 的 Full GC。监控指标:
jstat -gcutil的 FGC 列 - 大堆调优:超大堆(>256GB)建议配合
-XX:ShenandoahRegionSize调整 Region 大小(默认按堆大小自动选:堆 < 4GB 用 512KB,4-32GB 用 1MB,32-128GB 用 2MB,>128GB 用 4MB) - 与 G1/ZGC 的选型:吞吐量优先选 G1,大堆低延迟优先选 ZGC,中等堆低延迟且有 Red Hat 生态选 Shenandoah
- JDK 版本选择:JDK 17 的 Shenandoah 还没有分代能力,JDK 21 是分水岭,JDK 22+ 分代默认开启
面试八股
Q1:为什么 G1 的 Evacuation 不能并发? G1 的并发标记做完后,Mixed GC 的 Evacuation 阶段需要移动对象并更新引用。G1 没有转发指针,移动对象时应用线程如果读到旧对象就炸了。所以 G1 必须 STW 保证安全。Shenandoah 的 Brooks Pointer 相当于给每个对象加了个"门牌号通知",对象搬家了,门口贴个新地址,线程自己去看。G1 在 JDK 21 的提案(JEP 450)也在尝试并发压缩,但目前还处于实验阶段。
Q2:Brooks Pointer 的读屏障有多大性能开销? 每次读对象引用都需要一次额外的内存 load。SpecJBB 2005 测试显示吞吐下降约 5-8%。ZGC 用染色指针避免了读屏障,但付出了更复杂的 GC 屏障和 4TB 堆上限的代价。Generational Shenandoah 通过分代减少标记范围,额外读屏障开销降低约 40%。这是典型的"trade-off,没有银弹"。
Q3:Shenandoah 和 ZGC 在低延迟场景下怎么选?
- 堆 < 64GB,延迟要求 P99 < 10ms,CPU 有余量 → Shenandoah(JDK 21+ 分代版)够用,参数简单,生态成熟
- 堆 > 64GB,延迟要求 P99 < 5ms → ZGC 更合适,染色指针零额外对象头开销,大堆更稳定
- 堆 > 4TB → 只能用 Shenandoah(ZGC 染色指针 42 位堆地址上限,最大 4TB)
- 吞吐量优先 → 两个都不选,G1 更稳,尤其 Generational G1 在 JDK 21 已成熟
- 公司基础设施 → Red Hat/CentOS 生态优先 Shenandoah,Oracle 生态优先 ZGC
Q4:Generational Shenandoah 为什么不是默认开启? JDK 21 发布时还是实验性,需要 -XX:+UnlockExperimentalVMOptions。JDK 22 才正式默认。主要原因是分代模式引入了额外的 GC 屏障(remembered set 维护),在堆特别小(<4GB)或对象存活率极高的场景下,性能反而可能退化。你的场景如果堆 < 4GB 且对延迟极其敏感,可以先压测对比后再决定是否开启分代。
Q5:Shenandoah 的 Concurrent Evacuation 阶段,GC 线程和业务线程同时读写同一个对象,如何保证数据一致性? 逐字段复制 + CAS 更新 Brooks Pointer。GC 线程在复制对象字段时,业务线程可能读到旧位置(完整的旧数据),也可能读到新位置(完整的新数据)。永远不会读到"半残对象"。CAS 保证 Brooks Pointer 的更新是所有线程可见的原子操作。这是 Shenandoah 并发安全的核心保证,也是面试官最想听的点。
总结
- Shenandoah 的核心创新是 Brooks Pointer 转发表,让对象移动和引用更新可以并发执行
- 所有 STW 阶段只做初始化/收尾,停顿时间不随堆大小增长,实测通常在 1-10ms
- 与 ZGC 的路线差异:Shenandoah 用对象头转发指针,ZGC 用染色指针;前者实现更直观,后者对堆地址空间有硬限制
- Generational Shenandoah(JDK 21+) 通过分代将吞吐损失从 7% 降到 3%,是升级 JDK 21 的核心理由之一
- 生产开启参数简单:
-XX:+UseShenandoahGC,但需监控 CPU 开销和分配速率,防止退化模式 - 选型建议:高吞吐 → G1,亚 10ms 大堆 → ZGC,亚 10ms 中等堆 + Red Hat 环境 → Shenandoah
- 踩坑提醒:退化模式是 Shenandoah 最大的坑,大促前务必压测分配速率,确认不会触发;JDK 21 以下版本没有分代优化,吞吐损失明显
参考
OpenJDK Shenandoah Wiki: https://wiki.openjdk.org/display/shenandoah JEP 189: Shenandoah: A Low-Pause-Time GC (Experimental) JEP 379: Shenandoah: GC Production Ready JEP 462: Generational Shenandoah (Experimental) 《The Garbage Collection Handbook》第 17 章 Red Hat 官方性能 Benchmark:https://developers.redhat.com/articles/2021/06/16/shenandoah-gc-production-java-11-and-java-17 Oracle G1 Concurrent Evacuation Proposal (JEP 450): https://openjdk.org/jeps/450