首页
/
行业洞察
/
正文
INDUSTRY INSIGHT · 深度
JVM性能故障排查实战:CPU飙升、内存泄漏与GC频繁的根因定位
📅 2026/10/5 7:58:22
✍️ 爱科研究院
👁 阅读 3,247
1. 排查前先理清这三个症状其实是三角关系1.1 别把三个症状当成三个独立问题线上告警的时候经常是 CPU 冲高、内存曲线飙升、GC 日志刷屏一起涌过来。很多人第一反应是分开查CPU 高就看线程内存涨就 dump 堆GC 频繁就调参数。我踩过这种弯路折腾半天发现全是同一个根因。这三个指标在 JVM 里是强耦合的。举一个最常见的情形代码里有内存泄漏老年代在不停积累那些被引用链拴住、永远无法回收的对象堆可用空间越来越少JVM 为了腾出空间只能频繁触发 GC。一旦触发 Full GCStop The World 会让所有业务线程冻结这段时间 CPU 表面上被 GC 线程吃满接口超时、RT 飙升、负载暴涨全来了。你拿着 top 一看CPU 确实高但如果直接在用户线程里找凶手大概率一无所获因为真正在烧 CPU 的是 GC 线程。反过来也一样。如果业务代码本身有死循环或者用了极低效的算法CPU 被打满请求处理不过来线程池任务积压对象在内存里不断堆积然后 GC 又开始频繁。还有第三种情况分配速率过高比如每秒创建大量小对象年轻代瞬间被塞满Young GC 疯狂触发CPU 也跟着烧。所以排查的第一步不是动手敲命令而是先把三者之间的关系理顺。我们要回答的核心问题是谁是因谁是果是内存泄漏引发了 GC 风暴还是 GC 风暴吃光了 CPU还是 CPU 瓶颈拖垮了吞吐、导致内存堆积判断的顺序不同后面走的排查路径完全不同。1.2 动手前先抢救现场信息收集有优先级问题已经在线上发生了第一件事永远是保住现场。有些同学习惯先自己复现或者在测试环境折腾半天等回来线上已经自己恢复了什么都看不到。我个人的习惯是一旦接到告警先把手头这几类信息拉下来再做任何分析顺序也很有讲究。问题时间段精确到分钟对应发布记录、流量峰值、定时任务时间点。监控曲线CPU、堆内存、GC 次数与耗时来自 Prometheus、Grafana 或公司自有监控平台。线程栈快照连续采集两到三次间隔 5 到 10 秒防止偶发现象。堆转储如果内存已经处在高位尽快 dump晚了可能 OOM也可能现场被后续流量冲掉。GC 日志确认启动参数是否已经打开了 GC 日志没开的话至少用 jstat 补数据。我用过一个挺土但很有效的办法把常用的排查命令写成采集脚本放到服务器上出问题直接一键执行把所有现场信息打包到一个带时间戳的目录。别小看这件事真到告警的时候人都是慌的边翻文档边敲命令会浪费最宝贵的几分钟。脚本里包括 top、jstack、jmap、jstat、GC 日志路径、系统日志全部记录下来。注意jmap 和 jstack 在目标 JVM 上执行属于侵入性操作特别是jmap -dump:live会触发一次 Full GC在核心业务高峰期要格外谨慎。要不要执行、什么时候执行建议提前在预案里写清楚而不是现场拍脑袋。2. CPU 高的定位思路从进程一路抓到代码行2.1 先用 top 锁定进程和线程定位 CPU 高我的固定起手式是 top。别笑很多人查 CPU 问题第一步就跳去翻监控大盘反而忽略了这个最直接的命令。top 进入交互界面后按一下 P进程按 CPU 使用率排序先确认是不是我们的 Java 进程。确认之后关键操作来了执行top -Hp pid这一步是看进程内部的线程按 CPU 排序。你会看到一堆带 PID 的线程这个十进制数字就是线程在 JVM 里的原生线程 ID。接下来做一步很多人会忘的转换printf %x\n 十进制线程ID转成十六进制。为什么要转十六进制因为 jstack 输出的线程栈头部是这个格式http-nio-8080-exec-10 #84 daemon prio5 os_prio0 cpu1234.56ms elapsed234.56s tid0x00007f8c080fa800 nid0x2a35 runnable。这里的 nid 是十六进制你拿着十进制的线程号去搜什么都搜不到。用转换后的十六进制 nid 去 grep 线程栈文件就能直接看到这个吃 CPU 的线程到底在干什么。这一步是整个排查的桥一半以上的人卡在这里不是不会敲命令是不记得进制转换这回事。2.2 jstack 分析线程栈一次不够至少两次拿到线程栈之后怎么看很多人把 jstack 的输出贴到编辑器里从头翻到尾越看越乱。我建议先定位 CPU 最高的那个 nid然后沿着它的堆栈逐帧往下看。栈顶是当前正在执行的方法如果线程状态是 RUNNABLE而且栈顶上恰是我们业务代码里的某个方法比如某个 while 循环、某个正则匹配、某个 XML 解析嫌疑就非常大了。这里有个容易犯的错误单次线程栈有很强的偶然性。线程可能在某一瞬间恰好停在一个无关紧要的方法上因为 JVM 的线程栈采样是瞬时的所以坚持至少间隔几秒采两三次对比同一个线程的栈变化。如果每次它都停在同一段代码基本可以实锤。如果几次采样之间栈在频繁跳动可能是线程在做大量短小的操作这时候火焰图比线程栈更合适。还有一种情况是栈里看不到业务代码全是 GC 相关线程比如 vm thread、GC task thread。这时候 CPU 高很可能是 GC 本身在烧要立刻转去看 GC 日志和内存而不是继续纠结业务线程。这个判断能帮你少走一个小时弯路我在后面的案例里会再提一次。2.3 火焰图把 CPU 采样变成可视化证据线程栈适合找单个线程的问题但如果是整个进程 CPU 都比较高负载分散在很多线程里一个个看栈就很低效。这时候我推荐用火焰图具体工具用 async-profiler它基于硬件性能计数器采样开销极小在 Linux 上可以直接生成火焰图。基本用法一行命令./async-profiler -d 60 -o flamegraph.html pid采样 60 秒后生成一个 HTML 文件。用浏览器打开宽度代表采样占比高度代表调用栈深度从下往上看是完整调用链。哪个函数在最宽的位置一眼就能锁定。火焰图对新手最大的价值是把CPU 时间到底花在哪个函数变成视觉问题。我用它的实际经验是很多看起来玄乎的 CPU 问题最后都会在火焰图上找到直观答案比如某个三方库在热点路径上反复做反射、某个框架的拦截器在底层做大量字符串拷贝、又或者某个加密算法被意外地调了几百万次。这类问题靠人肉看线程栈翻到眼瞎也未必能找出来。2.4 CPU 高不一定都是业务代码最后提醒一类容易被忽视的情况CPU 高的根源不在业务代码而在 JVM 自身或外部环境。比如JIT 编译峰值应用刚启动或大量类首次加载时JIT 编译器会消耗较多 CPU通常几分钟后自然回落。堆外内存和本地内存压力DirectByteBuffer 使用过多、NIO 的大量分配回收会造成额外的 CPU 和内存开销GC 日志里往往看不出来。系统资源争抢宿主机上其他容器在抢 CPU或者发生了频繁的 swap 换页JVM 线程状态异常CPU 使用率虚高。排查这类问题时除了看 JVM 内部还要看系统层指标vmstat、iostat、free -m确认核数是否足够、cgroup 限制是否合理、机器本身的负载是否被邻居拖垮。别一条路走到黑CPU 高是一个症状不是病根揪出病根永远比治标重要。3. 内存泄漏排查别急着 dump先看趋势3.1 先判断是泄漏还是分配压力大内存问题最怕一上来就 jmap dump。dump 一个几 GB 的堆要花几分钟期间 JVM 卡顿还占大量磁盘空间而且 dump 出来的是一张静态照片单凭一张照片很难判断内存到底是泄漏还是在正常波动。我更推荐先看动态趋势。用jstat -gcutil pid 1000每秒打印一次 GC 和内存情况。重点盯这几列YGC/FGC 是年轻代和老年代 GC 次数FGCT 是 Full GC 累计耗时这个数字如果涨得很快说明频繁 Full GCO 是老年代使用率这是全场最关键的一列。连续观察几分钟如果老年代使用率在 Full GC 之后依然持续上升每次回收后的水位都比上一次更高那基本可以判断存在对象泄漏或者对象在持续进入老年代。另一种形态是老年代每次 GC 后都能明显回落到低位但很快又涨上来这更像是瞬时分配压力常见于大批量数据处理或高峰流量下的对象洪峰。判断清楚是哪种形态再决定下一步。泄漏就走对象存活分析分配压力大就去查代码里有没有大量创建对象、有没有大对象直接进入老年代或者考虑调整年轻代大小和晋升阈值。顺序反了要么白 dump 一次要么把代码改个遍也解决不了问题。3.2 堆转储的姿势和使用注意确认要 dump 之后命令是jmap -dump:formatb,file/path/heap.hprof pid。注意我不建议加 live因为加了 live 会先触发一次 Full GC把不可达对象清掉再 dump你拿到的是过滤后的快照反而丢失了现场。但生产环境里 Full GC 影响很大非高峰期操作是最基本的底线。另外大堆 dump 文件非常庞大几十 GB 的堆可能产生同等体量的文件传输下载都是麻烦事。建议先压缩再走专门的带宽拉回本地分析。还有一个压箱底的保险配置启动参数里加上-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs一旦 OOMJVM 会自动把堆快照写到指定目录这是事后回溯最有力的证据很多团队直到线上挂了几次才想起补这一行。Arthas 也是个好选择。它的heapdump命令可以在线执行对线上的打扰比 jmap 更小而且 Arthas 的vmtool --action getInstances --className xxx可以直接查看某个类的实例数量这在快速判断某个类是否泄漏时非常实用省得每次都要下载几个 GB 的 hprof。3.3 MAT 分析从直方图到支配树堆转储拿到以后我一般用 Eclipse MAT。打开 hprof 文件先看 Leak Suspects 报告MAT 会自动圈出几个可能的泄漏点虽然它的自动判断不一定都准但能提供很好的切入点。接着看 Histogram按对象数量或 Retained Heap 排序。Retained Heap 是如果删掉这个对象能被释放的内存大小这是一个极有判别力的指标它指向那些持有大量内存的对象。然后对可疑对象右键选择 Path to GC Roots看它的引用链。内存泄漏的本质就是对象明明不需要了但 GC Roots 还能通过某条引用链找到它所以永远不会被回收。沿着引用链走到底最后往往会停在一个静态字段、一个 ThreadLocal、或者一个被长期存活的容器持有的字段上。Dominator Tree支配树也要会用。它表示对象之间的支配关系能帮你快速找到谁在背后撑起了这一大坨内存。比如你看到一个 2GB 的 byte[]但真正的问题可能是持有这个 byte[] 的缓存对象没有清理策略。只看直方图容易被表象骗配合支配树才能找到幕后黑手。3.4 常见泄漏代码模式这些坑值得逐条自查下面这些模式我基本都在生产环境见过每一条都对应一次真实事故排查时建议按清单过一遍ThreadLocal 不清理线程池里的线程是复用的往 ThreadLocal 里放了对象又从不 remove这个线程私有的引用会一直存在对象永远无法回收。这是最常见的隐蔽泄漏尤其在用 ThreadLocal 做上下文传递、链路追踪的项目里。静态集合当缓存static Map 或 List 里不断 put 数据没有过期策略、没有容量上限数据只要塞进去就永远在。资源流不关闭Java 7 之后有 try-with-resources但大量老代码在 finally 里漏了 close或者根本没有 finally数据库连接、文件流、HTTP 连接全是重灾区。监听器和回调未反注册注册了观察者、消息监听器生命周期结束时没有移除宿主被监听器强引用。自定义类加载器热部署、动态加载 jar 时类加载器以及它加载的所有类都成为垃圾但如果有外部强引用会连累整批类无法卸载。大对象直接进老年代一次性查询取出全部数据、生成超大 byte[]超过阈值直接进入老年代且长期存在表现为老年代持续增长。自查的时候我的建议是不要猜先用 MAT 定到具体类再回代码里找对应位置。靠经验猜容易漏还会把团队时间浪费在错误的模块上定位到类之后再去对照代码清单效率会高得多。4. GC 频繁的定位与参数调整4.1 先把 GC 日志打开格式正确地读GC 问题没有日志等于盲人摸象。JDK 8 用-XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/data/logs/gc.logJDK 11 及以上统一用-Xlog:gc*:file/data/logs/gc.log。线上务必提前开启这是零成本的保险错过了故障窗口再想开就晚了。GC 日志怎么读拿一段简化后的 G1 日志举例[GC pause (G1 Evacuation Pause) (young), 0.0123456 secs] [Eden: 1024.0M(1024.0M)-0.0B(1024.0M) Survivors: 16.0M-16.0M Heap: 1.2G(4.0G)-238.5M(4.0G)]这段表示一次年轻代回收暂停了约 12 毫秒Eden 从满变空堆总使用从 1.2GB 降到 238MB。年轻代 GC 频繁核心指标是每次回收后堆水位有没有持续抬高。如果每次 Young GC 后堆水位都高于上一次说明有对象在不断晋升到老年代如果老年代 GC 后水位也降不下来那大概率是泄漏。Full GC 的日志更值得警惕因为 G1 的 Full GC 是串行的暂停时间可能长达数秒甚至十几秒日志里出现类似Full GC (Allocation Failure)的记录说明老年代空间不足分配对象失败被迫 Full GC这是服务即将 OOM 的强烈信号。4.2 区分分配过快和内存泄漏一个速判法GC 频繁有两种常见成因处理方式完全不同。我总结几个速判维度看每次 GC 后内存水位泄漏的话下降后还会持续上涨且下一次比上一次高分配过快的话每次都能回到相近的低位基线。看年轻代 GC 频率如果每秒触发多次 Young GC 且 Eden 很快就满通常是分配速率过高。看晋升对象大小jstat 里观察 Survivor 和 Old 的变化老年代涨得特别猛可能是大对象直接晋升或晋升阈值太小。看 Full GC 后 FGCT 是否继续增长Full GC 越来越频繁、回收效果越来越差堆内存正在被持续吃紧。还有一个非常容易被忽略的问题元空间Metaspace。大量动态生成类反射、CGLib、热部署可能导致 Metaspace 持续膨胀最终触发 Full GC。这种情况看 Eden/Old 都没用要看 Metaspace 的使用曲线很多人排查老半天堆内存结果问题根本不在堆里。4.3 参数调整的实操顺序先开药方再手术调 GC 参数有一条铁律先解决泄漏和分配问题再碰参数。很多团队在内存泄漏还没定位的情况下就忙着把堆调大、换收集器最后只是把爆发时间点往后推了几天该挂还是挂。正确顺序我建议这样走设置-Xms和-Xmx相同避免堆动态伸缩带来的额外开销和抖动。具体值根据机器内存、容器限制来定记得给系统和其他进程留余量。确定收集器。JDK 8 默认 Parallel追求低停顿可以换 CMS但 CMS 在 JDK 14 已被移除JDK 11 及以上强烈建议 G1堆非常大且追求极低停顿可以评估 ZGC。针对 G1先调-XX:MaxGCPauseMillis默认 200ms可以试着设成 100ms 或 50ms但别设得太激进否则 G1 会增加很多后台任务反而抬高 CPU。每次只改一个参数。改完至少观察几小时甚至一两个完整业务周期GC 行为受流量峰值影响很大只看十分钟数据就下结论很容易误判。保存调参前后的 GC 日志和 jstat 数据作为对比基线。我个人的体会是GC 调参更像是在做权衡不存在一套让所有人都满意的配置。JVM 默认参数在大多数场景是合理的真正的性能问题通常出在代码而不是 JVM调参只能兜底解决不了根因。5. 跟着真实案例走完整条排查链路5.1 案例一CPU 100% 背后的 Full GC 风暴有一回线上告警某核心服务的 CPU 直接打满接口大面积超时。我上去先执行 top确认 Java 进程占着近乎全部 CPU然后top -Hp看线程发现排在前面的是好几个名字带 GC 的线程比如 GC Thread。这一步基本就把方向定下来了不是业务线程在死循环是垃圾回收在烧 CPU。接着看jstat -gcutilFGC 数字快速上涨FGCT 同步增长老年代使用率接近 90%。GC 日志里连续出现 Allocation Failure 的 Full GC每次耗时 5 到 10 秒。顺着这条线做堆转储最终在 MAT 里定位到某个日志组件缓存了大量字符串数组每个数组几十兆。根因是业务代码在循环里用字符串拼接构造超长日志日志框架对这批对象的引用没有及时释放。修复方案分两步先把日志级别降下来阻断新对象持续产生再在代码里把构造日志的路径优化掉。改完 Full GC 消失CPU 恢复正常。复盘时最有价值的经验是遇到 CPU 高先确认热点线程到底是什么角色。发现是 GC 线程就立即转向内存链路别跟业务代码死磕这个方向错误我们踩过一次后来养成了先 jstack 两次确认角色的习惯。5.2 案例二悄无声息的 ThreadLocal 泄漏另一个案例更有意思。一个服务运行一周内存曲线每周缓慢上涨涨幅不大业务也没明显报错只是每隔几天需要重启一次才能稳住。这种问题最磨人因为它不紧急但一直在放血而且重启后一切正常给排查造成很大干扰。我的做法是每周固定时间执行一次jmap -histo:live把前 50 个类名和实例数拍下来对比两周的数据。两轮对比后发现某个自定义 Context 对象实例数每周翻一倍内存占用持续排在前面。用堆转储加 MAT 分析它的 GC Roots 引用链最终指向了一个线程的 ThreadLocalMap。根因是项目里用 ThreadLocal 保存用户上下文但业务代码没有统一清理异步线程池里的线程被反复复用ThreadLocal 值越积越多。修复方案是在出口处统一 remove用一个拦截器兜底清理同时在 finally 里显式调用。这个案例让我对 ThreadLocal 多了十二分警惕现在看到代码里出现 ThreadLocal第一反应就是问谁来清理它5.3 排查思路速查表我整理了一个排查顺序表适合贴在工位上随时对照症状首查命令关注指标常见根因CPU 高top、top -Hp、jstack线程角色、nid、栈顶方法死循环、GC 风暴、正则/序列化热点老年代持续增长jstat -gcutil、jmap -histoO 列、FGC 次数泄漏、大对象晋升、缓存无清理Young GC 过频jstat、GC 日志Eden 回收频率分配速率高、小对象过多Full GC 过频GC 日志、jstat FGCTFull GC 次数与耗时老年代满、泄漏、Metaspace 膨胀内存曲线缓慢上涨连续 jmap -histo 对比特定类实例数变化ThreadLocal、静态集合、资源未关闭接口超时伴随 CPU 高top、jstack、GC 日志用户线程与 GC 线程占比高 CPU 导致积压或 GC 停顿拖垮响应这个表不是标准答案每个团队的环境和代码风格都不一样但它能帮你把下一步该看什么这个决策变得更快。性能问题排查速度就是一切现场不等人。6. 写在最后的一点心得最后只讲两件我在实际工作中反复验证过的事。第一件事排查工具的准备永远赶在故障之前。我强烈建议把 top、jstack、jmap、jstat、GC 日志采集写成一个脚本和监控告警通道放在一起。故障发生后的十分钟里人需要的不是回忆命令而是采集现场。等一切恢复正常再去复盘你会感谢当初那个多写了三五行脚本的自己。第二件事CPU、内存、GC 三个指标不要分开看。无论先从哪个指标入手最后基本都会回到两个根本问题引用的对象为什么不能被回收CPU 时间到底花在哪个调用栈上只要能把这两个问题回答清楚性能问题的根因就差不多浮出水面了。如果你按这套思路排查过线上的 CPU 和内存问题大概会和我有同样的感受大部分问题算不上高深卡住的往往不是技术本身而是没有一套清晰的排查顺序。希望这篇文章能让你下次面对脏乱的告警时更快地冷静下来一步一步走到根因面前。
📌 标签:
工业官网
设计趋势
AI 建站
SEO
获取完整报告 →
RELATED ARTICLES
推荐阅读
2026/10/5 7:58:22
插件加载失败?搞懂激活机制与排查思路,避开did not activate坑
2026/10/5 7:58:22
手写Vue 3.4响应式系统:深入ref与computed核心实现
2026/10/5 7:58:22
拆解OpenShell迷局:构建跨平台Zsh工作流实战指南
2026/10/5 8:33:24
Java实现动态规划文献查重:LCS与编辑距离算法全解析
2026/10/5 8:33:24
AI应用架构设计实战:从LLM、Agent到MCP的工程化落地指南
2026/10/5 8:33:24
梁挠度计算全攻略:从平截面假定到工程验算避坑指南
2026/10/5 8:33:24
SAP PS预算占用与采购订单金额对不上的排查指南
2026/10/5 8:33:24
大模型推理可观测性实战:Token级监控与延迟优化
2026/10/5 8:28:24
Hot100贪心算法专题:五类高频模型与边界条件详解
2026/10/5 0:02:57
AZ-104题库深度拆解:从刷题到掌握Azure管理员核心考点
2026/10/5 0:02:57
WorkBuddy:基于MCP协议的组织级工作流神经中枢
2026/10/5 0:02:57
大模型 / AI 应用常见面试题及答案汇总(2026 最新版):用 TaoToken 统一 Key 跑通高频考点代码验证
2026/10/5 4:43:56
Jev+Agent接管浏览器:browser-use实战与jev-ultrafast性能优化
2026/10/5 1:10:25
多智能体集群实战:DeepAgents编排、MCP与A2A协议及Skills体系
2026/10/4 0:00:57
hindsight:面向LLM应用的事后可观测性工程实践
2026/10/4 2:41:08
我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
2026/10/4 17:59:15
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026/10/3 15:20:14
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)