Skip to content

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 间接寻址:

java
// 伪码示意: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,不随堆大小增长

各阶段详解

  1. Init Mark(STW,1-3ms):初始化标记根集合,准备并发标记所需的数据结构
  2. Concurrent Marking:并发遍历对象图标记存活对象。应用线程继续运行,新分配的对象自动标记为存活
  3. Final Mark(STW,2-5ms):处理并发标记期间变动的引用(SATB 类似 G1),确定哪些 Region 需要回收
  4. Concurrent Evacuation核心阶段。并发将存活对象复制到新 Region,通过 Brooks Pointer 保证并发安全。GC 线程复制期间,应用线程可能读到旧对象的中间状态,但 Brooks Pointer 确保最终一致性
  5. Init Update Refs(STW,<1ms):初始化引用更新阶段的根集合
  6. Concurrent Update References:并发遍历堆,更新所有指向旧对象的引用为 Brooks Pointer 指向的新地址
  7. 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 微服务负载):

指标原始 ShenandoahGenerational Shenandoah改善
平均 GC 周期时间450ms120ms73% ↓
STW 平均停顿3.5ms2.1ms40% ↓
吞吐量损失(vs G1)7%3%57% ↓
Full GC 触发次数0.3 次/小时0.01 次/小时97% ↓

核心优化:分代 Shenandoah 在 Young GC 中只标记 Young Region,标记范围大幅缩小,同时读屏障的开销也因为分代信息而部分消除(Old Region 中的对象不需要频繁读屏障)。

与 ZGC 的异同

特性ShenandoahZGC
并发压缩方案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 HatOracle

两者目标一致(亚 10ms 停顿),但实现思路不同。Shenandoah 的 Brooks Pointer 对 JVM 内部侵入更小,而 ZGC 的染色指针在指针层面做文章,对内存分配器和 GC 屏障有更高要求。

关键性能数据(来自 Red Hat 官方测试,32GB 堆,Java 21):

GC平均停顿P99 停顿吞吐量损失(vs G1)
G145ms220ms0%(基准)
Shenandoah3.2ms8.5ms5-8%
Generational Shenandoah2.1ms5.5ms3-4%
ZGC1.8ms4.2ms8-12%

Shenandoah 的吞吐损失主要来自读屏障的 JIT 插入开销。ZGC 的染色指针减轻了读屏障成本,但并发标记的写屏障更复杂。Generational Shenandoah 通过分代大幅缩小了标记范围,吞吐损失接近 G1 水平。

开启参数与生产实践

bash
# 基础启用(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

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。