移动式垃圾收集器可以通过压缩堆来回收碎片,却通常需要在对象移动时暂停应用线程。并发压缩的难点是:收集器正在复制对象,应用线程仍可能访问对象或修改对象图。Shenandoah 的路线是让标记、疏散和引用修复大部分与应用并发进行,再通过读屏障和并发协议维持对象访问的正确性。

Shenandoah 并不追求消灭所有停顿。它仍有短暂的全局协调阶段,且会付出屏障开销、并发 CPU 竞争和疏散空间成本。它适合被理解为一种延迟与资源成本之间的可选方案,而不是“换上就保证毫秒级”的开关。

项目演进与代际状态

版本 变化 状态
JDK 12 Shenandoah 作为实验特性引入 实验阶段
JDK 15,JEP 379 Shenandoah 成为产品特性 可以在启用该收集器的 JDK 构建中用于生产
JDK 24,JEP 404 分代 Shenandoah 作为实验特性 先开放试用与反馈
JDK 25,JEP 521 分代 Shenandoah 成为产品特性 分代模式进入正式可用状态

“产品特性”表示该能力离开实验状态,并不表示它在所有 JDK 发行版中默认启用,也不表示分代模式默认取代单代模式。HotSpot 构建可能因供应商、平台和编译选项而有不同支持情况;部署前需检查运行时参数和该发行版说明。

并发压缩的基本工作流

一次回收大致需要识别存活对象、为待回收区域选择疏散目标、复制存活对象,并让应用后续访问到正确位置。Shenandoah 将这些操作分成阶段,在多个阶段让 Java 线程继续运行:

文章流程图

这里的“并发”意味着收集器工作与应用线程交叠,不代表每个阶段从头到尾都没有暂停。阶段切换需要协调线程、扫描线程栈或建立快照一致性;具体停顿会因 JDK 版本、硬件、线程数、堆布局与负载而不同。

一轮回收中,哪些工作在哪个阶段

下面是并发周期的概念顺序。不同版本会拆分、合并或增加子阶段,GC 日志中目标 JDK 的实际阶段名才是准确记录。

阶段 是否通常暂停应用 主要工作
Initial mark 短暂停顿 建立根扫描和标记周期的起点
Concurrent mark 并发 遍历存活对象,屏障记录并发引用更新
Final mark 短暂停顿 完成标记收尾,处理引用和根并确定疏散集合
Concurrent evacuation 并发 复制回收集合中的存活对象
Concurrent update references 并发 修复堆字段、根和其他引用槽中的旧引用
Final update / cleanup 短暂停顿与并发收尾 确认引用已更新并回收旧区域

Shenandoah 会在多个短暂停顿之间执行并发工作,而不是一轮只暂停一次。停顿时间与并发周期总时长是两个指标:根和线程栈协调决定部分 pause,CPU 预算与堆余量决定后台工作能否及时结束。最大 pause 很短并不能单独证明整个服务的 GC 成本低。

标记:应用继续修改对象图时仍要找出存活对象

并发标记期间,应用线程仍会写字段、创建新对象并移除引用。收集器必须定义一个能被持续扫描的对象图视图。Shenandoah 的标记协议使用写屏障记录可能影响标记结果的引用更新;SATB(Snapshot-At-The-Beginning)式策略的直观目标,是把“周期开始时可达的对象”保守地纳入本轮存活集合,避免应用删除最后一个引用导致收集器漏标。

这种保守性可能让刚刚变成垃圾的对象多活一轮,但能让并发扫描拥有可证明的一致性边界。标记的精确协议会随收集模式与实现变化;应用开发者只需要通过 JVM 的正式开关选择收集器,而不应直接操纵标记状态。

SATB 写屏障通常在覆盖字段之前记录旧引用,先放入线程本地缓冲队列,再由 GC 工作者批量消费。这样无需在每次应用写入时同步完成图遍历,却要为热点写入增加判断、队列写入和缓冲区耗尽处理。引用写入密集的程序可能因此承担较明显的 CPU 成本,即使其 stop-the-world pause 很短。

Read barrier 如何配合疏散和引用修复

疏散:搬动对象并让旧引用可继续解析

选定回收区域后,收集器把其中的存活对象复制到其他区域。难点在于,应用线程可能仍持有指向旧位置的引用。Shenandoah 为引用访问插入 load-reference barrier(读引用屏障):当对象被转移时,屏障能够解析旧引用所指向的对象,并把访问导向当前有效位置。

常见实现讨论会用“转发指针”解释这条路径:旧对象位置保留足够的转发信息,运行时据此定位复制后的对象。不过对象头格式、转发表策略与快速路径细节会随实现和版本变化;更稳妥的抽象是,读屏障保证应用看到逻辑对象,而收集器可以在后台改变对象物理位置。

屏障必须足够便宜才有实用价值。JIT 会针对执行阶段和引用状态优化检查;如果引用无需解析,访问可以走快速路径。与此同时,读屏障不是免费的,它可能影响热字段访问、指针追逐密集型代码和某些微基准。测试真实业务比比较纸面机制更重要。

访问旧引用时,慢路径会查找对象的 forwardee;如果对象属于疏散集合但还未复制,触发访问的线程可能协助完成疏散。若屏障同时知道引用槽地址,还可以把槽原子地更新为新地址。于是一次字段读取既可能只是几条快速检查,也可能碰上对象搬迁或参与引用修复。

这解释了为什么只测 pause 仍不够:读屏障的慢路径可能发生在业务线程里,体现在请求耗时、CPU profile 或锁竞争上,而非一个单独的停顿行。结合 GC 日志、JFR 和请求时间线,才能判断程序是否经常撞上这些路径。

引用更新:允许压缩堆并恢复紧凑布局

移动对象之后,堆中其他对象还可能保存旧引用。Shenandoah 可以在后续阶段并发修复这些引用,逐步完成堆内指针的更新。读屏障使更新过程能够分散执行:即便某个引用尚未被批量修复,应用访问它时仍可解析到当前对象。

这使收集器不必把所有引用修复都塞进一次长暂停,但也意味着并发期间有更多状态需要管理。收集器要保证对象复制的发布顺序、引用解析的可见性和内存屏障语义正确,HotSpot 的实现承担这些细节。

分代 Shenandoah 为什么值得单独看

许多 Java 服务的大多数对象都很快死亡。若每一轮回收都以相同方式面对整个堆,短命对象会占用大量扫描与整理预算。分代模式把对象按年龄分组,让年轻代可以更频繁地回收,老年代则以适合长寿对象的节奏处理。

代际回收需要追踪老年代对象指向年轻代对象的引用。写屏障会将相应的堆区域记录到卡表或记忆集;年轻代回收时,收集器扫描相关区域,而不是无条件扫描整个老年代。代际屏障与记忆集提升了年轻代回收的针对性,也带来额外写入开销和元数据维护。

从 JDK 25 开始,可以通过以下参数选择 Shenandoah 的分代模式(前提是所用发行版包含 Shenandoah):

Shell
java -XX:+UseShenandoahGC \
     -XX:ShenandoahGCMode=generational \
     -Xlog:gc* \
     -jar service.jar

单代模式仍可作为比较对象。分代模式不是对所有程序都必然更快:长寿对象占主导时,年轻代能回收的比例有限;应用写入密集时,记忆集维护会增加成本;对象存活率、堆大小与 CPU 配额也会改变结果。

年轻代回收需要定位老年代中可能包含年轻对象引用的区域。卡表把老年代划分为较小范围,字段写入时相应卡被标记;年轻代回收时扫描这些卡,而不是扫描所有老年代对象。源码中,Shenandoah 的 SATB 队列、card table 与 load-reference barrier 分别对应不同工作:

屏障或元数据 维护的状态 主要相关阶段
SATB 写屏障 标记快照期间被覆盖的引用 并发标记
Card barrier 老年代中可能引用年轻对象的区域 记忆集扫描与年轻代回收
Load-reference barrier 已搬迁对象的旧引用解析与自愈 并发疏散和引用更新

卡表减少扫描范围,但卡粒度意味着被标脏的区域中可能还有不指向年轻代的字段。写入密集程序的脏卡数量、年轻代存活率和晋升速率都会影响分代模式收益;这些值应与年轻/老年代的回收字节数和周期 CPU 一起观察。

代际模式还要求在晋升、年轻回收和老年代周期之间保持记忆集正确。添加了年轻代不代表老年代工作消失,实际意义是调度不同工作集的回收频率和扫描范围。

疏散余量与退化回收

并发压缩必须先给存活对象找到新空间,等待引用更新后才能回收旧区域。可用于搬迁的余量过小、疏散集合存活比例过高或应用分配突发过大,都可能使正常并发周期难以按计划完成。Shenandoah 会维护 evacuation reserve;预留空间本身减少应用可用堆,因此需要结合活跃集与分配速率选择。

并发周期若被取消或遇到资源压力,可在某个阶段以 degenerated 方式继续完成必要工作;极端情况下还可能进入 Full GC。退化路径优先保证对象与引用正确,但可能扩大停顿,因此偶发的 degenerated/full cycle 对尾延迟的影响往往远高于大量正常短 pause。

并发线程数也要与空间余量一起调。给 GC 更多 CPU 可能加快标记或疏散,却会与业务线程争抢处理器;CPU 配额被限流时,周期拉长会让应用在后台工作期间继续分配更多对象。容量规划需要同时留出 heap headroom 和 CPU headroom。

和 ZGC、G1 的关系

维度 Shenandoah ZGC G1
目标 通过并发工作降低停顿 通过并发标记与搬迁降低停顿 在停顿目标与整体吞吐间平衡
对象移动 并发疏散,借读屏障解析移动期间的引用 并发搬迁,借着色指针和屏障维护引用 主要在疏散暂停内移动所选区域中的对象
代际选择 单代或分代模式;分代模式需显式选择 JDK 23 起默认分代;JDK 24 移除非分代模式 始终按年轻代与老年代分区收集
工程关注 并发资源、屏障成本、疏散余量 并发资源、堆余量、屏障成本 暂停目标、疏散集合、堆占用与吞吐

表中概括的是设计取向,不是固定性能排名。不同版本的实现和默认参数会演进。某个延迟目标下哪个收集器更合适,只有在真实的 JDK、硬件、容器限制和应用数据上才能判断。

从 GC 日志判断收集器是否跟得上

在目标 JDK 上保存完整统一 GC 日志,并把日志时间与服务指标、CPU throttling 和容器内存峰值对齐。重点观察:

  • 周期触发原因、总周期长度和每个停顿阶段的耗时;
  • 并发标记后存活字节、疏散字节和回收区域数量;
  • 年轻代与老年代各自的回收频率、晋升趋势和存活率;
  • available/reserve 余量变化、allocation failure 与退化/Full GC;
  • 业务 p99/p99.9、请求堆积和 GC 线程 CPU 的同步关系。

如果周期变长且 CPU throttling 同时发生,先检查容器配额;如果 CPU 充足但存活量持续上升,检查缓存与引用链;如果疏散字节接近总存活集且空间回收有限,可能是工作集本身太大或堆余量太紧。先从可观测数据确定限制,再逐项改参数,才知道性能变化由什么造成。

选型和调优:盯住服务而不只盯住 GC pause

先确定业务问题是停顿尾部、CPU 饱和、吞吐不足,还是内存上限。若当前 p99 请求延迟主要来自下游等待、锁争用或队列排队,换 GC 可能改变不了主因。若 GC 停顿与尾延迟时间线稳定重合,再并行测试收集器才有明确目标。

每轮比较应记录:

  • p50、p99、p99.9 业务延迟与最长 GC 停顿;
  • 吞吐、GC 线程 CPU 和应用线程被限流比例;
  • 分配速率、存活集比例、疏散失败或退化事件;
  • 堆提交量、进程 RSS、容器峰值内存和余量;
  • 预热阶段、稳态阶段和突发流量后的恢复时间。

并发收集器需要 CPU 时间。过紧的 CPU 配额可能延长并发阶段,进而压缩可用内存余量;过小的堆会减少搬迁缓冲空间;而单纯增大堆,也可能使容器接近内存限制。优先从 GC 日志与 JFR 事件定位限制,再调整并发线程、堆大小、分代参数等选项。

不要一开始堆叠大量高级参数。先以收集器默认行为建立基线,确保日志采集稳定,再逐项调整并保留同一负载重复对比。收集器升级后参数含义或推荐值可能变化,升级 JDK 时应重新验证。

适用场景与边界

Shenandoah 值得评估的场景包括:服务受 GC 长暂停影响明显、能接受一定并发 CPU 开销、且运行时发行版提供该收集器。它的并发压缩也可以减少长期运行后因堆碎片导致的分配压力。

如果服务受 CPU 配额限制,或必须把峰值内存压到极低,低停顿模式的额外并发和预留空间可能使整体表现变差。它也不会解决应用持有大量意外存活对象的问题。应把收集器视为容量规划的一部分,与对象分配行为、请求 SLA 和容器限制一起决策。

资料