☰
JVM堆栈分析实战:从CPU飙升到内存泄漏的排查指南
2026/10/21 10:00:33 网站建设 项目流程

做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.hprof

jcmd的优势在于它的功能是一个命令集合,包含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 GC,GC线程占用了大量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,format=b,file=/data/dump/app_2025xx.hprof <PID>

特别注意这个live参数,它的意思是只导出可达对象,会触发一次Full GC把垃圾先清掉。这个参数的问题在于,它会改变现场——有些临时对象会被清掉,但你本来就该关注"存活的对象",所以影响不大。如果为了保留完整现场、不让GC干扰数据,可以不带live参数。取舍看要分析什么问题。

另一个问题是堆转储文件通常很大,动辄几个GB。这导致很多人在分析时犹豫,甚至放弃分析。我的建议是:磁盘空间不是问题,但分析时的内存才是问题。本地电脑如果内存不够,就别指望把几个GB的堆文件拖到MAT里直接打开,正确的做法是用MAT的命令行模式,先导出分析报告再查看。

4.2 用MAT找到内存泄漏的"元凶"

MAT(Memory 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模型的理解。希望这篇文章能帮你在遇到类似问题的时候,少走一些我走过的弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询