拓冰建站拓冰建站
首页 / 资讯中心 / 正文

JVM GC停滞深度诊断:STW、安全点与Full GC根因排查实战

收到告警的那一刻服务已经“死”了两分钟。进程还活着CPU 也没有跑到 100%端口还能连上但所有请求都堆在队列里一动不动。这种最像“假死”的状态十次里有七八次都是 JVM 垃圾回收GC卡在了不该卡的位置上。做 Java 开发的人最怕的不是报错而是这种毫无报错的停滞——日志里没有任何异常却像有人按下了暂停键整个 JVM 时间静止了。这篇文章我想专门聊聊 JVM 垃圾回收陷入停滞时的深度诊断思路。我会从 jvm 工作原理和 jvm 内存模型讲起把 STW、安全点、晋升失败、并发模式失败这些导致停顿的根因拆开再给出一套自己平时排查这类问题的完整流程和工具组合。适合正在做 jvm 调优、处理线上 OOM 或者被 Full GC 卡到怀疑人生的后端开发同学看也适合准备 jvm 面试题时想真正理解 GC 停顿机制的人。看完你会知道遇到 GC 停滞不应该一上来就调参数而是要先回答“停在哪里、为什么停、到底是谁在踩刹车”这三个问题。1. 先搞清楚“时间静止”到底是什么1.1 JVM 的内存模型与垃圾回收的“舞台”聊 GC 停滞之前必须先把 JVM 内存模型摆到台面上。绝大多数停顿都发生在这几个“舞台”上堆内存里的新生代Eden、Survivor From、Survivor To、老年代以及堆外的方法区/元空间Metaspace。新生代的特点是“来得快、死得也快”对象在 Eden 区被批量创建Young GC 时把存活对象拷贝到 Survivor 区达到年龄阈值后再晋升到老年代。老年代则存放长生命周期对象空间一旦吃紧就会触发 Major GC 或 Full GC。元空间存的是类元数据很多人在配置里忽略了它结果类加载多了之后元空间不断扩容也会引发停顿。GC 的所有工作本质上都是围绕“找到垃圾、回收垃圾、整理碎片”这三个动作展开的。找到垃圾要遍历 GC Roots回收垃圾要清理内存整理碎片要移动对象。这三个动作里移动对象是最耗时的而 JVM 为了保证对象移动过程中引用关系的一致性必须暂停所有业务线程。这一暂停就是所谓的 Stop The WorldSTW。垃圾回收的停滞问题其实大部分都是在说 STW 太长、太频繁或者某个阶段在并发执行时反而变成了变相阻塞。1.2 为什么“停一下”是不可避免的很多第一次接触 jvm 工作原理的人会问能不能让垃圾回收完全不暂停业务线程答案是不能完全做到至少到目前为止传统回收器做不到。原因很简单JVM 在回收时可能要把存活对象从 A 地址挪到 B 地址如果业务线程同时正在读写这个对象就会出现“读到了旧地址”、“写到了已被搬走的位置”这类错乱。为了在挪动对象时保证所有线程看到的引用是统一的JVM 会在安全点Safepoint上做全局暂停。安全点其实就是一组特定的指令位置JVM 要暂停时会等待所有业务线程运行到最近的安全点上然后才统一挂起。这个机制本身没问题问题往往出在“等待线程进入安全点”这个环节上。线程在安全点外的代码里执行时间过长比如正在执行一个很深的循环、正在做 JIT 编译、正在持有锁阻塞JVM 就得傻等它。我遇到过一类很特别的“停滞”GC 日志里显示 STW 只有几十毫秒但整个进程却被卡了好几百毫秒最后排查发现是某个线程长时间停留在安全点外导致 JVM 一直进不了 GC 的暂停阶段。这种问题光看 GC 日志是看不出来的必须看安全点统计。这也是我觉得 jvm 深度诊断最迷人的地方——表面症状是垃圾回收真正的病根却可能藏在调度、锁、JIT 或者线程池里。1.3 停顿可接受与不可接受的边界得先把话说清楚不是所有 STW 都是病。一次几百毫秒以内的 Young GC 停顿在绝大多数业务场景下是可以接受的。真正需要警惕的是这几类信号单次 STW 超过 1 秒而且持续出现。停顿频率莫名其妙升高比如原来几分钟一次 Full GC变成几十秒一次。老年代占用率一直居高不下回收完成之后很快又涨回来。明明堆内存还充足却总是发生 Full GC或者日志里出现 Concurrent Mode Failure。看到这些信号才说明垃圾回收陷入了“停滞”——不是单次暂停的物理事实而是停顿机制已经严重影响了服务的可用性。接下来的章节我会把诊断流程拆成具体动作每一步都带着工具和命令走一遍。2. 一套能落地的诊断流程从现象到根因2.1 诊断前的三件事日志、监控、快照排查 GC 停滞第一件事不是调参而是确认自己手里有没有足够的“现场资料”。我每次接到这类问题第一反应是看三样东西GC 日志、线程栈、堆转储。如果没有 GC 日志我会先把问题复现出来同时在服务上加好日志参数再继续查。一个典型的、适合线上使用的 JVM 参数组合是这样-Xms4g -Xmx4g -XX:UseG1GC -XX:MaxGCPauseMillis200 -Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tags -Xlog:safepoint:file/data/logs/safepoint.log:time,uptime,level,tags老版本 JDK 8 上很多人还在用-XX:PrintGCDetails -XX:PrintGCDateStamps -XX:PrintTenuringDistribution效果一样只是格式不如新版本清晰。重点是safepoint日志它能告诉我线程同步到安全点花了多久这个信息对定位“假停顿时长”非常关键。除了服务端参数开发环境下还有一个每个人都会遇到的坑IDEA 里跑的本地项目因为堆内存设置太小直接 OOM构建工具报expiring daemon because jvm heap space is exhausted。这类开发期问题其实也是 GC 停滞的一个前奏。IDEA 默认给 Spring Boot 项目分配的堆内存往往不够用本地频繁 Full GC 甚至直接崩溃开发一台机器上最容易观察到的症状反而是“IDEA 越来越卡”。我一般建议在 IDEA 的 VM options 里显式设置-Xms512m -Xmx2048m -XX:MaxMetaspaceSize512m构建工具比如 Gradle的 daemon 内存也要单独调避免频繁 GC 耗尽堆内存。别小看这一步很多线上说的“GC 问题”最早都是在本地 OOM 时候埋下的苗头。2.2 GC 日志怎么看先找异常数据拿到 GC 日志之后别急着从头到尾读。我自己的习惯是先搜索几个关键词Full GC、Concurrent Mode Failure、to-space exhausted、Humongous Allocation。这些关键词出现的位置就是事故的高发区。对于 G1 回收器日志里容易看到的是Pause Young (Normal)、Pause Young (Concurrent Start)、Pause Mixed、Pause Full以及后台的Concurrent Mark和Concurrent Cleanup。Young 和 Mixed 的停顿通常在几十到几百毫秒之间如果发现Pause Full频繁出现说明 G1 已经进入“降级模式”——普通的回收已经无法满足内存压力只能走 Full GC 的兜底路径。还有一种隐藏很深的场景G1 的 Humongous Allocation大对象分配。超过 Region 大小一半的对象会被直接放进老年代的连续 Region 里分配和回收都与普通对象不同。如果业务代码里经常创建大数组、大 List、大对象G1 的老年代会被“巨型对象”撑出很多空洞导致后续触发 Full GC。这类停顿的排查难点在于业务代码本身没有错它只是不知道自己在创造一堆“巨型分配”。看 GC 日志时要记录的核心指标包括Eden 区的使用量、Survivor 区是否溢出、老年代在回收前后的占用率、单次 Pause 的时间以及各个阶段的耗时分布通常是 Evacuate Collection Set、Java Heap 等子阶段。这些数据能帮你判断“时间花在了哪里”是扫描花时间、拷贝花时间还是等待安全点花时间。2.3 线程栈与堆转储抓“人赃并获”GC 日志只能告诉你“停顿发生了”但停顿期间的业务线程在干什么是日志看不到的。这时候需要线程栈和堆转储也就是专业排查里最常用的 jstack 和 jmap 命令。# 抓取线程栈连续抓 3 次间隔 3 秒 jstack 12345 thread_1.txt sleep 3 jstack 12345 thread_2.txt sleep 3 jstack 12345 thread_3.txt # 抓取堆转储 jmap -dump:live,formatb,file/data/heap.hprof 12345连续抓三次线程栈是为了对比线程状态是否在变化。如果有一个线程每次抓到的栈都停在同一处比如卡在Object.wait()、卡在某个 XML 解析的深循环里、卡在java.util.zip的解压操作上那这个线程很可能就是导致安全点同步超时的元凶。堆转储则用来分析对象分布。用 MAT 或者 VisualVM 打开 hprof 文件先看 Dominator Tree支配树看看哪些对象占据了最大的深堆Retained Heap。我遇到过很多次“老年代打满”的真实案例最后都指向同一个根因某处把不该缓存的对象放进了 static 集合缓存雪崩式增长GC 根本来不及回收。这个环节最考验经验因为对象名往往极具迷惑性比如你看到大量的byte[]以为是大数据包实际上可能是一堆巨大的字符串密钥或者图片流没有释放。2.4 JFR 和 Arthas线上诊断的“放大镜”如果线上服务不好直接抓堆转储毕竟 dump 大堆对在线服务有影响我会优先用 JDK 自带的 JFRJava Flight Recorder来做持续采样。JFR 可以记录 GC 阶段、线程分配、锁竞争、IO 等待等信息而且开销非常低配上jdk.jcmd的JFR.start命令就能临时开启。jcmd 12345 JFR.start duration60s filename/data/app.jfr sleep 65 jcmd 12345 JFR.stop打开 JFR 文件之后我会重点看两个视图GC 视图里的 Pause 时间线和线程视图里的 Safepoint 停顿。JFR 的数据非常完整可以精确到“哪个阶段多少毫秒”比靠肉眼读 GC 日志高效太多。另一类常用工具是 Arthas。它的dashboard命令能实时展示内存、GC 次数、线程状态thread命令可以定位到阻塞线程heapdump命令可以在不重启进程的情况下导出堆。我尤其喜欢 Arthas 的ttTimeTunnel功能它能把线上方法调用的入参和返回结果记录下来非常适合排查“某个接口一调用GC 就飙升”的怪现象。3. 现场扑救从异常日志到根因闭环3.1 案例一老年代持续上涨与 Concurrent Mode Failure有一年我做订单系统的线上支持服务每隔 20 分钟左右就会出现一次长达 5 秒以上的停顿。GC 日志里反复出现 CMS 回收器的Concurrent Mode Failure紧接着就是一次 Full GC停顿时间直接飙到 7 到 8 秒。当时堆内存配置是-Xmx4g表面上很宽裕。但细看 GC 日志发现老年代在 CMS 并发清理还没有完成的时候就已经被新晋升的对象占满。也就是说不是堆不够大而是回收速度跟不上对象晋升速度。订单高峰期大量短生命周期对象在创建时恰好被直接分配到了老年代或者 Survivor 区太小对象在 YGC 后无处可去只能提前晋升。这个案例的修复动作并不是盲目把-Xmx调到 8g而是做了三件事一是把新生代比例调大-XX:NewRatio1让短期对象尽量在新生代被回收二是开启 CMS 的显式老年代占用率触发参数-XX:UseCMSInitiatingOccupancyOnly -XX:CMSInitiatingOccupancyFraction70三是排查业务代码里是否存在大对象直接分配。调整之后Concurrent Mode Failure 消失停顿回落到 1 秒以内。这个案例给我最深的印象是很多人看到“Full GC”就以为堆内存不够实际上在 CMS 时代很多 Full GC 是“并发回收跟不上分配”造成的堆大小只是表面因素。3.2 案例二元空间扩容引发的“幽灵停顿”另一个案例更隐蔽。服务用的 JDK 8没有设置-XX:MaxMetaspaceSize某次发版后突然出现周期性的几十毫秒停顿频率很高。GC 日志里老年代和新生代都很正常看不出任何异常但你如果同时打开了Metaspace相关的日志会发现元空间在使用率超过高水位之后每次扩容都会触发一次Metadata GC Threshold。元空间扩容为什么会引发停顿因为扩容过程中 JVM 需要重新分配类元数据的内存这个操作同样在安全点暂停中完成。如果应用里有大量的动态代理、CGLIB 代理类、反射类生成元空间的使用量会快速上涨频繁触发 Metadata GC。这个问题的解法有两条线。如果类加载量确实大就调高元空间上限并预留足够空间比如-XX:MaxMetaspaceSize512m如果类加载存在泄漏比如重复创建代理类、不断用反射生成新类那就要从代码层面处理。我还是第一次遇到这种问题时把注意力全放在堆上查了很久才发现病因在堆外后来每次都提醒自己GC 停滞不只在堆上发生元空间和堆外内存也可能成为瓶颈。3.3 案例三G1 的巨型对象与碎片化使用 G1 的场景里我最常遇到的 GC 停滞源头之一是大对象分配。G1 的每个 Region 默认大小可以通过-XX:G1HeapRegionSize设置通常为 1MB 到 32MB 之间。当一个对象大于 Region 的一半时会直接分配到连续的多个 Region 中这就是 Humongous Allocation。有一个数据报表服务每次批量计算时都会一次性构建一个三维数组大小在 20MB 左右。G1 为它分配连续 Region计算完成之后这些 Region 回收缓慢由于巨型对象回收时还会触发Humongous Allocation日志老年代被撑出碎片。碎片多了正常的对象晋升找不到连续空间就会引发to-space exhausted最终演变成 Full GC 级别的停顿。这个案例的解决方案分两步第一步是调整 G1 的参数比如适当调大 Region 大小、设置-XX:InitiatingHeapOccupancyPercent45让并发标记更早启动第二步是改代码把超大的数组拆成可分批处理的结构避免一次性创建巨型对象。很多时候代码上的一行改动比调十个 JVM 参数更有效。3.4 案例四安全点等待时间远超 GC 本身最后这个案例最让我觉得值得写出来。某个网关服务的 GC 日志显示每次 Young GC 只有 30 毫秒但监控系统记录的接口 RT响应时间却出现大量 2 到 3 秒的超时毛刺。一开始所有人都在查 GC因为请求超时的时间点恰好和 GC 日志的时间点重合。后来我打开安全点日志才发现GC 本身的 Pause 只有 30 毫秒但 JVM 在等待所有业务线程进入安全点这件事上花了 2.5 秒。再抓线程栈发现有一个线程长时间执行 SNI 回调里的一段加密逻辑JCE 的 Cipher 操作代码循环里没有安全点导致 JVM 发起了全局暂停请求却等不到这个线程停下来。这类问题的排查思路已经超出了“GC 调优”本身。它考验的是你对 JVM 安全点机制的理解。如果当时我只盯着 GC 日志可能永远找不到真相。后来我们在该线程执行热循环的代码路径中加入可被中断的检测点、减少单次加密的批处理量安全点等待时间立刻降了下来。这里也要强调不要轻易用-XX:-UseBiasedLocking或关闭偏向锁来规避问题那只是掩盖症状真正的病根始终在线程的行为方式上。4. 调优与止损参数策略和验证闭环4.1 从停顿现象反推要调哪个参数很多人拿到 GC 日志后第一反应是跑去网上搜“jvm 调优参数表”然后抄一堆参数改上去。这种做法非常危险因为参数之间是联动的没有搞清楚停顿发生在哪个阶段就乱调很容易让情况变得更糟。我的做法是先给停停顿分类再决定参数方向停顿现象优先检查的方向常用参数动作Young GC 频繁且耗时高新生代是否太小、Survivor 是否溢出调大-Xmn或-XX:NewRatio检查-XX:SurvivorRatioFull GC 频繁老年代长期高占用对象晋升速度、缓存/大对象调整-XX:CMSInitiatingOccupancyFraction或-XX:InitiatingHeapOccupancyPercentMetaspace 频繁扩容动态代理类、反射、框架类加载设置-XX:MaxMetaspaceSize并排查类加载来源服务停顿但 GC 时间很短安全点同步问题、JIT、锁竞争打开-XX:PrintSafepointStatistics排查安全点等待大对象分配导致 G1 碎片化Humongous Allocation调大-XX:G1HeapRegionSize或改代码避免大对象这里要特别记住一个原则任何参数调整都应该一次只改一个变量并且观察至少一个完整的高峰周期。如果一次性改五个参数出了问题你根本不知道是谁的锅。4.2 回收器选型从 CMS 到 G1 再到 ZGC现在的 JDK 版本里CMS 已经逐渐退出主流G1 成为 JDK 9 之后的默认回收器而 ZGC 和 Shenandoah 则是低停顿方向的新选择。但这并不意味着所有人都应该立刻切到 ZGC。ZGC 适合超大堆、对停顿极其敏感的场景比如实时交易、低延迟中间件它用染色指针和读屏障把绝大部分回收工作放到了并发阶段STW 能压缩到毫秒级。但它的代价是更高的 CPU 开销如果你的 CPU 资源本身紧张强行上 ZGC 反而可能把 CPU 打满。G1 是当前综合体验最好的默认选择它把堆划分成 Region回收时只回收垃圾最多的 Region 集合兼顾吞吐量和可预测的停顿。实际调优中-XX:MaxGCPauseMillis不能设置得太离谱比如把目标停顿设为 10msG1 为了满足目标会频繁做并发标记和混合回收反而增加 CPU 消耗、降低吞吐。如果你还在用 JDK 8 且堆大小在 4G 到 8G 之间CMS 在低并发场景下表现并不差。但新项目最好直接上 G1别在旧回收器上耗费维护精力。选型逻辑本质上是在回答你的业务是更看重吞吐还是更看重延迟。批处理系统可以容忍秒级停顿交易链路就不能容忍。4.3 调优后的验证拿数据说话调参之后不能只看服务不宕机就认为解决了。我会用一套固定的验证流程去确认停顿是否真的被消除。第一步重放压力测试。用 JMeter、wrk 或者内部压测平台模拟之前的故障流量观察 GC 日志里的 Pause 时间线是否恢复正常。第二步对比监控指标。看老年代占用率曲线是不是变得平稳Full GC 次数是不是降下来了单次 STW 的 P99 值是多少。第三步如果条件允许开启 JFR 跑 30 分钟确认安全点等待和 GC 子阶段耗时没有异常波动。一个反直觉的验证技巧是如果调优之后 Full GC 次数确实降下来了但 CPU 使用率变高了不代表调优失败这很可能是回收器把原子的 Full GC 动作拆成了更多的并发标记和并发清理动作CPU 开销换取了更低的停顿。这时候要结合业务对延迟的要求来做权衡而不是一味追求“GC 次数越少越好”。4.4 兜底方案止损比调优更重要线上故障扑救时我必须提醒一句永远别在故障现场花两个小时调优。如果服务已经处于不断 Full GC 的恶性循环中第一步应该是快速止损。止损手段包括把故障节点的流量摘掉、对服务进行滚动重启、临时增大堆内存或者切换备机先把可用性拉回来再慢慢分析根因。我见过不少团队在故障时反复抓线程栈、开 JFR、现场改参数结果服务在几个小时内频繁挂掉用户投诉越来越多。正确的做法是“先止血再体检”。抓完必要的现场数据GC 日志、线程栈、堆转储之后立刻恢复服务后续离线分析这些数据。所有诊断工具的使命都是帮你最快找到根因而不是在事故现场太“恋战”。5. 常见问题速查与我的排坑记录5.1 一张速查表解决 80% 的停滞问题为了方便大家以后自查我把这些年遇到的问题整理成了下面这张速查表。每次遇到 GC 相关告警照着顺序走一遍能覆盖大部分场景现象优先怀疑对象第一步操作进阶排查接口偶发超时GC 日志显示 STW 很短安全点同步问题打开 safepoint 日志jstack 找安全点外长时间运行的线程Full GC 频繁老年代回收效果差内存泄漏/缓存膨胀抓堆转储用 MAT 看支配树检查静态集合、缓存框架的过期策略GC 停顿上百毫秒Eden 区分配压力大对象分配速率过高jstat -gcutil 观察增长率结合 JFR 的 Allocation Profiling 找分配热点G1 日志出现 Humongous Allocation大对象占连续 Region调整 G1HeapRegionSize代码层拆解大对象减少直接分配Metaspace 频繁触发 GC类加载器泄漏/代理类过多查看类加载统计检查动态代理类的创建位置本地 IDEA 跑项目直接 OOMIDE 默认堆内存太小调整 VM options检查构建 daemon 的 JVM 堆设置这张表不是万能药但它能帮你在面对“GC 停顿”时有一个清晰的排查起点。至少不会第一时间就去改参数而是先定位问题的真正所在。5.2 踩过坑之后总结的几条铁律第一GC 日志和监控是排查的基石没有日志就没有诊断。很多线上服务没有开启 GC 日志出故障时除了一个 CPU 图什么都拿不到。凡是上生产的 JVM 服务我强烈建议至少保留-Xlog:gc*输出并且配好日志滚动策略。第二堆转储文件是“事故现场的照片”但不是所有照片都有用。如果服务已经恢复内存里的大部分对象其实都是当前状态下的正常对象反而掩盖了故障当时的异常。所以最好在故障高峰期抓取堆转储并且记得抓live模式避免太多垃圾对象干扰分析。第三不要盲目抄网上配置。很多所谓的“最佳实践”只是在特定版本、特定场景下有效。比如-XX:DisableExplicitGC这个参数虽然能避免业务代码里手动调System.gc()触发 Full GC但如果你的程序依赖 JMX 或者某些框架调用System.gc()来做内存管理禁止显式 GC 可能引发更糟糕的后果。第四安全点问题普遍被低估。我遇到过的 JVM 停滞至少有三分之一是安全点等待导致的。打开安全点日志这件事所有做 Java 后端的人都应该重视。JDK 8 上可以用-XX:PrintSafepointStatistics -XX:PrintSafepointStatisticsCount1新版本则用-Xlog:safepoint。别嫌日志多关键时候它能救命。5.3 一个开发期容易被忽视的 OOM 场景最后补充一个开发期场景因为这个几乎所有人都遇到过本地跑项目时控制台直接报java.lang.OutOfMemoryError: Java heap space或者像开头提到的expiring daemon because jvm heap space is exhausted。这通常不是代码有问题而是本地 JVM 的堆设置太小。IDEA 的默认堆内存配置对大型项目来说往往不够多个微服务模块同时启动时一下子就撞上了内存上限。处理方式很简单在 IDEA 里打开Help - Edit Custom VM Options把-Xmx调大同时在 Gradle 的gradle.properties里设置org.gradle.jvmargs-Xmx2048m -XX:MaxMetaspaceSize512m避免构建 daemon 因堆耗尽而退出。别小看这一步如果开发环境自己都频繁 OOM你对垃圾回收的感知反而会被这种“假问题”带偏——明明线上一切正常你却还以为调 GC 参数能解决本地启动失败的问题。先让开发环境的内存配置合理再去谈线上 GC 调优顺序不能反。就我个人的实际体会而言排查 JVM 垃圾回收停滞这件事60% 靠方法论40% 靠对工具和日志的熟悉程度。方法论能帮你把问题从“玄学”变成“科学”而工具熟练度决定了你追根因的速度。很多同学问我有没有“一招搞定 GC 停顿”的秘籍我的回答始终是没有。停顿问题千变万化唯一可靠的路径就是先收集现场数据、再定位阶段、最后从参数和代码两个层面修根因。最后再分享一个小技巧排查前先把 GC 日志保留周期拉长到至少 7 天很多偶发性停顿不会在故障当时就暴露而是过两三天才在日志里露出马脚。数据留得够久诊断才能做得够深。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门