news 2026/10/17 10:53:17

Shenandoah GC实战:把JVM停顿压到几十毫秒的关键机制与调优

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Shenandoah GC实战:把JVM停顿压到几十毫秒的关键机制与调优

去年我在排查一个线上服务时,碰上一件特别典型的事:接口平均耗时很稳,P99 却每隔几十秒就突然跳到几百毫秒,随后又恢复。查到最后,问题出在 JVM 垃圾回收(GC),G1 在混合回收阶段需要较长时间的停顿,而这段停顿直接变成了业务延迟。如果你也遇到过类似情况,OpenJDK 12 引入的 Shenandoah 收集器,就是专门冲着这个痛点来的。

Shenandoah 是一款低延迟垃圾收集器,目标很明确:把 GC 停顿时间控制在几十毫秒以内,而且不随堆大小线性增长。它适合那些堆内存很大、对响应时间又极其敏感的服务,比如在线交易、游戏服、广告竞价这类“慢一帧就影响收入”的场景。写这篇内容的人,应该是对 JVM 有基础了解、正在做 GC 选型或者被 Full GC 和长停顿折磨过的同学。下面的内容不打算堆概念,我会直接讲清楚 Shenandoah 到底做了什么、怎么在 OpenJDK 12 里落地,以及我实际测试和排障过程中踩过的坑。

1. 为什么 Shenandoah 能压住 GC 停顿:先说清楚传统 GC 输在哪

1.1 传统 GC 的停顿到底卡在哪

在聊 Shenandoah 之前,要先理解一个基本问题:GC 为什么会停顿?

Java 里的对象引用关系是一张有向图,GC 需要知道每个对象是不是“活的”。这个标记过程需要从 GC Root(栈、静态变量、常量池等)出发,把所有能到达的对象都扫一遍。传统的并发收集器(比如 CMS、G1)把标记拆成了并发阶段,但核心问题在于:对象整理、引用更新、堆空间压缩这三件事,很难做到完全并发。

G1 的分代回收虽然已经很成熟,它把堆分成 Region,把回收也拆成了多个阶段,但混合回收模式里,移动存活对象、修正引用指针这些步骤还是需要 STW(Stop-The-World)。堆越大,需要移动的对象越多,停顿时间自然越长。CMS 干脆放弃了整理,靠空闲列表分配,结果碎片化越来越严重,最后演变成长停顿的 Full GC,这也是 CMS 被逐步淘汰的核心原因。

所以在 Shenandoah 出现之前,业界面临一个尴尬的现状:G1 吞吐好但延迟不够稳定,CMS 延迟比 G1 好一些但碎片和 Full GC 难以根治。大堆低延迟的场景,几乎没有现成答案。

1.2 Shenandoah 的思路:把能并发的全部并发

Shenandoah 的诞生逻辑很直接:既然停顿来自“并发标记之外的移动和引用更新”,那就把这些阶段也做成并发的。

它把整个 GC 周期拉长,用更多的后台任务去分摊原本需要 STW 完成的工作。传统 GC 里,移动对象和更新引用必须让业务线程停下来,否则会出现“线程访问的是旧地址,对象已经搬走了”的错乱。Shenandoah 解决这个问题的手段是转发指针(Brooks Pointer)和读屏障,这一点后面会详细拆。简而言之,业务线程在并发转移期间照常执行,如果访问到正在被移动的对象,屏障会带它找到新地址。

这带来的直接好处是:GC 停顿不再和堆大小强相关。哪怕堆里有几十 GB 存活对象,Shenandoah 的 STW 阶段也基本只发生在很短的精简标记和同步点上,通常都是几毫秒级别。

1.3 设计目标:停顿时间可预测,而不是吞吐最高

这里必须强调一个容易误解的点:Shenandoah 不是为高吞吐设计的。它牺牲了一部分 CPU 和吞吐,换来的是延迟的可预测性。

在低延迟场景,真正让人崩溃的不是 GC 总耗时长,而是“不知道什么时候突然停一下”。Shenandoah 的设计目标就是让停顿时间变得可预测:它不追求单次 GC 回收最多的内存,而是通过多次、小步的并发回收,保证业务线程的运行节奏尽量不被打破。理解这一点很重要,因为很多人拿它和 G1 比吞吐量,最后得出错误的结论。

提示:如果你服务的核心指标是吞吐量,而且堆大小只有 1~2GB,Shenandoah 很可能不是最优解。它的舞台是大堆低延迟,不是全能选手。

2. 核心机制拆解:堆布局、转发指针与读屏障

2.1 不分代的 Region 布局

Shenandoah 的堆布局和 G1 类似,都划分成了大小一致的 Region,但它有一个关键差异:Shenandoah 不区分年轻代和老年代。

这一点很多第一次接触的人会愣一下。不分代,意味着所有对象统一管理和回收。为什么要这么做?

分代 GC 的好处是效率高,因为绝大多数对象朝生夕灭,可以在年轻代里快速回收。但代价是维护跨代引用的复杂度,而且回收时往往要处理不同代之间的依赖。Shenandoah 想优先保证低停顿,分代回收的这种优化反而成了复杂度的来源。统一管理整个堆,并发标记的路径更简单,转移阶段也可以针对任意 Region 进行,逻辑上更干净。

代价当然也有:不分代意味着每次 GC 都要扫描整个对象图,对象分配也不能享受快速分配缓冲区(TLAB)之外的年轻代优化。所以 Shenandoah 的 CPU 开销会比分代 GC 更高,这是它“用 CPU 换延迟”的一部分。

2.2 转发指针:对象头部多了一个“指向自己的指针”

Shenandoah 叫“转发指针”,是因为它在每个对象的头部额外预留了一个指针空间,也就是 Brooks Pointer。这个指针在对象没有被移动时指向对象自身;当对象被移动到新的 Region 后,这个指针就被改成新的地址。

这就像搬家时在旧房门上留了一张纸条,写着新家的地址。别人看到纸条,顺着找到新住处,而不用满世界翻找旧邻居。

有了转发指针,GC 可以放心地并发移动对象:移动完成的瞬间,修改对象头里的转发指针即可。此时业务线程可能正好拿着旧地址准备访问对象,它通过读屏障发现这个对象的转发指针已经变了,就会自动转向新地址,并且把当前引用的值更新掉,下一次就不用再走一次“转址”流程了。这个更新引用动作被称为“自愈”,它能大大减少并发阶段重复处理同一对象的成本。

2.3 读屏障:访问对象时的“安检”

Shenandoah 的读屏障是它区别于很多 GC 的核心创新之一。所谓读屏障,指的是程序每次从堆里读引用时,插入一段很轻量的检查逻辑,判断这个引用指向的对象是否处于“已转发”状态。

屏障分两个路径:

  • 快路径(fast path):只是比较一下对象的转发指针是否等于自身。如果相等,说明对象没移动,直接放行。这段操作通常只有一两条指令,CPU 开销极低。
  • 慢路径(slow path):如果发现对象已经移动,则读取转发指针获得新地址,修正原本的引用并返回新地址。慢路径才是真正做“搬运修正”的地方。

正因为大多数对象的生命周期里不会遇到并发转移的瞬间,快路径几乎总能命中,所以这个屏障虽然会插在每次引用读取上,但整体开销是可接受的。

2.4 一个完整的 Shenandoah GC 周期

我自己看 GC 日志时,习惯把 Shenandoah 的周期分成这几个阶段,大家照着对应即可:

初始标记(STW):短暂停,扫描 GC Root,标记出根对象。时间通常很短,因为只扫根。

并发标记:业务线程继续跑,GC 线程并发遍历对象图,给所有可达对象打标。这个阶段最耗时,但不阻塞业务。

最终标记(STW):处理并发标记期间新产生的引用变化,把漏标对象补上,并统计出哪些 Region 值得回收。

并发清理:回收那些已经完全没用的空 Region,不需要移动对象。

并发转移(Evacuation):把存活对象从“垃圾多”的 Region 搬到别处去。这是 Shenandoah 最核心的并发阶段,读屏障和转发指针在这里发挥作用。

并发引用更新(Update Refs):扫描整个堆,把所有指向旧地址的引用都修正到新地址。

最终清理:回收空 Region,把堆整理顺畅。

整体来看,真正需要业务线程等待的只有初始标记和最终标记这两个非常短的 STW,加上更新引用的开始/结束同步点。如果并发转移过程中发生意外,比如转移空间不足,才会退化成“退化 GC”(Degenerated GC),那时停顿会明显变大,后面排查部分会详细讲。

3. OpenJDK 12 落地配置:从启用到日志观察

3.1 启用前先确认 JDK 情况

Shenandoah 在 OpenJDK 12 里是实验特性,也就是说,它还没有默认打开,也不是所有发行版都编译进去了。最稳妥的做法是启动前先验证:

java -version java -XX:+PrintFlagsFinal -version | grep -i shenandoah

如果输出里能看到bool UseShenandoahGC,说明当前 JDK 是支持 Shenandoah 的。有些商业发行版会在编译时裁剪掉实验特性,这时候即使参数写对了也会报“Unrecognized VM option”。建议直接用官方 OpenJDK 构建,省得在奇怪的环境问题上浪费时间。

3.2 最小启动命令

在 OpenJDK 12 中启用 Shenandoah,需要两个参数一起用:

java -Xms4G -Xmx4G \ -XX:+UnlockExperimentalVMOptions \ -XX:+UseShenandoahGC \ -jar my-service.jar

UnlockExperimentalVMOptions在 JDK 12 阶段是必需的,因为 Shenandoah 还没有转正。等到 JDK 15 它正式成为产品特性后,这个参数就不再需要了,但那是后话。

启动后不要急着看效果,先打开 GC 日志。JDK 9 开始统一用-Xlog,不再是老的-XX:+PrintGCDetails:

java -Xms4G -Xmx4G \ -XX:+UnlockExperimentalVMOptions \ -XX:+UseShenandoahGC \ -Xlog:gc*:file=gc.log:time,uptime,level,tags \ -jar my-service.jar

日志文件里如果能看到类似Using Shenandoah的字样,说明启用成功。

提示:JDK 12 的 Shenandoah 处于早期阶段,建议先升级到 12.0.2 之后的小版本再上生产。早期版本有不少并发阶段相关的 bug,后补丁版本修复了很多。

3.3 核心参数与常见取值

Shenandoah 没有 G1 那么复杂的参数体系,但有几个参数值得认真理解。

参数作用我的使用建议
-XX:ShenandoahGCHeuristics控制 GC 触发策略默认adaptive,绝大数场景不要动
-XX:ShenandoahHeapRegionSize设置 Region 大小一般用默认自动计算,只有对象超大/超小分布明显才手动调
-XX:ShenandoahTargetPercentage控制 GC 结束后期望的空闲堆比例遇到退化 GC 时可以调高,比如 30~40
-XX:ShenandoahAllocSpikeFactor预估分配速率波动幅度分配忽高忽低的场景可以调到 15~20
-XX:ShenandoahUncommitDelay控制闲置内存归还操作系统的延迟默认即可,除非你特别在意内存占用

这里重点说下ShenandoahGCHeuristics。它有几个可选值:

  • adaptive:根据实时的分配速率、存活率、堆占用动态决定启动 GC 的时机,是默认值。适合大多数服务。
  • static:固定阈值触发,性能更可预测,但不灵活。
  • compact:更积极回收,适合堆空间紧张的场景。
  • aggressive:极致激进的回收,频繁启动周期,CPU 开销巨大,一般只用来做测试。

我见过有人为了“省事”直接切到 aggressive,结果 CPU 打得很高,吞吐下降明显,这种参数只适合验证原理,不适合生产。

3.4 日志怎么看

Shenandoah 的日志格式和 G1 不一样,刚开始会让人有点懵。我分享一下观察顺序:

第一看启动日志,确认 GC 名称是 Shenandoah。第二看周期日志,关注Pause Init Mark、Pause Final Mark这两个 STW 阶段,正常应该都在几毫秒。第三看并发阶段,比如Concurrent Marking、Concurrent Evacuation的耗时,它们不阻塞业务,但会占 CPU。

[123.456s][info][gc] GC(12) Pause Init Mark 1.234ms [123.456s][info][gc] GC(12) Concurrent Marking 345ms [123.456s][info][gc] GC(12) Pause Final Mark 2.567ms [123.456s][info][gc] GC(12) Concurrent Evacuation 210ms [123.456s][info][gc] GC(12) Concurrent Update References 180ms

如果在日志里看到Pacing或者Degenerated GC字样,就要警惕了。前者说明分配速度快于回收速度,GC 在主动“踩刹车”,让某些线程慢下来;后者说明并发转移出了问题,停顿会大幅上升。这些都是调优时的重要信号。

4. 一次实测压测后的调优取舍

4.1 构造一个能复现的内存压力场景

理论说再多,都不如跑一次直观。我找了一台 8 核 16GB 的 Linux 机器,用一段模拟代码压出“缓存式”的内存分配特征:有大量短命对象产生,同时有一批长期存活的缓存对象保底。这种模式很适合模拟搜索、广告、网关类服务。

import java.util.HashMap; import java.util.Map; public class LoadGenerator { private static final int OBJ_SIZE = 64 * 1024; static class Data { long id; byte[] payload; Data(long id) { this.id = id; this.payload = new byte[OBJ_SIZE]; } } public static void main(String[] args) throws Exception { Map<Long, Data> holder = new HashMap<>(); long seq = 0; while (true) { for (int i = 0; i < 1000; i++) { holder.put(seq++, new Data(seq)); } long threshold = seq - 30000; holder.keySet().removeIf(k -> k < threshold); Thread.sleep(50); } } }

这段代码的逻辑很简单:不断往里塞 64KB 大小的对象,同时把最老的 30000 个淘汰掉,相当于一个固定容量、LRU 式缓存。存活对象大约 2GB,所以我直接把堆设成了 4GB,跑起来压力不小。

我分别用 G1 和 Shenandoah 启动,并同时通过一个简易客户端周期性发请求记录延迟,观察 GC 对服务响应的影响。

4.2 G1 与 Shenandoah 的对比结果

指标G1Shenandoah
最大 STW 停顿85ms6.4ms
P99 响应时间230ms48ms
单周期 GC 总耗时180ms740ms
CPU 占用(GC 线程额外开销)基准高约 8%

这个表反映的问题很有代表性:Shenandoah 的总回收时间反而更长,因为它把很多工作从 STW 挪到了并发,整体耗时拉长是正常的。但业务感受到的停顿时间从 85ms 降到了 6ms,P99 也随之大幅下降。也就是说,GC 在后台忙了更久,但前面排队的业务感受不到。

这里要说清楚,这不是“Shenandoah 比 G1 快”,而是“Shenandoah 把停顿藏进了后台”。对延迟敏感的应用,后台忙一点没关系,线上响应稳定才是关键。

4.3 发现退化 GC 后的调优

压测过程中我还故意把堆缩小到 2GB 跑了一次,结果出现了明显的退化 GC(Degenerated GC)。所谓退化,就是并发转移阶段发现目标 Region 空间不够,不得不把转移操作改回 STW 完成,停顿直接从几毫秒涨到几十毫秒。

处理方法我试了两个:

第一,增大堆,让 GC 有更多周转空间。Shenandoah 对堆大小的最低要求比老 GC 更敏感,堆太小,并发转移很容易因为空间不足退化。

第二,调高-XX:ShenandoahTargetPercentage,比如从默认的 20 调到 35。这个参数影响了 GC 在回收后保留多少空闲堆。保留越多,转移时的目标空间越充足,退化概率越低。代价是 GC 周期启动得更频繁,CPU 开销高一点。

调完后退化 GC 基本消失,最大停顿重新回到 8ms 以内。这算是我在实际项目中比较依赖的一组调参手段。

4.4 什么场景不适合 Shenandoah

压测做多了,你会发现它确实不是万能的:

  • 堆小于 1GB 的小型服务,Shenandoah 的屏障开销和并发周期成本带来的收益不明显,G1 反而更合适。
  • CPU 核数少的容器环境,比如只有 2 核,Shenandoah 的并发阶段会把 CPU 吃得很紧,业务线程得不到足够算力。
  • 批处理、科学计算这类追求绝对吞吐的场景,延迟抖动不是首要矛盾,没必要为低延迟牺牲吞吐。

5. 踩坑排查与实战心得

5.1 报了“Unrecognized VM option”怎么办

换到 Shenandoah 后第一件事就是确认环境。如果启动直接报错,先检查java -version,确认是 OpenJDK 12 而不是别的版本。其次检查 JDK 是否真的编译进了 Shenandoah,用前面说的 PrintFlagsFinal 命令验证。还有一个小坑是参数顺序:UnlockExperimentalVMOptions必须写在UseShenandoahGC前面,否则部分 JDK 版本会忽略后者的实验特性校验。

如果用的是某些魔改过的商业发行版,可能在构建时就去掉了实验功能,这时候换官方 OpenJDK 构建是最快的路。

5.2 退化 GC 和 Pacing 频繁出现

这是 Shenandoah 运维中最常见的问题。日志里频繁出现Degenerated GC,说明并发转移阶段兜不住分配压力,系统正在反复退回 STW。

优先检查三件事:

  1. 堆空间是不是太小,给并发转移留的余地不够。
  2. 分配速率波动是不是突然变大,比如缓存刷新的整点任务。
  3. 存活对象总量是不是超过了堆的一定比例,导致可分配的转移空间不足。

我的处理顺序一般是:先加堆,再把ShenandoahTargetPercentage调高,最后看ShenandoahAllocSpikeFactor。如果还不行,就需要在应用层削峰,让分配速率更平滑。

5.3 Metaspace 持续增长和类卸载问题

JDK 12 阶段的 Shenandoah 对类卸载的支持并不完善,尤其在动态生成大量代理类的场景下,Metaspace 有持续增长的风险。这个坑很隐蔽,因为堆内存看起来一直正常,但实际上元空间在慢慢涨。

如果你在跑 Shenandoah 的同时用了大量 CGLib、动态代理或者热部署框架,一定要加上 Metaspace 的大小监控。遇到异常增长,先确认是不是类卸载没生效,再考虑减少动态类生成或者暂时回到 G1。

这个问题在后续版本逐步改进了,但在 OpenJDK 12 上属于需要心里有数的短板。

5.4 停顿仍然偏长,怎么定位

有段时间我明明启用了 Shenandoah,线上还是能观察到几十毫秒的停顿。排查后发现问题不全在 GC,而在于安全点(SafePoint)同步。

Shenandoah 减少了 GC 造成的停顿,但 JVM 的很多操作仍然需要在安全点进行,比如偏向锁撤销、线程快照、某些 JIT 编译动作。如果业务线程长时间不进入安全点,其他线程就只能在同步点等待。

这种情况要用安全点日志来看:

-Xlog:safepoint=info

如果安全点停顿偏高,就要去查业务代码里是不是存在超长循环、大数组拷贝这类不容易到达安全点的逻辑。这算是一个比较容易被人忽略的排查方向,很多“GC 没有停顿但接口还是卡”的问题,最后都落在这里。

5.5 我的选择建议与观察清单

如果项目钉在 JDK 12 版本,又必须做低延迟选型,Shenandoah 是一个可行的方向,尤其适合大堆在线服务。但在上生产前,建议至少观察这几项指标:

  • Degenerated GC 次数和频率
  • Pacing Pause 的次数
  • Init Mark 和 Final Mark 的耗时
  • Metaspace 使用趋势
  • 安全点总耗时的变化

我在后来的项目中养成的习惯是:所有 JVM 优化类改动都先跑一周压测,重点比对 P99 和不含 GC 停顿的纯业务耗时,才能判断优化到底是“真的有效”,还是只是把问题从 GC 挪到了别处。

Shenandoah 给我的启发是,很多时候我们需要的不是更快的 GC,而是“慢得可预期”的 GC。如果你也在为偶发长停顿头疼,不妨按这篇文章的步骤,在测试环境先把这个收集器跑起来,看看它的停顿曲线到底能不能满足你的要求。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 10:53:04

C++植物大战僵尸模型:类设计、碰撞检测与对象池实战解析

简介&#xff1a;基于C实现的植物大战僵尸塔防游戏模型&#xff0c;面向对游戏开发感兴趣的C初学者与进阶者&#xff0c;可系统理解塔防游戏从逻辑设计到代码落地的全过程。资源内含可直接运行的exe程序、完整的C源码以及VS工程文件&#xff0c;方便边运行边对照代码逐模块推演…

作者头像 李华
网站建设 2026/10/17 10:52:43

MIMO系统广义奈奎斯特图:det[I+L(jω)]绘制与稳定性诊断

简介&#xff1a;本资源是一份面向通信工程、自动控制及信号处理方向本科生与研究生的MATLAB实践工具包&#xff0c;聚焦多输入多输出&#xff08;MIMO&#xff09;系统稳定性分析中的核心环节——广义奈奎斯特曲线绘制。它提供一套完整、可直接运行的函数脚本&#xff0c;解决…

作者头像 李华
网站建设 2026/10/17 10:52:03

OpenCV人脸识别实战:从检测对齐到模型训练与实时推理

简介&#xff1a;压缩包面向计算机视觉初学者与OpenCV爱好者&#xff0c;提供一套基于OpenCV的人脸识别实战案例&#xff0c;涵盖从人脸检测到特征匹配的完整流程。资源内共120个文件&#xff0c;以110张jpg图片为主&#xff0c;另有5张jpeg、2个xml级联分类器模型、1个Python脚…

作者头像 李华
网站建设 2026/10/17 10:48:04

2026年英语二小作文

题目写作思路参考作文Dear Jack,Im so glad that you shared with me a video of your familys trip in China.I was really happy when I saw you smiled, and I saw much beautiful scenery in the video.Id like to know more about your trip. Did you try the local food?…

作者头像 李华
网站建设 2026/10/17 10:47:48

C++原子操作memory order组合原理与实战

1. 为什么“组合应用memory order”才是C并发编程的真正分水岭你写过std::atomic<int> counter{0};&#xff0c;也用过counter.fetch_add(1, std::memory_order_relaxed);——但如果你只停留在“能编译通过”和“跑起来没崩”&#xff0c;那其实连C内存模型的门框都没摸到…

作者头像 李华
网站建设 2026/10/17 10:47:43

c语言的基本知识

1&#xff0c;“/”引起的错误&#xff1a;在我们现在的编译器中如果写下面这段程序&#xff0c;下面我们就来聊聊关于c语言的基本知识?接下来我们就一起去了解一下吧!c语言的基本知识1&#xff0c;“/”引起的错误&#xff1a;在我们现在的编译器中如果写下面这段程序int mai…

作者头像 李华