ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

JVM垃圾回收导致服务假死?一次完整GC停滞诊断与调优实战

2026/9/15 6:26:32 拓冰建站 浏览量
JVM垃圾回收导致服务假死?一次完整GC停滞诊断与调优实战 “线程全部卡住接口响应全红了CPU占用却不高服务像死了一样。”这是上个月一个用户量不算小的线上服务出故障时我在监控大屏上看到的现象描述。故障持续了几十秒之后一切恢复正常仿佛什么都没发生过。但就是这几十年一遇的“时间静止”让我把排查重心从网络、锁竞争一路拉到了JVM垃圾回收GC上。GC导致的长时间停滞在Java服务端是出了名的隐形杀手它不像OOM那样直接崩溃也不像死锁那样完全不可用而是以“周期性假死”的方式存在极难复现也极难定位。这篇文章我就用一次真实的诊断过程把JVM垃圾回收停滞背后的原理、工具和排查思路完整拆一遍。无论你是刚接触JVM调优的开发者还是已经在用G1或者ZGC的资深工程师这套方法论应该都能帮你少走很多弯路。1. 先搞清楚GC停顿到底是怎么回事1.1 “时间静止”在JVM里是怎么发生的JVM里的垃圾回收并不是一边回收、一边让业务线程照常运行的。为了保证对象引用关系的正确性绝大多数回收动作都需要在“所有业务线程全部暂停”的状态下进行这就是我们常说的Stop-The-World简称STW。STW期间应用线程完全冻结用户请求排队等待表现就是接口超时、服务无响应严重的时候甚至会让负载均衡器误判服务宕机直接把节点摘掉。很多刚接触JVM的人会有一个误解STW只发生在Full GC的时候。实际上即便是号称“并发回收”的CMSConcurrent Mark Sweep或者G1也包含多个必须STW的阶段。比如G1的初始标记Initial Mark、最终标记Final Mark、混合回收Mixed GC的转移阶段都需要暂停业务线程。区别只在于暂停时间的长短和频率。换句话说完全的“无停顿”在目前主流的回收器里是不存在的只是把停顿控制得更短、更分散而已。“时间静止”这个词用来形容GC引发的服务假死非常贴切。一次几秒钟的Full GC就能让一个日活百万的服务在用户侧“蒸发”几十秒这在交易、支付、实时推荐这类场景下是致命的。所以理解GC停顿核心就是理解STW发生在哪、持续多久、为什么这么久。1.2 停顿的层次从毫秒级到分钟级GC停顿在影响面上分三个层次实际排查时经常混淆。第一层是Minor GC/Young GC停顿通常几十毫秒以内影响有限但频率高的时候同样会造成吞吐量下降。第二层是Old GC/Full GC停顿从几百毫秒到几秒都有可能这通常伴随着堆内存压力过大、晋升速率过快或者回收器配置失当是线上“时间静止”的主要来源。第三层是JVM本身之外的系统级停顿比如操作系统内存交换Swap、频繁的Page Cache写回、容器CPU限流等这类问题在GC日志里看不出来但症状和GC停滞几乎一样排查时要纳入考虑范围。不同层次的停顿排查思路完全不同。Young GC频繁往往和新生代太小、对象分配速率太高有关Full GC时间长就要看是老年代满了、元空间膨胀了还是出现了大对象Humongous Object导致GC压力剧增如果GC日志正常但服务还是卡顿那就要往操作系统和硬件的方向查。诊断的第一步应该是先确定你遇到的到底是哪一个层次的停顿而不是盲目堆内存或者换回收器。1.3 为什么线上问题总是很难复现GC停滞类的故障和普通Bug最大的差异在于你很难在测试环境复现同样规模的停顿。原因有三点。第一测试环境的QPS和线上差一两个数量级对象分配速率不同GC行为就完全不同。第二数据特征不同线上存在大量特定模式的对象比如大数组、缓存、长生命周期对象这些在测试数据里很难模拟。第三JVM的即时编译JIT需要时间预热短时间压测时JIT还没完成优化GC行为也和长时间运行的线上实例有明显差异。这就决定了诊断GC问题的方法论必须回到现场GC日志、堆转储Heap Dump、线程快照Thread Dump、监控指标缺一不可。下面我会完整演示一次实际排查过程你可以直接套用这套流程。2. JVM内存模型与回收器选型决定你遇到哪种“停滞”2.1 堆内存分区是理解一切的基础JVM的堆内存Heap按代际划分典型结构是新生代Young Generation和老年代Old Generation。新生代里又分为Eden区和两个Survivor区S0、S1比例默认是8:1:1通过-XX:SurvivorRatio调整。新对象分配在Eden区Minor GC时存活对象通过复制算法在两个Survivor区之间来回拷贝每次拷贝年龄加一超过-XX:MaxTenuringThreshold默认15后晋升到老年代。理解这个模型的意义在哪里GC停滞的根源几乎都能在这套分区体系里找到。比如Eden区过小Minor GC就会很频繁Survivor区过小大量对象来不及熬过年龄阈值就被提前晋升到老年代进而触发Full GC老年代持续高位CMS或者G1的并发周期就会一直跑并发失败后还会退化成Serial Old式的串行Full GC停顿时间暴涨。很多JVM调优其实就是在调这三个区域的容量配比让对象在新生代“多活几个轮回”减少晋升压力。这套“分代收集”理论并不是Java独有的像C#的垃圾回收也采用了类似的分代策略。Java和C#在这方面思路相近但JVM的回收器实现更丰富参数更复杂这也是同一个问题在Java生态里更容易被讨论、也更容易出事故的原因。2.2 回收器家族谱系从Serial到ZGCJDK各个版本内置了多款垃圾回收器选型直接影响你的停顿表现。Serial最古老适合单线程、堆内存极小的场景比如客户端应用Parallel是JDK 8默认回收器强调吞吐量适合后台批处理但Full GC时停顿明显CMS从JDK 9开始废弃JDK 14移除它的并发标记清理思路在当时很先进但碎片化和并发模式失败是硬伤G1从JDK 9开始成为默认主打可预测的停顿时间模型通过把堆划分为多个Region优先回收垃圾最多的Region是目前服务端主流ZGC和Shenandoah则是超低停顿回收器ZGC的停顿时间可以控制在10ms以内适合超大堆场景JDK 15之后逐步成熟。选回收器本质上是在吞吐量、停顿时间、内存占用、实现复杂度之间做权衡。Parallel把吞吐量做到极致代价是STW时间长G1牺牲少量吞吐量换取更可控的停顿ZGC为了低停顿额外增加了内存屏障和着色指针等开销。没有“最好的回收器”只有“最适合业务特征的回收器”。比如你是个跑批任务的后端服务对延迟不敏感Parallel GC反而更合适如果是在线交易系统G1或者ZGC更匹配。2.3 不同回收器的“暂停”特征对比我刚入行那会儿经常把CMS和G1混为一谈觉得都是并发回收器停顿时间应该差不多。实际上差异非常大。CMS的并发标记和并发清理阶段确实不暂停业务线程但它的初始标记和重新标记需要STW最重要的是CMS在并发清理时如果老年代被业务线程写满了会触发Concurrent Mode Failure直接退回Serial Old进行串行Full GC这个停顿经常是十几秒起步。G1的思路是把堆切成一个个Region通过追踪每个Region的垃圾堆积价值在满足停顿时间目标-XX:MaxGCPauseMillis的前提下选垃圾最多的Region优先回收。G1的Young GC需要STW但停顿时间通常远低于Parallel它的Mixed GC会并发标记STW转移垃圾最多的Region。G1最怕的是大对象分配如果一个对象超过Region大小的50%会被直接分配进连续的巨型RegionHumongous Region这种对象回收压力很大很容易导致Full GC。至于ZGC它把STW时间压缩到了极致染色指针和读屏障让绝大部分回收工作都能和应用线程并发进行。但我个人不建议一上来就用ZGC因为它的CPU开销相对更高而且对堆内存大小和JDK版本有要求核心业务建议在充分压测后再上。下面用一个表格直观对比主流回收器的停顿特征。回收器主要STW阶段典型停顿时间适用场景主要风险SerialYoung GC / Full GC全程数十ms~数s客户端、极小堆停顿不可控ParallelYoung GC / Full GC全程数十ms~数s批处理、吞吐优先Full GC长停顿CMS初始标记/重新标记数百ms级JDK 8时代在线服务碎片化、并发模式失败G1Young GC / Mixed GC转移数ms~数百msJDK 9在线服务大对象、转移失败ZGC极少数阶段10ms超大堆低延迟场景CPU开销较高对大部分团队来说当前最优解基本上是在G1和ZGC之间做选择。JDK 8上跑CMS的老项目建议优先升级到JDK 17并切换到G1收益非常明显。3. 深度诊断实操一次Full GC长时间停顿的完整排查3.1 故障现场现象、监控与第一反应回到开头那个线上故障。监控数据显示服务在晚上八点准时出现一次长达40多秒的无响应窗口期间CPU使用率反而下降到接近0内存使用率接近95%。故障结束后一切恢复GC指标出现一次明显的异常峰。这种“CPU低、内存高、定时出现”的组合几乎立刻让我锁定了方向不是死锁死锁时CPU通常也低但不会周期性出现也不是网络问题网络故障不会这么规律大概率是GC停滞而且很可能是Full GC。第一反应不要急着重启机器或者加内存。正确做法是先把证据拿到手。我立刻执行了三件事确认GC日志是否开启查看监控系统里最近一小时的Full GC次数和时间记录下故障发生前后的存活对象情况。如果GC日志没开后面一切都是盲人摸象。这也是我一直强调的生产环境JVM参数里-Xlog:gc*这类日志开关必须打开哪怕你觉得永远用不上。如果你的服务已经出现Full GC但GC日志开关没开也不是完全没办法。可以通过jstat -gcutil pid 1000每秒钟采样一次堆内存使用率和GC耗时能大致定位到Full GC的时间点但看不到完整的GC Cause和对象统计定位起来会慢很多。所以我的习惯是只要是长期运行的服务GC日志和必要的转储开关一律在启动参数里配好。3.2 用GC日志还原“案发经过”拿到GC日志之后第一步是看GC Cause。GC日志里每个GC事件都会标注触发原因常见的包括Allocation Failure分配失败、Metadata GC Threshold元空间达到阈值、System.gc()显式调用、ErgonomicsJVM自适应调整、G1 Humongous AllocationG1大对象分配。如果是System.gc()那就去搜代码里哪里调用了System.gc()或者Runtime.getRuntime().gc()很多框架比如Netty、RMI都可能在特定条件下触发这类Full GC是“人祸”而不是“天灾”。我那一次排查到的原因就是G1 Humongous Allocation翻译过来是“巨型对象分配失败导致Full GC”。日志里频繁出现类似G1 Humongous Allocation的Cause并且伴随Pause Full (G1 Humongous Allocation)事件。所谓大对象指的是大小超过G1 Region Size一半的对象-XX:G1HeapRegionSize默认会根据堆大小自动调整通常在1MB到32MB之间。如果一个大对象超过这个阈值G1无法把它放进普通的Region只能占用连续的巨型Region而这些巨型Region一旦堆空间紧张就会直接触发Full GC。接下来要做的就是从堆转储或者代码路径里定位是什么对象这么大、谁分配的。这一步需要和业务代码特征结合。我当时通过jmap -histo:live pid和jmap -dump:live,formatb,fileheap.hprof pid拿到了堆转储用MATMemory Analyzer Tool分析后发现罪魁祸首是一个2MB大小的byte[]数组由某个报表导出功能创建。这个导出功能每次执行都会把一批数据全量加载到内存然后一次性写入响应流QPS一高立刻制造大量大对象。3.3 结合堆转储与线程快照锁定元凶拿到堆转储之后别直接看Dominator Tree支配树先看Histogram直方图。按对象总大小排序一眼扫过去byte[]、char[]、Object[]这几类占用最高的往往就是问题所在。然后用“Find Object by Name”或者“Search for unreachable objects”查一下这些大对象是从哪个线程、哪段业务逻辑分配出来的。MAT的“Thread Overview”和“Leak Suspects”能帮你快速定位到可疑的调用链。线程快照Thread Dump在这个场景里同样有价值。GC停顿期间所有的业务线程都卡住线程栈里能看到线程当前处于什么锁等待或者代码执行状态。如果多次取样发现大量线程停留在某个对象分配相关的代码路径上比如在byte[]的分配处、在某个流的读取处那基本可以确定这些线程正在刺激堆内存不断分配新对象。我当时在故障时间段抓了三次jstack发现大量线程停在FileOutputStream.write一个报表导出的路径上和堆转储的结果互相印证。诊断到这一步根因已经很清楚了一个占用大内存的报表导出接口在高峰期集中被调用制造了大量G1巨型对象这些对象占用大量连续Region触发G1 Humongous Allocation类别的Full GC最终表现为整个服务“时间静止”40秒。3.4 参数调整与效果验证根因是业务代码的分配特征太差所以治本方案是改代码报表导出改成流式查询加分批写入避免一次性把全量数据装进内存对于大对象能拆就拆拆不掉就考虑使用堆外内存。在你改完代码之前JVM参数可以做几件事来缓解症状。第一调大-XX:G1HeapRegionSize让大对象不再被视为巨型对象。比如把Region Size从默认的2MB提高到4MB那么2MB的数组就不会再触发巨型分配。但要注意Region Size越大G1的粒度越粗停顿时间目标的控制精度会下降不要盲目调到32MB。第二适当调大新生代-Xmn给短命大对象更多周转空间尽量减少直接晋升老年代的概率。第三设置-XX:MaxGCPauseMillis并监控Mixed GC是否达标但不要把这个值设得过低否则G1会为了迎合目标而频繁进行GC反而降低吞吐量。修改参数后我观察到Full GC的频率从每半小时一次降到两天一次Pause时间从40秒降到200毫秒以内。在业务代码优化上线后Full GC基本消失服务恢复稳定。这次排查看似走了很多弯路但事后复盘真正高效的路径其实很清晰监控发现特征模式GC日志定位Cause堆转储锁定对象代码确认分配路径参数缓解代码根治。这套流程在之后的多次GC问题诊断里反复验证有效。4. 常见问题排查实录与避坑指南4.1 高频问题速查表在我处理过的GC问题里有一批问题出现频率极高很多人反复踩坑这里整理成一张速查表方便直接对照。症状常见原因诊断方向处理建议Full GC频繁但堆内存不高元空间达到阈值查看Metadata GC Threshold调大-XX:MetaspaceSize和-XX:MaxMetaspaceSizeFull GC后内存占用依然很高对象未被真正回收检查堆转储中的GC Roots排查静态集合、缓存、ThreadLocal泄漏GC日志中出现System.gc()代码显式调用或框架触发搜索代码和依赖删除显式调用或者加-XX:DisableExplicitGCG1出现Humongous AllocationFull GC大对象分配过多堆转储定位大对象代码层拆分大对象调大Region Size出现Allocation Failure频繁Young GC新生代过小或分配速率过高jstat观察Eden区使用率调大新生代优化对象分配逻辑服务卡顿但GC日志正常系统级寄顿检查Swap、CPU限流、IO等待排查操作系统和容器资源配置JVM堆内存耗尽Gradle构建失败构建工具守护进程堆不足查看Daemon日志调整gradle.properties中的jvmargs开发工具频繁OOMIDE堆内存配置不足查看IDE日志在IDE配置中调大-Xmx有一类问题是很多人在开发阶段就会遇到的比如运行Gradle构建时出现expiring daemon because jvm heap space is exhausted这表示Gradle守护进程Daemon的堆空间耗尽了。和线上服务不同这是构建工具的堆配置问题通常改一下gradle.properties里的org.gradle.jvmargs-Xmx2048m就能解决。还有人在IDE启动时看到cannot collect jvm options caused by...的报错这种多数是指向的JVM配置文件路径有问题或者配置参数格式不对跟运行时GC是两码事但都属于JVM调优范畴里的“日常小坑”。4.2 排查工具使用心得工欲善其事必先利其器。GC诊断常用的工具有这么几个我会在每次实战里组合使用。jstat是第一个要用的jstat -gcutil pid 1000能实时看到Eden、Survivor、Old、Metaspace的占用百分比和GC次数、耗时适合快速判断当前GC压力。jmap用于查看堆内存分布和导出堆转储-histo适合快速概览-dump适合做深度分析。jstack在GC期间抓线程快照配合GC日志能还原暂停期间业务线程的状态。MAT和VisualVM是堆转储分析工具VisualVM适合快速看概览MAT适合做泄漏分析用Leak Suspects能自动判别可疑对象。还有一个很多人忽略的组合技jcmd。它是JDK 7之后自带的多功能命令可以打印堆信息、GC统计、JIT编译状态还能触发JFRJava Flight Recorder录制。jcmd pid GC.heap_info和jcmd pid GC.class_histogram是jmap的现代替代方案在部分受限环境里更安全。JFR则是终极武器-XX:StartFlightRecording可以在不重启JVM的情况下持续记录GC、JIT、锁竞争、IO等所有热点数据事后用JMCJava Mission Control分析很多“偶发性”问题都可以通过JFR捕获。我个人在排查GC问题时最推荐的工作流是先用jstat确定GC压力级别再开JFR做一次短时录制10分钟到1小时把GC事件、线程状态、分配速率全部记录下来然后用JMC打开直接看GC的Pause时间和分配压力曲线。JFR的开销非常低生产环境长期开启也没问题这是目前Java生态里最接近“一劳永逸”的监控方案。4.3 日常开发中的JVM配置提醒前面讲的都是线上问题但JVM配置其实从日常开发阶段就应该打好底子。很多人习惯把IDE的堆内存设得很小结果Indexing和编译期间频繁触发Young GC整个IDE卡到怀疑人生。主流配置建议是IDEA里在Help菜单下的Change Memory Settings处把堆内存调到2GB到4GB之间具体看本机内存。Gradle构建时在gradle.properties里设置org.gradle.jvmargs-Xmx2048m -XX:MaxMetaspaceSize1024m能避免大部分构建时的OutOfMemory问题。理解JRE和JVM之间的关系也有助于排查。JDK里包含JRE和一堆开发工具JVM是JRE的核心执行引擎。我们说的JVM调优调的是运行Java字节码的那个虚拟机的行为参数。IDE和Gradle这类工具本身也是跑在JVM上的Java程序所以它们同样需要合理的堆配置不然就会出现开发阶段特有的“JVM假死”。很多人以为“JVM调优”是线上才需要做的事实际上线上问题的很多隐患在开发阶段就埋下了比如测试环境堆内存配得很大掩盖了代码里的内存缺陷一上生产就原形毕露。另外说一句关于jvm面试题的。这类问题现在面试官尤其爱问JVM内存模型是什么GC回收器有哪些G1和CMS有什么区别实际工作中如果你真的处理过GC停滞回答这些问题时能讲出真实的故障场景、分析过程和参数调整依据比背十遍八股文都有说服力。写这篇文章也是希望能帮你在面对“为什么系统会突然卡住”这类经典问题时真的能从GC的角度给出有理有据的答案。5. 写在最后的排查心法这次“时间静止”故障处理完之后我在复盘文档里写了一段话很适合作为结尾分享给正在读这篇文章的你。排查GC停滞这类问题最大的障碍不是工具不会用不是参数记不牢而是心态。故障一旦发生所有人都在催你“赶紧恢复”这个时候最容易做出的错误决策是重启大法——先重启再说。但重启会让所有现场证据灰飞烟灭如果根因是代码里的大对象分配逻辑重启十次也解决不了下一次高峰期故障会准时复发。正确做法可以分四步走。第一稳定情绪抓现场确认GC日志和监控是否就位不行就现开。第二利用监控和日志找出故障规律是周期性还是随机性是跟流量高峰相关还是跟特定业务操作相关。第三用GC Cause和堆转储锁定根因类别再结合代码定位细节。第四先通过参数调整缓解症状同时安排代码修复上线。我自己的经验是一个稳定的JVM服务70%靠合理的参数30%靠代码质量。参数不合理再好的代码也会在流量冲击下现原形代码分配模式差参数调得再漂亮也只是拖延问题。真正的高效团队会把GC日志、堆转储、JFR这些基础能力在服务上线第一天就配置好而不是等故障发生后才想起来。真正能让你在“时间静止”里快速苏醒的不是运气是你提前布下的观察能力和一套持续演练过的排查方法论。