JVM堆栈分析实战:从CPU飙升到内存泄漏的排查指南
做Java开发的人迟早会撞上JVM问题。就说我前阵子碰到的一个线上故障接口平均响应时间从80ms飙到3秒CPU使用率持续打满业务方催得紧代码层面翻了几轮也看不出名堂。最后就是靠JVM堆栈分析工具和方法一步步揪出了真凶——一个第三方SDK里隐藏的无限循环。这个案例让我再次确信堆栈分析是每个Java开发必须掌握的保命技能。这篇文章把我这些年用下来的工具、排查思路和踩过的坑整理出来适合刚接触JVM调优的新手也适合想系统梳理排查套路的同行。1. 什么时候需要搬出JVM堆栈分析1.1 三种典型故障场景先说清楚一件事堆栈分析不是没事找事做的它有非常明确的应用场景。我总结了三个最常见的触发时机遇到这些情况你基本不用犹豫直接上工具。第一种是CPU飙升。进程的CPU占用率莫名其妙跑到90%以上甚至打满。这时候多半是有线程在疯狂做事要么是死循环要么是频繁的Full GC要么是有线程在做大量的计算或序列化。CPU飙升的可怕之处在于它会让整个服务的响应速度都拖慢因为CPU时间片被消耗殆尽正常的业务线程根本抢不到执行机会。我记得有一次排查线程栈里反复出现同一个方法调用序列再一看是一段循环条件写错的代码当时我就跟同事说这就是典型的忙等。第二种是线程卡死/无响应。表现是请求堆积、接口超时、进程还活着但业务完全不动。这时候需要看线程是不是进入了BLOCKED状态或者长时间处于WAITING状态等待某个锁。线上最常见的是数据库连接池被耗尽或者分布式锁没释放导致线程全部堵在一个获取锁的地方。这类问题用jstack一拍线程栈哪里卡住了一目了然。第三种是内存持续上涨/频繁Full GC。这个稍微隐蔽一些因为内存问题的定位通常需要配合堆转储Heap Dump来做。不过如果发现GC日志里Full GC的频率异常高或者老年代空间一直在涨先把堆栈抓下来结合看能帮助判断是哪个线程在制造垃圾对象、哪些对象占着内存不释放。1.2 堆栈文件里到底藏着什么很多人第一次看线程栈Thread Dump会觉得密密麻麻全是英文不知道从哪看起。其实你只需要抓住几个关键信息。每个线程栈都包含线程名称、线程ID、线程状态RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、调用栈帧也就是执行到哪个类的哪个方法。其中线程状态是最直观的线索RUNNABLE状态要看它在执行什么代码BLOCKED和WAITING状态要往上找找到那个锁住不让别人进的持有者。我习惯把线程栈理解成一张事故现场航拍图。它拍的是某一瞬间所有线程各自在做的事情就像一栋办公楼里每个工位上的人谁在埋头写代码谁在门口排队等会议室谁干脆趴桌上睡觉了。这张图拍下来你就能大致判断出系统处于什么状态。不过这里有个特别重要的点线程栈是瞬时快照只能代表抓取那一刻的情况。如果线程在快速切换状态或者问题只间歇性出现一次快照可能什么都拍不到需要多次抓取对比甚至用固定的时间间隔连续截图才能看出规律。这也是为什么很多人在第一次使用jstack时什么异常都没发现就开始怀疑工具没用——其实是姿势不对。2. 工具选型从命令行到可视化2.1 jstack线程栈的X光机JDK自带的jstack是我用得最多的命令没有之一。它的基本用法简单到令人发指# 先找到Java进程的PID jps -l # 生成线程栈快照输出到文件 jstack PID thread_dump_1.txt # 支持多次抓取间隔几秒一次方便对比 jstack PID thread_dump_2.txt如果是Linux环境我通常会配合top命令先确认哪个进程CPU高然后用top -Hp 拿到具体是哪个线程ID占用了CPU再把线程ID转成十六进制去线程栈文件里找对应线程。这个组合拳我后面会详细演示这里先记住一个核心思路CPU高是要找执行中的线程线程卡死是要找等待中的线程两者排查方向完全不同。jstack还有一个容易被忽略的选项是-l它会输出带有锁信息的线程栈显示当前线程持有哪些锁、在等待哪些锁。排查死锁问题的时候jstack -l输出末尾如果有Found one Java-level deadlock那就基本上实锤了。2.2 jstat与jmap堆内存的两面镜子线程栈解决的是线程在干什么但内存问题光看线程栈不够还得看堆内存的运营情况。我常用的两个命令是jstat和jmap。jstat用来观察JVM的类加载、GC行为、堆内存分区占用它输出的是一串串快照数据适合监测趋势。最基本的一条命令# 每隔1000毫秒输出一次GC信息共输出20次 jstat -gcutil PID 1000 20这个输出里S0、S1是幸存者区使用率E是新生代Eden区O是老年代M是元空间YGC是新生代GC次数YGCT是新生代GC总耗时FGC是Full GC次数FGCT是Full GC总耗时。看这一行数据能快速判断是不是频繁Full GC新生代和老年代分配是否合理。jmap则更进一步可以导出堆转储文件Heap Dump。注意一点生产环境执行jmap -dump需要谨慎因为导出堆文件的过程会让JVM做一次类似Full GC的操作可能导致应用短暂停顿。如果是在流量高峰期这无异于主动给自己的服务制造一次卡顿。我一般会先评估影响或者干脆用后面要说的jcmd替代。2.3 jcmd新一代全能瑞士军刀很多人不知道JDK 8之后jcmd其实比jstack、jmap更好用它是官方推荐的统一诊断命令入口。同样是导出线程栈你可以这样操作# 生成线程栈输出到指定文件 jcmd PID Thread.print thread_dump.txt # 导出堆转储不触发GC的选项叫-all jcmd PID GC.heap_dump /path/to/dump.hprofjcmd的优势在于它的功能是一个命令集合包含VM.version、VM.system_properties、GC.heap_info等多个子命令。排查时如果不想记一堆JVM工具的命令格式jcmd可以统一解决。尤其是堆转储GC.heap_dump在大部分版本中比jmap的行为更温和对应用暂停的影响更小。不过工具箱里不能只有JDK自带命令。我强烈建议每个团队至少再部署一个Arthas作为线上诊断利器。Arthas能干很多事情比如直接查看某个方法的入参和返回值动态反编译线上类甚至在不重启服务的情况下修改运行中的代码逻辑。它有一个dashboard命令可以实时展示内存、GC、线程信息比jstat的静态输出直观得多。还有thread命令直接列出最忙的N个线程不用自己再去做十六进制转换了。这套工具组合完全能覆盖90%的线上JVM排查场景。工具作用适用场景生产环境风险jstack线程栈快照排查线程卡死、死锁、CPU占用基本无风险可放心使用jstatGC与内存监测观察GC频率、堆内存趋势低风险不影响应用jmap堆转储导出分析内存泄漏、大对象有触发GC停顿的风险需谨慎jcmd统一诊断命令线程栈、堆转储、VM信息堆转储注意停顿其余低风险Arthas在线诊断平台动态观察方法参数、反编译、热更新低风险但需关注权限控制3. 实战演练从CPU飙升到根因定位3.1 定位可疑进程拿到第一手现场纸上谈兵没有意义我拿一个真实案例来走一遍完整排查流程。假设模拟项目X上线后运维反馈生产环境某节点CPU占用率持续在150%左右请求响应变慢但服务没有宕机。第一步当然是把可疑的Java进程找出来。执行top按CPU使用率从高到低排序找到PID。这里建议多做一步top -Hp PID这个命令能显示该进程内部所有线程的CPU占用情况。你会看到一个线程的CPU占用可能就是80%、90%甚至更高。记下这个高CPU线程的ID转成十六进制这一步不要用计算器去手工算直接在Linux终端里敲printf %x\n 线程ID得到十六进制值比如0x45a2。然后执行jstack抓取线程栈在输出文件里搜索这个十六进制值jstack PID dump.txt grep -A 20 0x45a2 dump.txt搜索到的线程栈会直接告诉你这个线程正卡在哪一行Java代码上。如果是一段自己写的代码打开对应的类和方法基本就能看出循环是不是没退出条件或者是不是在不停地做字符串拼接之类的危险操作。3.2 解读线程栈判断是业务代码还是GC线程有个细节要注意CPU高不一定就是业务线程干的好事也有可能是GC线程在疯狂回收。看线程栈的时候如果发现占用CPU的是VM Thread或者GC task线程那么核心问题可能出在内存上——很可能是堆空间不足导致频繁Full GCGC线程占用了大量CPU。判断方法很简单看线程栈里有没有指向GC线程的标志。肉眼扫描一遍栈文件如果大量线程栈都指向你项目里的同一个类那就是业务死循环如果是GC相关线程在忙碌就要回头用jstat确认GC频率和堆内存分配情况。我那次线上事故就是经典的双线排查一方面有线程栈指向某SDK的解析方法另一方面jstat显示Full GC次数每分钟上百次。事后分析是SDK解析逻辑里有个没有断开条件的while循环不断产生新的对象又把Eden区撑爆触发GC后老年代迅速填满最终导致Full GC风暴。看起来是两个问题本质是同一个根因。3.3 案例复盘一次Full GC引发的CPU毛刺这个案例特别典型我再多说几句。当时我们团队排查思路是先看GC日志确认Full GC频率——确实高但频率高不等于就是根因还得回答为什么Full GC频繁。带着这个问题看线程栈发现某线程正在执行一个字符串解析的循环代码逻辑里存在一个方法循环内不断new String对象这些对象生命周期短应该进新生代但由于循环实在太快瞬间申请大量内存导致年轻代放不下对象直接晋升到老年代老年代迅速被填满。这个链条如果不看线程栈单纯堆内存分析很容易走弯路——你会误以为是不是哪里把大对象缓存起来了其实问题根源在代码逻辑是它在疯狂造垃圾对象。所以我的经验是线程栈和堆内存分析不是互相替代的关系而是互为主力的两个方向。回头看这个案例拯救我们的是两样东西一个是过硬的基础命令功底另一个是机械地记录了完整现场——GC日志、线程栈、堆转储都在第一时间保留下来了。真到了排查问题的时候手上没有现场数据再厉害的技术也发挥不出来。4. 堆内存分析从快照到泄漏点4.1 堆转储的正确打开方式排查内存泄漏线程栈只是辅助真正的主力是堆转储文件。但很多新手第一步就出错在服务已经快要OOM的时候才想起导堆结果导出过程太慢或者直接导不出来。正确做法是在问题还处在可疑阶段时就先把堆转储存下来宁可多存几个文件也不用担心占用磁盘空间。终端上导出命令我用得最多的是jmap -dump:live,formatb,file/data/dump/app_2025xx.hprof PID特别注意这个live参数它的意思是只导出可达对象会触发一次Full GC把垃圾先清掉。这个参数的问题在于它会改变现场——有些临时对象会被清掉但你本来就该关注存活的对象所以影响不大。如果为了保留完整现场、不让GC干扰数据可以不带live参数。取舍看要分析什么问题。另一个问题是堆转储文件通常很大动辄几个GB。这导致很多人在分析时犹豫甚至放弃分析。我的建议是磁盘空间不是问题但分析时的内存才是问题。本地电脑如果内存不够就别指望把几个GB的堆文件拖到MAT里直接打开正确的做法是用MAT的命令行模式先导出分析报告再查看。4.2 用MAT找到内存泄漏的元凶MATMemory Analyzer Tool是我主力使用的堆分析工具。它最出名的功能是Leak Suspects报告打开堆文件后MAT会自动生成一份嫌疑犯名单告诉你哪些对象最可疑、被什么对象引用着、估算占用了多少内存。不过自动报告只是入口真正的分析还要看两个人视图第一个是支配树Dominator Tree。它展示的是如果这个对象被回收能被连带回收掉多少内存的层级关系。内存泄漏的对象往往位于支配树的顶层附近因为它被很多其他对象引用着导致整棵引用树都收不掉。第二个是GC Roots追踪路径。MAT可以针对某个可疑对象列出从GC Roots到该对象的所有引用链。这条路径一旦找出来代码里到底是哪个静态集合类一直持有对象引用、哪个缓存没有清理策略就全部一目了然了。我见过不少内存泄漏案例归根结底逃不过老三样一是静态集合类无限添加数据且没有清理机制二是手动实现了某种缓存但缺淘汰策略三是线程池里的ThreadLocal没有remove每个线程都绑了一个大对象不释放。遇到这种情况有一个自查技巧打开MAT对比两份堆日志看哪类对象的增长幅度最大直接验证泄漏点。4.3 内存泄漏排查的完整链路现在把整个内存泄漏排查流程串起来说一个我最近处理过的典型情况。某应用老年代持续上涨每隔几小时就需要重启一次才能恢复。当时我们的排查顺序是先用jstat确认老年代曲线确实只升不降再用jmap导出两份不同时间点的堆转储文件拖进MAT用对比分析定位增长最快的类。结果显示一个自定义的消息对象数量暴涨顺着GC Roots追踪回去发现是一个全局的事件监听器在不断地往一个List里添加消息而该监听器被错误地设计成了单例且永远不会被消费。根因是一个设计缺陷不是JVM配置问题。这个案例说明内存泄漏的排查链路其实很清楚确认症状老年代持续涨→ 采集证据堆转储和GC日志→ 对比分析两份堆文件差异→ 追踪引用链GC Roots→ 代码修复。每一步都有工具对应照着这个链路走基本不会迷失方向。5. 生产环境排雷常见坑与实战技巧5.1 生产环境执行诊断命令的三大红线说真的很多人不是不会用工具而是用工具的方式把线上环境搞挂了。我先说三条红线都是同行用惨痛教训换来的经验。第一条不要在业务高峰期随意执行jmap -dump。就像前面提到的导出堆转储可能触发Full GC如果此时应用本来就在高负载运行一次强制GC就把服务打停了。必须要导的时候先评估堆大小、GC停顿时间或者提前在启动参数里配置好OOM自动导出堆文件-XX:HeapDumpOnOutOfMemoryError让它出事时自己留证据这样才是王道。第二条不要频繁抓取线程栈。虽然jstack对性能影响很小但在CPU已经吃紧的情况下连续的抓取操作会加剧负载。一般抓两三次就足够了每两次间隔不低于10秒。第三条不要在自己不熟悉的进程上乱执行命令。生产环境可能同时部署了多个Java应用用jps -l看清楚应用名再操作别把运维平台或者其他服务的进程当成目标。这个错误听起来低级但在紧急情况下真的很容易犯。5.2 线程栈分析容易忽视的细节拿到线程栈文件后我见过很多人直奔RUNNABLE状态的线程认为只要不是RUNNABLE就是瓶颈所在。这是个大误区。RUNNABLE状态看它执行的代码路径BLOCKED和WAITING状态也只是结果真正要问的是是什么导致它进入这个状态。举个例子一个线程处于WAITING状态停在park方法上它可能在等一个锁也可能在等某个线程池的任务队列。如果只知道它在等待你还需要配合看它等待的资源是什么是被哪个线程持有这就是jstack -l的锁信息派上用场的地方。还有个常见问题是盲目相信一次快照。我之前遇到过一个问题线上偶尔出现接口偶发超时但jstack抓一次完全看不到异常。后来改成连续抓取每隔15秒抓一次连续抓了10份对比后发现某个线程反复在同一个地方卡住那种偶发问题就暴露了。一次快照看不到规律多次快照对比才有意义。5.3 预警机制比事后排查更重要写了这么多排查技巧最后必须说句实在话最好的堆栈分析是根本不需要分析的场景。仪表盘上的监控、GC日志的自动归档、OOM时的自动转储这些在问题还没发酵之前就已经把线索记录好了。具体做法我建议在应用启动参数里加上-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/dump/ -Xloggc:/data/logs/gc.log -XX:PrintGCDetails -XX:PrintGCDateStamps这样就算服务哪天真的OOM了堆文件和GC日志都会自动躺在磁盘上不需要你在紧急时刻手忙脚乱地敲命令。我之前还踩过一次坑当时没配置HeapDumpOnOutOfMemoryError结果服务OOM重启后什么现场都没留下只能靠猜。那次之后我每次新项目上线第一件事就是检查启动参数里有没有这几行配置。再推荐一个低成本的预警方案写一个简单的脚本定时检查GC日志里Full GC的频率如果一分钟内Full GC次数超过阈值就自动触发一次线程栈抓取和堆转储保存。这样既不影响线上运行又能保证黄金现场的留存。我们团队后来还加上了指标告警堆内存使用率超过80%就开始预热排查而不是等系统完全卡死才处理。最后再分享一点个人体会JVM堆栈分析工具的本身学起来不难难的是形成一套先保现场、再看数据、后定位根因的方法论。我自己也经历过那种面对一堆线程栈数据无从下手的时候后来才发现关键不在于记住多少命令而在于对JVM运行机制的理解深度——线程栈上每一行调用背后都对应着内存与执行资源的消耗GC日志里的每个数据点都在讲述堆空间的故事。先把JVM的内存模型、线程状态转换、GC算法这些基础打牢再去看工具的输出很多问题自然而然就有了解题思路。从这个角度说工具只是一个载体真正赚钱的是你对JVM模型的理解。希望这篇文章能帮你在遇到类似问题的时候少走一些我走过的弯路。