☰
JVM调优:STW无法消除的真相与现代GC的优化策略
2026/10/13 4:39:58 网站建设 项目流程

老Java人都知道,JVM调优和线上故障排查打交道时,最让人血压上升的词就是STW(Stop-The-World)。哪怕你已经用上了G1、ZGC,还是会发现日志里时不时出现一段“user=0.xx sys=0.xx real=0.xx”的停顿,业务线程在某几个毫秒里像被点了穴一样不动了。于是很多人会问:都202X年了,为什么 Java 还要发生 STW?难道不能设计一个完全不停顿的收集器吗?

这个问题问得特别实在,但答案比想象中复杂。先说结论:STW不是JVM团队不想消灭,而是“在并发环境下维护一致性”这件事本身有物理意义上的困难。既要回收垃圾、又要把对象搬走、还要保证所有线程看到的对象引用不会瞬间失真,这几件事叠加在一起,必然需要某个极短的时间窗口,让全体应用线程停下来对齐状态。现代收集器做的不是消灭这个窗口,而是把这个窗口从“几百毫秒”压缩到“几毫秒”,甚至压到亚毫秒级,同时另外一些阶段也尽量做到并发。

这篇文章我从原理讲到实践,聊几个大家最关心的问题:STW为什么必须存在?现代收集器里面还有哪些阶段必须STW?你在线上看到停顿日志时怎么判断是谁造成的?以及有什么实际可行的调优手段,能让停顿更可预测、更可控。不管你是刚接触JVM的新人,还是已经在生产环境跟GC搏斗多年的老兵,应该都能从里面找到点可落地的东西。

1. 先别急着骂:STW到底停在哪几毫秒

1.1 从一次Full GC说起

先还原一个最常见的场景。某天线上服务突然慢了一下,你抓GC日志,看到类似这样:

[Full GC (Ergonomics) 8192M->2048M(8192M), 0.5233410 secs]

这一行意味着所有应用线程停了约523毫秒,堆从8G降到2G。在这0.5秒里,你的接口、定时任务、消息消费者全都卡住。如果QPS很高,这半秒会导致一堆超时、堆积和告警。这就是Full GC的STW,传统意义上最“有名”的暂停。

但Full GC并不是唯一的暂停来源。哪怕平时没有Full GC,只看年轻代GC(Minor GC / Young GC),日志里也会有类似:

[GC (Allocation Failure) 1024M->128M(4096M), 0.0089020 secs]

这8.9毫秒是年轻代GC的STW时间。现在很多互联网服务的接口响应时间P99在几十毫秒级别,8.9毫秒已经很可观了。如果年轻代里有大量对象存活,或者引用关系特别复杂,这个暂停还会更长。

所以大家口中的STW,其实是一类事件的总称。具体来说,只要GC日志里出现了不止一个线程同时运行、并且期间应用线程被强制挂起,那就是一次STW。JVM源码里管这个叫“Safepoint”期间的停顿,所有Java线程必须在安全点上停下来,JVM才能做全局操作。

1.2 为什么“暂停”听起来简单,实现起来这么难

很多人有一个直觉:既然要暂停,那把线程停住不就行了?但实际场景里,把一个多线程Java进程“干净地停住”非常难。难点不只是“让线程不执行”,而是:停在哪、怎么恢复、停多久。

线程可能在执行字节码的任意位置,可能在执行JIT编译后的机器码,也可能正卡在synchronized锁上、正在JNI调用里、正在Object.wait()上面休眠。JVM不可能让线程在任意位置停止,然后立刻扫描它的栈和寄存器里的引用。为了能正确枚举所有“GC Roots”(局部变量、静态变量、JNI引用等),JVM必须让线程先运行到一个已知的位置,这个位置就是“安全点(Safepoint)”。

安全点机制有点像车站。火车不能在铁轨中央随便停,必须到达一个固定站台,乘客才能安全下车。线程从生成、执行到退出都是这样,JVM在生成机器码时会在循环回边、方法调用、异常抛出这些位置插入“GC轮询”指令,线程运行到这些点时会检查一个全局标志,如果JVM需要STW,线程就会在安全点阻塞并汇报。“强制停止”看起来是瞬间的,真正的等待时间却包含“最后一个线程走到安全点的时间”,所以STW实际感知时间往往比纯暂停时间要长。

更重要的是,暂停过程中JVM要做的事远不止“把对象标记出来”。它可能需要移动对象(年轻代复制、老年代压缩)、更新所有指向移动对象的引用、清理类引用、处理JIT编译产物等。这些操作如果允许应用线程并发读写引用,就会出现一个线程刚把对象A的引用改成对象B,另一个线程还在用旧地址读A,读到的可能是一块半移动状态的内存。结果就不是性能问题,而是内存崩溃、数据错误。所以,GC的关键阶段必须用一个全局停顿来锁住整个堆的状态。

2. 为什么JVM不敢在高速运转时做GC

2.1 堆是一个持续变化的“靶子”

把堆想象成一个巨大的仓库,GC线程是清理工,而业务线程是快递员。快递员不停地把包裹从货架A搬到货架B,同时包裹上贴的标签还在不断更新。清理工要在这种环境下辨认哪些包裹是“没用的空箱”,还要顺手把它们清走。问题是,快递员搬包裹的时候,包裹上的地址在不断变化,清理工看着看着就花了。

Java堆里的对象引用也是这个状态。一个对象的字段可能被线程1写为指向对象X,几微秒后被线程2改为指向对象Y。GC要标记存活对象,必须基于某一个瞬间的引用关系快照。如果每个业务线程都按自己看到的最新状态来修改引用,GC线程就会看到一个“自相矛盾”的图:对象A指向B,B又指向A,但是都被回收了?为了消除这种不一致,GC必须让所有修改引用的线程先停下来,让堆处于一个确定的、不再变化的稳定状态。

这也是为什么很多并发收集器依然需要“初始标记”或“最终标记”这种短暂STW的原因。比如CMS的“初始标记”可能只要几毫秒,但它的任务就是找到GC Roots,然后让并发标记阶段从这些根出发去遍历整个对象图。如果根都不一致,后面全乱套。

2.2 引用关系的一致性到底指什么

要理解这个“一致性”,可以做一个简单推演。假设有两个对象,A和B,A引用B。GC线程已经标记了A,正要标记B。此时应用线程突然运行,把A对B的引用改成A对C的引用,并且B再没有任何别的引用。如果没有同步机制,GC线程可能认为B存活(因为刚才从A出发看到了B),也可能漏掉B(因为A改引用后不再遍历B)。如果漏掉B,就会在引用关系变化之后立刻把B回收,可此时线程栈或寄存器里可能还有一个B的引用,程序随后访问B就出现悬垂引用。

为了避免这种“漏标”和“错标”,要么在应用线程修改引用时做大量记录和替换(比如写屏障、卡表),要么直接把修改引用和GC遍历之间加一个屏障,让它们不可能重叠。现代收集器选择的是混合方案:并发标记阶段用写屏障维护“增量更新”或“快照标记”,把新修改的引用记录下来,后续再处理。但无论如何,在“最终标记”阶段,还是需要一个一致的全局状态,把所有增量记录合并、收敛。这个收敛动作本身就需要各线程已经不再修改引用,所以要么特殊算法,要么直接STW。

“快照标记”这个词很多人不陌生,它的本质是把堆画一个时间点快照,并确保此后所有对该快照的修改都被记录。ZGC和Shenandoah都用了类似思想。可在实际实现中,“快照”和“当前状态”之间仍有一个转瞬即逝的窗口要做原子切换,这段切换在目前所有落地的收集器里,都选择了短暂停顿。

2.3 让应用线程与GC线程配合:安全点机制

前面提到安全点,它是STW能否“小于几毫秒”的关键。JVM在所有线程的生成代码里设置了“轮询点”。当JVM需要STW时,会设置一个全局标志_safepoint_state。线程运行到轮询点时会检查这个标志,如果发现要同步,就把自己挂起。

这里有个实际的坑:如果一个线程长期不进入安全点,其他线程就都得等它。比如一个Java线程进入了一个非常长的循环,而循环内部没有方法调用、没有分配对象,理论上可能很久都不检查安全点。JVM编译器的优化会尽量在每条循环回边位置插入“safepoint poll”,但如果循环体特别小且被JIT优化成极紧密的循环,poll开销也会被视为性能负担。

于是JVM提供了一些兜底手段,比如-XX:+UseCountedLoopSafepoints、-XX:GuaranteedSafepointInterval这些参数,或者在线程长时间不抵达安全点时,JVM运行时会触发“异步打断”。但从崩溃日志里,你依然可能看到“Thread stuck in infinite loop”或者“A thread may not have responded to an action”之类字样。实际上很多线上长停顿,并不是GC本身慢,而是某个线程迟迟不到安全点,把STW延后了。这时候GC日志会显示“safepoint sync”耗时很长,但后续GC操作耗时很短。所以排查STW时,别只盯着垃圾回收,也看看是不是线程在“躲”安全点。

3. 现代收集器已经把STW压到很小,但没消失

3.1 G1:还是有一小段“大动作”

G1是JDK 9之后默认的收集器,它把堆分成很多Region,混合GC时主要是并发标记+并发转移。它比CMS强的地方在于可预测停顿时间,-XX:MaxGCPauseMillis参数可以设目标值,比如200毫秒。但注意,G1的“可预测”指的是它在转移阶段尽量满足暂停目标,不代表所有阶段都是并发的。

G1的GC暂停主要发生在:

  • 年轻代GC:新生代Region里的存活对象要复制到Survivor或老年代,这个过程必须STW。
  • 混合GC的“最终标记”:需要处理并发标记阶段收集到的“下一次标记”的所有对象变化,有一个很短的STW。
  • 转移阶段:把选定Region里的存活对象移动到其他Region,需要更新所有引用,必须STW。
  • Full GC:当发生并发模式失败、晋升失败、巨型对象分配失败等情况时,会退化为传统Full GC,整个堆做标记-清除-压缩,STW时间可能几百毫秒甚至更长。

G1的设计哲学是“分区域处理,每次只搬一部分”,尽量让总暂停时间可控。可它到底还是要复制对象。复制对象时,如果让业务线程并发读,就需要非常复杂的“负载屏障”来处理“对象的旧地址和新地址同时可见”的问题。G1在很长时间里选择不这么做,而是把复制操作放STW里完成。直到JDK 12往后,G1也做了并发类卸载等方面的改进,但核心复制阶段的STW依旧存在。

3.2 ZGC和Shenandoah:几乎并发,但有一个“最后一站”

ZGC和Shenandoah是面向超低暂停时间设计的收集器,目标是把STW压到10毫秒以内。它们把“标记”、“转移”、“重映射”都做成并发,确实很惊艳。但你拉开它们一个GC周期的日志,会发现仍然有至少两处非常短的STW:一个是“Pause Mark Start”,一个是“Pause Mark End”。

以ZGC为例,它的GC周期大致是:

Pause Mark Start 0.123ms Concurrent Mark Pause Mark End 1.567ms Concurrent Prepare Relocation Concurrent Relocate ...

“Mark Start”用不到0.2毫秒,任务是把GC Roots做一次快照,并标记当前栈、静态变量等为起点。“Mark End”用一两毫秒,任务是把并发标记阶段的收尾、处理少量漏标对象、以及确定转移集合做一次全局统一。这两个暂停时间极短,但确实存在。

Shenandoah也一样。它通过“加载屏障”保证业务线程在并发转移阶段也能读到正确的新地址,理论上连“最终标记”都可以做成并发,但最终在触发“初始化转移”和“整理引用”时,仍需要一个短暂停顿来建立转移集合和清空更新队列。所以,真正把STW完全消灭的收集器,目前还没有商用级实现。

3.3 为什么连ZGC也有“最后一站”

可能有人问:既然CD(Concurrent Threads)都能并发干这么多事,为什么这两个暂停不去掉?这涉及一个工程现实:JVM需要保证“所有线程都处于一个已知状态”才能安全修改全局元数据。比如:

  • 需要暂停JIT编译器线程,避免它们正在生成的代码引用一个即将被移动的对象;
  • 需要暂停VMThread之外的所有Java线程,确保当前没有任何线程正在修改堆外数据结构(比如卡表、记忆集合);
  • 需要快速统计GC Roots,因为GC Roots除了堆内对象,还包含当前所有Java线程栈上的引用、JNI全局引用等,这些数据在并发状态下无法拿到稳定的快照。

因此,“最后一站”本质上是一个“一致性截断点”。并发部分再怎么努力,最终还是要让四海八荒的线程看一眼同一个秒表,然后由主GC线程把之前记录的“增量变化”合并成最后结论。因为这只是一个截断和合并动作,所以暂停时间很短,但“有”和“没有”是两回事。

换句话说,现代JVM用并发遍历换来了更短的STW,群面思想是用一个极短的全局屏障替代原来的长垃圾回收动作。这就像是大型会议里让所有人同时静默一秒,比让每个人轮流停下来一分钟要舒服得多,但静默本身依然存在。

4. 实际操作中的STW排查与调优

4.1 先判断停顿来自哪里

碰到线上服务偶发停顿,第一步不是改JVM参数,而是先定位停顿类型。最直接的方式是开GC日志。JDK 8到JDK 11用:

-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/to/gc.log

JDK 11及之后推荐统一日志:

-Xlog:gc*:file=/path/to/gc.log:time,uptime,level,tags

统一日志更细,能区分出safepoint、GC pause还是各种辅助线程工作。如果你用的是JDK 8,建议至少加上:

-XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1

这个参数会输出每次safepoint的“sync”耗时和“vmop”耗时。其中sync是线程到达安全点所花时间,vmop是JVM实际执行GC或其它VM操作的时间。如果一个操作显示sync time特别高,说明有线程响应慢;如果vmop time高,说明GC或脱机优化等动作本身慢。

4.2 GC日志里看STW的痕迹

我们拿一个G1日志片段来说:

[GC pause (G1 Evacuation Pause) (young) 512M->256M, 0.0453211 secs]

这段45毫秒的暂停来自年轻代GC,期间发生了对象复制。要优化这类暂停,可以看它的“一般块”里打印的各个阶段:

[Ext Root Scanning: 5.1 ms] [Evacuation: 18.3 ms] [Code Root Fixup: 2.5 ms] [Clear CLDG: 0.2 ms] [Resize TLABs: 0.1 ms]

如果Ext Root Scanning耗时高,通常是线程太多或者GC Roots扫描范围大,比如大量JNI引用、线程栈;如果Evacuation高,多半是存活对象太多,需要搬动的量太大,这时应该考虑增大堆、减少进入老年代的对象或调整Region大小。

如果是ZGC,日志更清晰,每个阶段都标注了并发的还是Pause。比如:

[GC#123 6905.3s] Concurrent Mark [GC#123 6905.3s] Pause Mark End 1.98ms [GC#123 6905.3s] Concurrent Prepare Relocation [GC#123 6905.3s] Concurrent Relocate

看到Pause Mark Start和Pause Mark End就知道是ZGC的固定停顿点。正常生产环境下,ZGC一个周期的总STW时间通常在几毫秒内。如果出现几十毫秒甚至更大的Pause,大概率是元数据分配压力、JIT脱机卸载、或者某种外部原因(比如系统CPU被抢占、内存交换)导致。

4.3 业务侧减少STW影响的常用手段

说完原理,实操上有些“立竿见影”的招数,都是我试过之后觉得靠谱的:

  1. 不要迷信一个“大堆”。堆越大,GC扫描和搬运动作通常越重,STW也越长。应该结合业务对象生命周期合理评估堆大小,尤其避免把堆设到几十个G但堆内大部分是生命周期很长的缓存对象。

  2. 优先控制对象晋升。调大年轻代、减少大对象直接进入老年代,可以延长混合GC周期,但别指望这能消除STW。更有效的做法是减少业务系统中不必要的对象分配,比如避免在热路径上反复创建巨大数组、大量临时对象。

  3. 谨慎设置-XX:MaxGCPauseMillis。G1默认尝试的目标是200毫秒,但如果你把目标设得太小比如20毫秒,G1为了满足目标会频繁发起垃圾回收,反而降低吞吐,甚至导致更多的STW总开销。要找到吞吐与延迟的平衡点。

  4. 关闭偏向锁、减少锁竞争。锁竞争本身不直接产生STW,但会让线程在安全点附近停留更久,或者导致Threads数量过大,最终拖慢STW的收敛过程。

  5. 注意JIT的“类卸载”停顿。某些服务大量动态生成类,GC的“类卸载”阶段需要遍历类加载器,STW会变长。可以考虑使用-XX:+ClassUnloadingWithConcurrentMark(在支持并发类卸载的收集器上),并尽量减少非必要动态代理、反射生成类的数量。

  6. 使用无STW的替代方案并非“万能药”。ZGC和Shenandoah可以把暂停压到极低,但会消耗更多CPU做并发着色和屏障。如果你的服务CPU核数紧,吞吐量要求高,未必划算。要按场景选,而不是“无脑上ZGC”。

下面给一个典型的Shenandoah配置案例,先在测试环境压测:

-XX:+UnlockExperimentalVMOptions -XX:+UseShenandoahGC \ -XX:ShenandoahGCHeuristics=adaptive \ -XX:+PrintGCDetails -Xlog:gc*:file=gc.log:time

压测时先不加参数跑一轮,记录P99和GC日志;再加Shenandoah跑一轮,对比暂停时间和吞吐。很多时候你会发现,可能线上P99并没有因为低STW变得更好,因为业务瓶颈在锁、网络或数据库。所以,判断“STW是不是核心矛盾”比直接跳进调参更重要。

5. 关于STW的经典误区和避坑手册

5.1 STW越少越好?未必

性能优化要向业务指标对齐。STW极低但CPU耗损巨大,总吞吐下降,最后可能表现为应用“不卡了,但QPS也低了”,这未必是你想要的。再举个极端例子:给你一个完全无STW的收集器,但它在4核机器上占了3.5个核做GC,剩下0.5个核跑业务,这个服务可能还没老GC卡顿撑得久。

正确评估标准有三个:业务可容忍的最大暂停时间、平均暂停时间、以及GC占用的CPU开销。你只需要把STW控制在可容忍范围内,而不是追求理论上的“零”。很多大型后端服务的RT指标是“P95 < 200ms”,那一次50ms的STW完全可接受;但对量化交易、高频交易系统来说,一次5ms的停顿可能都是事故,这时ZGC/Shenandoah才值得用。

5.2 “按需分配”的堆和默认参数

有不少同学遇到STW就直接-Xmx设大,认为堆越大GC越少。这确实会减少GC频率,但也会拉长单次STW。因为Full GC标记和清理由堆大小决定,G1年轻代GC则与新生代大小强相关。单纯靠大堆换低频率,就像把一个房间无限放大,结果每次清扫都要花更长的时间。更合理的方式是:先分析存活对象,找出一个“最小可用堆”加上一定余量,同时避免堆频繁扩容和缩容。如果服务经常出现Allocation Failure触发GC,说明堆偏小或分配速率太高;如果GC日志里老年代占用率长期低于50%,说明堆可能偏大,可以适当下调。

默认参数也不是“最优参数”。比如JDK默认的-XX:MaxHeapFreeRatio与-XX:MinHeapFreeRatio会影响堆收缩,可是多数线上服务并不希望堆频繁伸缩,最好显式配置为一套固定逻辑。还有-XX:+UseContainerSupport(JDK 10+默认开启)会识别容器内存限制,如果你用了旧版本而不支持容器,就可能因为看到的还是宿主机内存而配错堆大小。

5.3 未来:STW会彻底消失吗

从学术研究角度看,有一些“并发总是安全”的算法,比如基于引用计数、基于Tri-color marking的并发变体,理论上可以完全消除停顿。但在商用JVM里,需要同时满足跨平台、稳定、兼容大量Native接口和工具链,引入完全无停顿算法会带来难以预估的可观测性和调试成本。至少在未来几年内,我判断STW不会消失,只会在最先进的收集器里被压得越来越短,越来越接近“可忽略”。

同时,业务侧对延迟的极致追求,已经催生出另一种思路:避免在Java线程内做GC敏感操作,比如把大热数据交给堆外内存管理、用低延迟框架减少线程切换成本、或者干脆用支持UMA/堆外对象模型的运行时。但这些都是“绕开STW”,不是“消灭STW”。

最后分享一个我踩过的小坑。有段时间某服务线上偶发500ms级停顿,GC日志看到的是Full GC,但磁盘读性能也很差,后来定位到是云主机内存轻度交换,GC线程在扫描大堆时频繁缺页,导致STW被拉长。我说这个案例是想提醒大家:不要一看到STW就默认“Java垃圾回收器有问题”,先排除操作系统层面的CPU抢占、内存交换、磁盘IO抖动,再回来优化JVM。很多时候,真正暂停的原因藏在你没有注意的地方。

如果下次有人再问“为什么Java还要STW”,你可以告诉他:STW是JVM为了维持并发堆一致性而付的“契约金”。理解这层契约,比单纯追求把暂停数字打成0,更能帮你在真实业务里做出正确的取舍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询