移动式垃圾收集器可以通过压缩堆来回收碎片,却通常需要在对象移动时暂停应用线程。并发压缩的难点是:收集器正在复制对象,应用线程仍可能访问对象或修改对象图。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):
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 和容器限制一起决策。
资料
- JEP 189:Shenandoah: A Low-Pause-Time Garbage Collector (Experimental)
- JEP 379:Shenandoah: A Low-Pause-Time Garbage Collector
- JEP 404:Generational Shenandoah (Experimental)
- JEP 521:Generational Shenandoah
- OpenJDK Project Shenandoah
- OpenJDK HotSpot Shenandoah source
- Shenandoah barrier implementation
- Shenandoah concurrent cycle implementation