问题:大型堆下,GC 停顿为何成为高并发服务的瓶颈
一个典型的在线交易服务在促销高峰期间,堆内存膨胀到 200 GB,年轻代和老年代中充斥着大量存活对象。当 CMS 或 G1 触发 Full GC 时,整个应用停顿数秒甚至数十秒,所有请求被阻塞,上游负载均衡开始超时重试,进而引发雪崩。根本原因在于这些收集器在移动对象(即“疏散”)时需要暂停所有 Java 线程,以确保对象图的一致性——堆越大,需要移动的对象越多,停顿时间就越长。
Shenandoah GC 的设计目标正是打破这种“堆大小决定停顿时间”的关联。它允许在 Java 线程持续运行的同时,将存活对象从碎片化的区域复制到新的内存区域,这一过程称为并发疏散。实现这一点的关键在于两个相互配合的机制:为每个对象引入一个间接指针(Brooks 指针),以及在访问对象时插入一个读屏障。本文将以一个响应时间 SLA 要求 99.9% 请求低于 100 ms 的 Web 服务为贯穿场景,剖析 Shenandoah 如何通过这两个机制将 GC 停顿压缩到亚毫秒级。
Brooks 指针:对象访问的“中间人”
传统 JVM 中,一个对象的引用直接指向对象数据在堆中的起始地址。如果 GC 在应用线程读取 obj.field 的同时将对象移动到新位置,应用线程可能读到半复制状态的数据,或者访问到已经释放的旧内存。Shenandoah 的解法是让所有 Java 对象引用不再直接指向对象数据,而是指向一个Brooks 指针(以发明者 Rodney Brooks 命名),该指针再指向对象数据的实际地址。对象布局变为:对象头、Brooks 指针、实例数据。引用访问路径变为:引用 → Brooks 指针 → 对象数据。
这一层间接性使 GC 可以在应用线程运行时安全地移动对象:GC 线程先将对象数据复制到新位置,然后原子地更新旧对象中的 Brooks 指针,使其指向新地址。应用线程通过读屏障始终从 Brooks 指针获取当前有效地址,因此它要么看到旧地址(对象尚未被移动),要么看到新地址(移动已完成),不会看到不一致的中间状态。
在 Shenandoah 中,Brooks 指针存储在对象头的固定偏移处,其更新操作通过 CAS 指令保证原子性。当 GC 线程准备移动一个对象时,会先尝试 CAS 该对象的 Brooks 指针,如果成功,则负责复制数据;如果失败,说明其他 GC 线程已经接管,当前线程可以跳过该对象。这种无锁设计允许多个 GC 线程并发疏散不同对象,而不会互相阻塞。
读屏障:拦截每一次对象访问
Brooks 指针本身并不保证应用线程总能拿到正确的引用。假设应用线程已经将 Brooks 指针的值(旧地址)加载到寄存器或本地变量中,然后 GC 移动了对象并更新了 Brooks 指针,应用线程仍可能使用过期的旧地址去访问字段。为此,Shenandoah 在每次通过引用访问对象时插入一个读屏障(read barrier)。读屏障的逻辑非常轻量:检查引用所指向的 Brooks 指针是否指向自身(即对象未被移动),如果不是,则重新加载 Brooks 指针的值。
在 HotSpot 实现中,读屏障被 JIT 编译器内联到生成的机器码中。对于 obj.field 这样的访问,编译器会生成类似下面的伪代码:
// 伪代码示意
r1 = obj.brooks_ptr; // 加载 Brooks 指针
if (r1 != obj) { // 对象已被移动?
r1 = obj.brooks_ptr; // 重新加载到新地址
}
value = r1.field; // 从正确地址读取字段
这个检查通常只需要一次额外的内存读取和一次条件跳转。在对象未被移动的常见情况下,分支预测器可以准确预测跳转不会发生,因此读屏障的开销被控制在很低的水平。Shenandoah 还利用局部性原理:一旦某个对象被移动,其 Brooks 指针会稳定指向新地址,后续访问不再需要修正。
读屏障不仅用于并发疏散,还用于并发标记阶段。在标记过程中,如果应用线程修改了对象引用,读屏障会确保标记逻辑看到的是最新的引用关系,从而避免漏标。这种“自愈”能力使得 Shenandoah 的并发标记可以与应用线程完全并行,不需要像 CMS 那样在重新标记阶段暂停。
并发标记:在应用运行中追踪存活对象
Shenandoah 的 GC 周期由一系列并发阶段组成,停顿只发生在极短的根扫描和最终更新引用等点上。第一个主要阶段是并发标记(Concurrent Marking)。它的任务是从 GC 根(线程栈、静态变量、JNI 引用等)出发,遍历对象图,标记所有存活对象。
与 CMS 或 G1 不同,Shenandoah 的并发标记完全不需要在标记结束时暂停应用线程来“重新标记”被并发修改的引用。这得益于读屏障的另一个职责:当应用线程试图将一个引用写入对象字段时,写屏障会记录该引用,并在标记循环中重新扫描这些“变更集”。具体来说,Shenandoah 使用了一种基于卡表(card table)的交叉引用记录机制,但粒度更细。在标记阶段,如果应用线程修改了某个对象的引用字段,该对象所在的卡会被标记为脏卡。标记线程在完成初始遍历后,会反复扫描这些脏卡,直到没有新的脏卡产生,或者达到最大重扫次数。
在我们的 Web 服务场景中,假设一个请求处理线程将新创建的订单对象链接到用户会话中。这个写操作会触发写屏障,将对应的卡标记为脏。并发标记线程在后续扫描中会重新访问该卡,确保新引用被追踪到。整个过程应用线程无需停顿,标记线程与业务线程交替运行,利用多核 CPU 的剩余算力。
并发疏散:移动对象而不暂停应用
标记完成后,Shenandoah 知道了哪些对象是存活的。接下来进入并发疏散(Concurrent Evacuation)阶段,这是 Shenandoah 区别于其他收集器的核心。GC 线程将存活对象从收集集(Collection Set,通常是选定的若干内存区域)复制到目标区域,同时应用线程继续运行。
疏散过程分为以下几个步骤,每一步都与应用线程并发执行:
- 对象复制:GC 线程遍历收集集中的对象,在目标区域分配新空间,将对象数据逐字段复制过去。此时旧对象中的 Brooks 指针仍然指向旧地址。
- Brooks 指针更新:复制完成后,GC 线程使用 CAS 操作将旧对象的 Brooks 指针更新为新地址。从这一刻起,任何通过读屏障访问该对象的应用线程都会自动跳转到新对象。
- 转发指针安装:旧对象头部会被写入一个转发指针(forwarding pointer),指向新对象。这用于后续 GC 自身引用更新的需要。
应用线程在疏散期间访问一个正在被移动的对象时,读屏障会介入。如果对象尚未被移动,Brooks 指针指向自身,访问正常进行;如果对象已被移动,Brooks 指针指向新地址,读屏障自动重定向。如果应用线程恰好在 GC 线程复制字段的过程中访问该对象,会看到不一致的数据吗?答案是不会,因为 Shenandoah 使用了一种“先复制后更新”的顺序:在 Brooks 指针更新之前,应用线程仍然通过旧对象访问数据,而 GC 线程此时只写入新对象,不影响旧对象。一旦 Brooks 指针更新,应用线程立即切换到新对象,而新对象的数据已经完整。这个顺序保证了应用线程始终看到一致的对象状态。
在 Web 服务场景中,假设一个请求正在计算订单总价,需要遍历订单项列表。如果 GC 同时移动了某个订单项对象,读屏障会确保遍历逻辑无缝切换到新地址,请求处理不会感受到任何停顿。
引用更新:修正指向旧地址的指针
对象被移动后,堆中可能还存在大量指向旧地址的引用(例如其他对象中的字段、栈上的局部变量)。如果不修正这些引用,它们将成为悬空指针,导致后续访问错误。Shenandoah 在并发疏散之后执行并发引用更新(Concurrent Update References)阶段,遍历堆中的所有引用,将指向旧地址的引用更新为新地址。
这一阶段同样与应用线程并发。GC 线程扫描堆的每个区域,对于每个引用字段,检查其指向的对象是否已被移动(通过旧对象头的转发指针判断),如果是,则更新为新地址。应用线程在此期间可能读取或写入引用。读屏障再次发挥作用:当应用线程读取一个引用时,读屏障会检查该引用指向的 Brooks 指针,如果发现对象已移动,则返回新地址,同时可选地更新引用本身(称为“自愈”)。写引用时,写屏障会记录更新,确保 GC 线程不会漏掉新产生的旧引用。
引用更新完成后,旧对象所占的内存就可以被回收了。整个周期中,唯一需要短暂停顿的时机是初始标记(扫描线程栈根)和最终更新引用(更新根引用并回收区域)。这些停顿的时间与堆大小无关,只与根的数量和线程数有关,通常在亚毫秒级。
Shenandoah 与 ZGC 的权衡:吞吐、延迟与实现复杂度
Shenandoah 和 ZGC 都是低延迟收集器,但实现路径不同。ZGC 使用染色指针(colored pointers)在 64 位指针中嵌入元数据,通过内存多重映射和读屏障实现并发重定位,不需要额外的 Brooks 指针。下表对比了两者在关键维度上的差异:
| 维度 | Shenandoah | ZGC |
|---|---|---|
| 核心技术 | Brooks 指针 + 读屏障 | 染色指针 + 读屏障 + 多重映射 |
| 对象访问开销 | 一次间接访问 + 读屏障检查 | 读屏障检查(无间接访问) |
| 内存开销 | 每对象额外 4 或 8 字节(Brooks 指针) | 无额外对象头开销,但依赖指针压缩和地址空间 |
| 停顿特征 | 亚毫秒级,与堆大小无关 | 亚毫秒级,与堆大小无关 |
| 吞吐影响 | 读屏障和间接访问可能降低 5%~15% 吞吐 | 读屏障和多重映射可能降低 5%~15% 吞吐 |
| 并发疏散 | 支持,通过 Brooks 指针原子更新 | 支持,通过染色指针和转发表 |
| 平台支持 | x86_64, aarch64 等 | 仅 64 位平台,依赖操作系统虚拟地址映射 |
| 成熟度 | JDK 12 起实验性,JDK 15 起生产可用 | JDK 11 起实验性,JDK 15 起生产可用 |
选择 Shenandoah 而非 ZGC 的典型理由包括:需要在 32 位或非 Linux 平台上运行;希望避免染色指针带来的地址空间限制(例如某些容器环境对内存映射有约束);或者团队对 Brooks 指针的间接访问模型更熟悉。反之,如果应用对象数量极大且内存非常紧张,Brooks 指针的额外开销可能不可接受,此时 ZGC 更合适。
在我们的 Web 服务场景中,假设服务部署在 x86_64 Linux 上,堆大小为 200 GB,对象数量约 2 亿个。选择 Shenandoah 意味着额外消耗约 1.6 GB 内存用于 Brooks 指针(每个 8 字节)。如果内存充足,这个开销可以接受;如果内存紧张,可以考虑 ZGC。吞吐方面,读屏障和间接访问可能导致 10% 左右的吞吐下降,但换来了稳定的亚毫秒停顿,对于 SLA 严格的服务来说是值得的。
生产调优与故障模式
启用 Shenandoah 需要添加 JVM 参数 -XX:+UseShenandoahGC(JDK 15 之前还需 -XX:+UnlockExperimentalVMOptions)。关键调优参数包括:
-XX:ShenandoahGCHeuristics:选择启发式策略,如adaptive(默认)、static、compact、aggressive。adaptive根据运行时数据动态调整周期触发时机;static基于固定阈值触发,适合行为稳定的应用。-XX:ShenandoahAllocationThreshold:触发 GC 的分配阈值(占堆的百分比)。降低该值会使 GC 更频繁,但每次回收的区域更少,停顿更短。-XX:ShenandoahFreeThreshold:空闲内存阈值。当空闲内存低于该比例时,GC 会更积极地回收。-XX:ShenandoahPacing:启用分配节奏控制,防止应用分配速度超过 GC 回收速度导致分配失败(Allocation Failure)。
常见故障模式之一是分配失败(Allocation Failure)。当应用线程分配对象的速度超过 GC 线程回收内存的速度时,堆中没有足够空间,Shenandoah 会退化到一次短暂的 Stop-The-World 停顿,执行紧急疏散。这通常发生在堆设置过小或分配速率突发性增长时。监控 GC 日志中的 Allocation Failure 事件,如果频繁出现,应增大堆或调整启发式参数。
另一个问题是读屏障开销导致的吞吐下降。虽然读屏障很轻量,但在访问密集型应用中(如大量遍历对象图),累积开销可能显著。可以通过 JFR(JDK Flight Recorder)监控 CPU 使用率和 GC 停顿时间,结合应用吞吐指标判断是否可接受。如果吞吐下降超出预期,可以考虑切换到 G1 或 Parallel GC,牺牲延迟换取吞吐。
此外,Shenandoah 的并发阶段依赖足够的 CPU 核心。如果应用线程已经占满所有核心,GC 线程将与之争抢 CPU,导致 GC 周期延长,甚至引发分配失败。建议为 GC 线程预留至少 1~2 个核心,可通过 -XX:ConcGCThreads 设置并发 GC 线程数。
贯穿场景的流程图示
下图展示了 Shenandoah 在一次 GC 周期中,应用线程与 GC 线程如何并发执行,以及 Brooks 指针和读屏障在其中的作用。场景为 Web 服务处理请求时,GC 同时移动一个订单对象。
flowchart TD
A[应用线程访问订单对象] --> B{读屏障检查 Brooks 指针}
B -->|指向自身| C[直接访问对象数据]
B -->|指向新地址| D[重定向到新对象]
E[GC 线程开始疏散] --> F[复制订单对象到目标区域]
F --> G[CAS 更新旧对象 Brooks 指针]
G --> H[旧对象头写入转发指针]
H --> I[并发引用更新阶段]
I --> J[扫描堆中引用并更新]
J --> K[回收旧区域]
C --> L[请求处理完成]
D --> L
在并发标记阶段,应用线程修改引用会触发写屏障,标记线程重新扫描脏卡,图中未详细展开。关键点在于:应用线程在整个周期中仅在初始标记和最终更新引用时短暂停顿,其余时间均与 GC 线程并发运行。Brooks 指针的原子更新和读屏障的自愈能力保证了对象访问的一致性。
适用边界与未解决问题
Shenandoah 适合对延迟敏感、堆大小在数十 GB 到数百 GB 的应用,例如在线交易、实时数据分析、游戏服务器等。如果应用可以容忍几十毫秒的停顿,G1 通常能提供更高的吞吐。如果堆很小(< 2 GB),Shenandoah 的并发开销可能超过收益,此时 Serial 或 Parallel GC 更合适。
Shenandoah 当前不支持分代收集,所有对象一视同仁,这可能导致年轻对象频繁被移动,增加 CPU 开销。OpenJDK 社区正在探索分代 Shenandoah,以进一步降低 CPU 使用率和内存带宽消耗。另外,读屏障虽然优化良好,但在极端情况下(如大量虚引用或最终器)仍可能引起停顿波动。
最终选择 Shenandoah 还是 ZGC,取决于具体环境:如果平台支持且内存地址空间充足,ZGC 的染色指针方案可能提供略低的 CPU 开销;如果需要在非 64 位或非 Linux 系统上运行,或者希望避免染色指针的复杂性,Shenandoah 是更稳妥的选择。无论哪种,都应基于实际负载进行基准测试,观察停顿分布、吞吐和内存开销,而不是仅凭理论决策。