JVM调优:STW无法消除的真相与现代GC的优化策略 老Java人都知道JVM调优和线上故障排查打交道时最让人血压上升的词就是STWStop-The-World。哪怕你已经用上了G1、ZGC还是会发现日志里时不时出现一段“user0.xx sys0.xx real0.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只看年轻代GCMinor 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不敢在高速运转时做GC2.1 堆是一个持续变化的“靶子”把堆想象成一个巨大的仓库GC线程是清理工而业务线程是快递员。快递员不停地把包裹从货架A搬到货架B同时包裹上贴的标签还在不断更新。清理工要在这种环境下辨认哪些包裹是“没用的空箱”还要顺手把它们清走。问题是快递员搬包裹的时候包裹上的地址在不断变化清理工看着看着就花了。Java堆里的对象引用也是这个状态。一个对象的字段可能被线程1写为指向对象X几微秒后被线程2改为指向对象Y。GC要标记存活对象必须基于某一个瞬间的引用关系快照。如果每个业务线程都按自己看到的最新状态来修改引用GC线程就会看到一个“自相矛盾”的图对象A指向BB又指向A但是都被回收了为了消除这种不一致GC必须让所有修改引用的线程先停下来让堆处于一个确定的、不再变化的稳定状态。这也是为什么很多并发收集器依然需要“初始标记”或“最终标记”这种短暂STW的原因。比如CMS的“初始标记”可能只要几毫秒但它的任务就是找到GC Roots然后让并发标记阶段从这些根出发去遍历整个对象图。如果根都不一致后面全乱套。2.2 引用关系的一致性到底指什么要理解这个“一致性”可以做一个简单推演。假设有两个对象A和BA引用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也有“最后一站”可能有人问既然CDConcurrent 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.logJDK 11及之后推荐统一日志-Xlog:gc*:file/path/to/gc.log:time,uptime,level,tags统一日志更细能区分出safepoint、GC pause还是各种辅助线程工作。如果你用的是JDK 8建议至少加上-XX:PrintSafepointStatistics -XX:PrintSafepointStatisticsCount1这个参数会输出每次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影响的常用手段说完原理实操上有些“立竿见影”的招数都是我试过之后觉得靠谱的不要迷信一个“大堆”。堆越大GC扫描和搬运动作通常越重STW也越长。应该结合业务对象生命周期合理评估堆大小尤其避免把堆设到几十个G但堆内大部分是生命周期很长的缓存对象。优先控制对象晋升。调大年轻代、减少大对象直接进入老年代可以延长混合GC周期但别指望这能消除STW。更有效的做法是减少业务系统中不必要的对象分配比如避免在热路径上反复创建巨大数组、大量临时对象。谨慎设置-XX:MaxGCPauseMillis。G1默认尝试的目标是200毫秒但如果你把目标设得太小比如20毫秒G1为了满足目标会频繁发起垃圾回收反而降低吞吐甚至导致更多的STW总开销。要找到吞吐与延迟的平衡点。关闭偏向锁、减少锁竞争。锁竞争本身不直接产生STW但会让线程在安全点附近停留更久或者导致Threads数量过大最终拖慢STW的收敛过程。注意JIT的“类卸载”停顿。某些服务大量动态生成类GC的“类卸载”阶段需要遍历类加载器STW会变长。可以考虑使用-XX:ClassUnloadingWithConcurrentMark在支持并发类卸载的收集器上并尽量减少非必要动态代理、反射生成类的数量。使用无STW的替代方案并非“万能药”。ZGC和Shenandoah可以把暂停压到极低但会消耗更多CPU做并发着色和屏障。如果你的服务CPU核数紧吞吐量要求高未必划算。要按场景选而不是“无脑上ZGC”。下面给一个典型的Shenandoah配置案例先在测试环境压测-XX:UnlockExperimentalVMOptions -XX:UseShenandoahGC \ -XX:ShenandoahGCHeuristicsadaptive \ -XX:PrintGCDetails -Xlog:gc*:filegc.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:UseContainerSupportJDK 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更能帮你在真实业务里做出正确的取舍。