做Java开发这行,迟早会碰到“线上告警响了、服务CPU飚了、接口批量超时、要么干脆内存溢出重启”这种用户体感级的故障。这时候靠日志打点往往不够,更直接的办法是给JVM做一次“快照体检”——把堆和线程栈导出来,看对象到底被谁拽着不放,看每个线程到底停在哪行代码上。这就是JVM堆栈分析的核心价值。我自己前几年排查线上问题,一大半都是靠这套方法收场的,从JDK自带的jstack、jmap到MAT、Arthas,每个工具在什么场景下用、怎么抓、怎么读,踩过的坑不少。这篇文章我把完整的方法论和实操步骤整理出来,覆盖CPU飙高、死锁、内存泄漏这几个最典型的问题场景,适合正在入门JVM调优的初中级开发,也适合需要一套“可以照着抄”的排障流程的运维同学。
1. 先把概念捋清楚:堆和栈到底在分析什么
1.1 两个容易搞混的区域
很多刚接触JVM的同学会把“堆”和“栈”理解成一个东西,其实它们的职责完全不同。
堆(Heap)是JVM内存里最大的一块区域,用来存放对象实例和数组。所有new出来的对象都在这片区域里分配,由垃圾回收器统一管理。堆分析的目标就是回答三个问题:对象有多少?谁存活?谁占着空间不放。
线程栈(Thread Stack)是每个线程私有的内存区域,记录线程执行方法时的一个个栈帧。每个栈帧包含局部变量表、操作数栈、动态链接、方法返回地址。线程栈分析的目标是回答三个问题:线程在做什么?卡在哪个锁或者哪个等待上?方法调用路径是什么样的?
这里有个容易忽略的点:栈里存的是局部变量,这些局部变量很可能引用堆里的对象。所以堆分析的时候经常要顺藤摸瓜看GCRoot路径,栈分析的时候也能看到某一线程持有的锁对象。两者不是孤立的,后面实操里我会提到组合使用的场景。
1.2 什么时候需要堆栈分析
不是所有问题都需要上堆栈分析。我一般按现象做第一层分类:
| 现象 | 首选分析方式 | 可能的原因方向 |
|---|---|---|
| CPU持续飙高 | 线程栈分析 | 死循环、正则回溯、频繁GC、序列化开销 |
| 接口卡死、无响应 | 线程栈分析 | 死锁、线程池耗尽、分布式锁等待 |
| 内存持续上涨、OOM | 堆分析 | 内存泄漏、大对象、Holder引用 |
| Full GC频繁、GC时间过长 | 结合堆分析和GC日志 | 内存分配速率过快、堆参数不合理、泄漏 |
| 应用频繁重启 | 堆分析 | OOM场景下的自动重启、资源不足 |
| 整体RT变高但不卡死 | 线程栈 + 监控指标 | 锁竞争、IO等待、CPU调度异常 |
这套表格不是万能的,但它给了我一个排障起点:先看现象属于CPU型、卡死型、内存型还是GC型,再决定抓什么数据。瞎抓dump只会浪费时间,而且线上环境每一步操作都有代价。
2. 工具选型:从JDK自带命令到复杂分析平台
2.1 JDK自带命令行工具是基本功
不管桌面端有多少花哨工具,线上服务器未必有图形界面,命令行工具才是真正的保底能力。我平时最常用的是这几个:
- jps:查看当前机器上有哪些Java进程。常用参数
-l显示完整包名,-v带出JVM启动参数。很多排障第一步就是用它确认目标进程PID。 - jstack <pid>:导出线程栈快照。
-l会输出锁的附加信息,-m会混入本地方法栈(native方法),排查用到ZGC、JNI或者一些底层库问题时很有用。 - jmap:查看堆信息。
-heap打印堆参数和当前占用,-histo打印对象直方图(能看到类名和实例数),-dump:format=b,file=xxx.hprof导出堆转储文件。 - jstat:查看JVM GC状态和类加载信息。常用
-gcutil看各区使用率,-gccause看最近一次GC的原因。 - jcmd:JDK 8之后功能越来越强,可以执行
GC.heap_dump、Thread.print、PerfCounter.print等命令,比调用jstack、jmap更灵活,也是我在容器环境下的首选。 - jinfo:查看和修改运行时JVM参数,在不需要重启的情况下调优某些参数时很有用。
初学阶段强烈建议把这些命令逐个在本地起一个Spring Boot项目实验一遍。你知道jmap -histo和jmap -dump的差别,知道什么时候用jcmd而不是jmap,到了线上才不会慌。
2.2 可视化工具和在线诊断工具的取舍
JDK自带的VisualVM和**JMC(Java Mission Control)**可以连接本地或远程JVM,图形化查看CPU、堆、线程、GC,导出dump文件也很顺手。但线上环境不一定开放JMX端口,所以这类工具更多用于本地测试环境或者压测环境。JDK 11之后VisualVM不再随JDK发布,需要单独下载,这点要注意。
这两年我用得最多的是Arthas。它是命令行交互式诊断工具,不用重启进程,能在线执行dashboard看全局实时数据,thread命令看线程状态和锁信息,sc、sm做类和方法查找,甚至能做方法级热替换。对于容器内Java进程、不方便联调的线上环境,Arthas非常好用。
选择和取舍上我的原则是:能命令行解决的用命令行,需要图形分析堆的用MAT,需要在运行中动态观察和定位的直接Arthas。工具不是越多越好,而是在自己的排障流程里形成一套顺手的组合。
2.3 堆转储深度分析工具
MAT(Memory Analyzer Tool)是我做堆分析的主工具。它能打开hprof格式的堆转储,提供三种核心视角:
- Leak Suspects:自动分析可能的泄漏点,直接告诉你“哪个对象最可疑”。
- Dominator Tree:支配树,展示哪些对象持有最多“retained size”,方便找大头。
- OQL:类似SQL的对象查询语言,可以写
select * from java.lang.String这样的语句,精准筛选对象。
还有一个老牌工具JProfiler,图形化做堆分析、CPU分析都很强大,但商业授权费用不低。如果没有预算,MAT足够应付九成问题。
工具选型最终服务于排障效率。我见过同学一上来就抓完整堆转储,结果文件好几个GB,机器内存不够还打不开,这是典型的“操作顺序错了”——后面第5章我会专门讲这些坑。
3. 线程栈分析:定位CPU飙高和接口卡死的不二法门
3.1 线程dump抓取的正确姿势
线程dump抓取本身很简单:jstack -l <pid> > thread_dump.txt。但有几个细节实操中特别重要。
第一,抓取时机。线程栈是瞬时快照,很多问题线程状态是动态变化的,比如短暂出现的锁冲突可能一闪而过。所以遇到卡死、死锁这类问题,我通常会连续抓3~5份dump,间隔3~5秒。单份dump可能看到的是表象,多份之间的变化才能还原“线程在怎么走”。
第二,重定向文件要带时间戳。thread_dump_20250120_103000.txt这种命名会让你对比多份dump时舒服很多,别问我怎么知道的。
第三,jstack有个常见误解:以为它会把JVM完全暂停。实际上jstack在JDK 8及以前通过SIGQUIT信号触发线程栈打印,会要求所有线程走到安全点(SafePoint),这会造成非常短暂的世界停顿。对绝大多数业务来说影响可以忽略,但如果是响应时间极度敏感的系统,建议挑低峰期抓,或者用jcmd Thread.print代替。
第四,容器环境里要注意PID问题。你在宿主机上看到进程PID可能是Java进程的真实PID,但如果在容器内执行命令,PID命名空间和宿主机不同。我在Kubernetes环境里一般先kubectl exec进pod,在pod内用jps找到Java进程PID,再执行后续命令。
3.2 CPU飙高排查完整流程
我拿一个真实模拟场景来演示:某后端服务CPU使用率从正常的20%突然飙到400%,用户投诉接口超时。
第一步,确认进程。
top找到CPU占满的Java进程PID,假设是12345。
第二步,查看进程内哪些线程占用CPU。
top -Hp 12345这一步会列出进程里的所有线程,按CPU使用率排序。假设看到线程ID是11254的线程占了接近300%的CPU。注意,这里的线程ID是十进制。
第三步,把线程ID转成十六进制。jstack输出里的nid字段是十六进制,所以要把十进制转一下:
printf '0x%x\n' 11254 # 输出 0x2bf6第四步,用jstack抓线程栈,并过滤出这个线程:
jstack 12345 | grep -A 30 '0x2bf6'这会显示出线程的完整调用栈,包括类名、方法名和代码行号。真实案例中我经常看到三类问题:
- 业务代码里出现
while(true)空转循环,比如错误的消息拉取实现。 - 正则表达式回溯爆炸,典型的是日志里某一行很长、正则分支又复杂。
- 频繁序列化和反序列化,比如在热点路径里反复
ObjectMapper.writeValueAsString。
看到调用栈后,定位到具体代码行,问题基本就水落石出了。
这里有个容易被坑的点:CPU高不一定是业务线程,也可能是GC线程。如果dashboard或jstack里看到大量GC task thread、VM Thread忙活,那就不能只盯着业务线程看,要去查GC日志和堆占用。GC线程非常高是“果”,“因”多半在堆分配速率或者内存不足上。
3.3 死锁分析
死锁是典型的一类卡死问题。用jstack抓线程dumb,然后在文件里搜索:
grep -A 30 'deadlock' thread_dump.txt如果看到Found one Java-level deadlock或者Found one Java-level deadlock后面的详细描述,说明JVM已经帮你确认了死锁。通常输出会写明:
- 线程A等待一个锁,这个锁被线程B持有。
- 线程B也在等待一个锁,而这个锁被线程A持有。
模拟项目X里就出现过典型的两个订单服务方法交叉加锁问题:A方法先锁订单对象再锁库存对象,B方法先锁库存对象再锁订单对象。当两个订单同时操作时,就死锁了。jstack输出直接能把加锁顺序和等待关系列出来,我按着输出改代码——统一加锁顺序、或者用tryLock带超时——就解决了。
不过实际业务中死锁不会写得那么直白,经常是间接调用链导致的。比如线程A持有锁1,等待锁2;锁2被线程B持着,线程B又在等线程C持有的锁3;线程C等锁1。这种环形等待要人工整理好几层,我更倾向于用Arthas的thread -b命令,它能直接找出阻塞其他线程的“根线程”,效率比肉眼盯jstack高不少。
3.4 线程状态解读与线程池耗尽场景
同样一份线程dump,新手看到一大堆状态会头痛。我整理了一个速查表:
| 线程状态 | 含义 | 典型场景 |
|---|---|---|
RUNNABLE | 正在执行Java代码或等待CPU,注意它不一定真的在跑指令 | 正常业务执行、循环空转、IO阻塞中的“看起来可运行” |
BLOCKED | 阻塞在synchronized代码块/方法上,等待monitor锁 | 锁竞争激烈、临界区代码过重 |
WAITING | 无限期等待另一个线程执行特定操作 | Object.wait()、Thread.join()、未带超时的LockSupport.park() |
TIMED_WAITING | 有超时时间的等待 | Thread.sleep()、带超时的wait()、LockSupport.parkNanos() |
TERMINATED | 已结束 | 一般很少在dump里看到 |
线上接口突卡死的一个高频原因,是线程池被耗尽。比如Tomcat默认最大线程数200,如果200个线程全部卡在某个上游调用上,新的请求就只能排队,表现为接口超时、系统整体响应变慢。这时候jstack的典型特征是:大量线程停留在WAITING on java.util.concurrent.CountDownLatch、WAITING on java.util.concurrent.CompletableFuture,或者BLOCKED on object monitor。
我记得有一次排查某个微服务接口雪崩,jstack一看,200多个Tomcat线程全部卡在一个HttpClient连接的等待上——上游服务已不可用,下游的连接池超时设置又特别长,导致线程全部被占死。我当时的处理是:先把超时时间调短,让线程尽快失败释放;再引入熔断降级,不让不可用服务拖垮整个进程;最后给连接池设置线程隔离。整个排查过程中,jstack让我确认了卡点在哪一层,而不是靠猜。
4. 堆分析:内存溢出和内存泄漏的精准手术
4.1 堆转储和GC日志:两个维度看内存
堆分析不是单纯“导个dump就行了”,我习惯把内存问题拆成两个观察维度。
GC日志看的是时间维度的趋势:隔多久触发一次Minor GC?老年代增长速率如何?每次GC后存活对象有没有下降?这个维度能告诉你“内存是不是一直在涨”“GC频率是否合理”。
堆转储看的是空间维度的快照:当前有哪些对象类型、实例数多少、谁在引用着大对象。这个维度能精确定位“什么对象占着内存不肯放”。
排查内存泄漏,我通常是两件事配合着做:先开GC日志看趋势,确认老年代持续增长;再导出堆转储用MAT找泄漏点。直接抓dump反而容易忽略“正常情况下的内存波动”,比如大促流量上来,对象多一倍,那是合理现象。
4.2 抓取堆转储的正确姿势
堆转储的命令很简单:
jmap -dump:format=b,file=heap.hprof <pid>但线上抓堆转储有几个必须提前注意的点,一定不要忽略:
第一,文件大小约等于堆大小。一个4GB堆dump出来就是4GB文件,磁盘不够就直接抓取失败。我建议抓之前先df -h检查磁盘空间,并且最好在单独的数据盘上建目录,不要放到系统盘。
第二,抓取过程有代价。jmap -dump会触发一次full GC,并且要求进程进入安全点。对超大堆,这个过程可能持续数秒到数十秒。我在64GB堆的机器上抓过dump,最夸张的一回停顿了近10秒。生产环境操作前必须评估影响窗口,或者干脆靠-XX:+HeapDumpOnOutOfMemoryError让JVM在OOM前自动导出,避免手动抓取事故。
第三,jmap -dump:live,format=b,file=heap.hprof和去掉live的区别。加了live会先触发一次GC,只保留存活对象,文件会小一些、分析时干扰少;但不带live能保留更多现场信息,包括已经被GC准备回收但还没回收的对象。我自己的习惯:两侧对比或者是泄漏类问题用live,看全量对象分布用不带live版本。
第四,OOM自动导出参数是保命符。上线配置里我每次都会加:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/heapdump这几乎没有额外开销,却能在OOM时留下案发现场。没有这一步,靠事发后手动抓dump基本来不及——OOM发生时进程可能已经半死不活了。
4.3 MAT分析实战:三步找到泄漏嫌疑人
拿到堆转储文件后,我用MAT一般走三步。
第一步,看Leak Suspects。MAT打开hprof文件后会生成一份“嫌疑报告”,列出最可能是泄漏根的对象链。它的推理逻辑是通过计算对象的retained size和GCRoot路径,把“持有大量对象实例的单一入口”给揪出来。这一步不需要太高门槛,新手也能看懂报告上的“疑似问题”。
第二步,看Dominator Tree。支配树按每个对象“如果释放它,能释放多少字节”排序。大对象或者说白了是被很多对象引用的公共父容器,会出现在最上方。我经常在这里看到一个ArrayList、HashMap或者自定义业务类,里面塞了几十万条数据,问题就非常直观。
第三步,用OQL精准验证。OQL是MAT里的SQL方言,比如:
SELECT * FROM java.util.HashMap$Node[]或者搜索某个业务类的实例,看看它们的字段值:
SELECT * FROM com.example.OrderCache我还能用OQL算出某类对象的数量和总大小,佐证“某个缓存没有上限”的判断。
我还遇过一种情况:本地heap dump好几GB,MAT默认启动内存只有1GB,打开就OOM。这时候要修改MAT安装目录下的MemoryAnalyzer.ini,把-Xmx调大,比如-Xmx6144m。操作系统内存不够的话,就得退回用jmap -histo先看对象直方图,定位嫌疑类后再针对性抓dump。分析工具不是越大越全越好,要懂得量体裁衣。
4.4 高频泄漏模式与识别要点
基于我的排障经验,Java后端最常见的堆泄漏逃不出这几类:
第一类:静态集合持有对象。最常见。比如一个静态Map<String, Object>当成缓存用,只往里放,从不清理。泄漏特征是dump里这个Map的实例数持续增加,每个value都“有入口”。修复建议是淘汰策略,比如引入带过期时间的缓存组件,或者定时清理。
第二类:资源未关闭。连接、IO流、文件句柄打开后没有close。很多这类代码是“明明写了关闭,却因为没有放在finally里,中途异常时泄露了”。例:JDBC连接和Statement。建议用try-with-resources,JVM会保证关闭。
第三类:ThreadLocal残留。线程池里的线程是复用的,如果你往ThreadLocal里塞了值而没有在finally里remove(),线程再次执行任务时还能读到旧值,同时对象也一直被线程引用,无法释放。这在异步任务和Web容器里特别容易踩。识别泄漏点主要看ThreadLocalMap里的Entry,MAT的直方图里可以搜java.lang.ThreadLocal$ThreadLocalMap$Entry。
第四类:监听器/回调未解绑。注册了事件监听器或订阅了消息,但没有取消注册/订阅。对象被监听器列表持有,即使业务侧已经不再使用,仍然无法回收。泄漏特征看listener容器里对象实例数和业务实际存活数不符。
第五类:缓存无淘汰机制。非静态缓存,比如一个成员变量Map,跟着业务数据无限增长。这种和第一类类似,但它不是“全局静态”,而是“实例级无限增长”。
第六类:String.intern滥用。JDK 7之后字符串常量池移到堆里,String.intern()用得不好会在堆里堆积大量不可回收的字符串。排查起来比较复杂,我通常是通过OQL统计字符串内容和数量来判断。
识别思路说穿了就是:看到某个集合或容器类的实例数剧增、且生命周期长、又没有释放入口,它就是头号嫌疑人。MAT的作用不是替你做判断,而是把这家隐藏在几百层调用之下的“持有关系”给挖出来。
5. 常见问题与排查技巧实录
5.1 抓取时机不对,等于白抓
我见过太多人拿到手一份dump,分析半天发现“一切正常”。问题多半出在抓取时机上。内存泄漏是渐进的,如果你是在业务低峰、监控指标已经回落之后抓的dump,泄漏对象可能已经被GC清了一部分;或者问题还没到顶点,泄漏特征不明显。正确的做法是:盯监控指标,内存涨到高位并持续不下时立刻抓。对于线程问题也同理,CPU高的时候抓才有意义,等进程恢复“正常”再抓就没有现场了。
另外,多实例比对是很好用的办法。如果服务部署了多个实例,只有一台出了问题,可以同时间点抓两台实例的dump做一个横向对比。正常实例的某类对象是10万个,异常实例是500万个,那差异本身就是突破口。
5.2 dump文件太大打不开
这个坑太常见了。解决办法有这么几条路:
- 用MAT时先调
-Xmx; - 用
jmap -histo先做粗筛,定位到具体可疑类后再考虑抓full dump; - dump文件用zip压缩后传输,注意hprof本身是二进制,压缩率很高;
- 如果只是看对象数和大小,不需要完整引用关系,用
jmap -histo:live就够,何必抓整个堆文件。
我一直坚持一个原则:能少抓就少抓,能先用直方图就用直方图。完整堆转储是最后一步的杀手锏,不是第一步的照明灯。
5.3 线程dump里看不懂的那些原生线程
新手看jstack输出常常会被一些非业务线程吓到,比如VM Thread、GC task thread#0 (ParallelGC)、CompilerThread2、Signal Dispatcher、Finalizer等。这些是JVM自带的线程,是正常的,不是问题所在。排查时要先通过线程名排除这些系统线程,把注意力放在应用线程上。如果应用线程大量阻塞在GC锁或者GC Thread里,说明内存问题已经严重到影响线程调度,这时候要回去看GC日志和堆。
我看线程栈的快速方法:先数一下线程总数和状态分布,再找nid匹配的线程。如果大把的RUNNABLE里有业务代码调用,那是CPU热点;如果大量BLOCKED和WAITING,那就是并发协调问题;如果线程本身没事干,但CPU高,那就要看GC线程。
5.4 容器环境里排障的额外注意点
容器化后的JVM排障比裸机多几个弯。首先是PID命名空间,容器内的jps看到的PID和宿主机看到的不同,不要混用。其次是资源限制,容器设置了CPU limit和内存limit,你会看到CPU使用到达容器上限而非宿主机的100%;GC和堆的行为也和裸机不一样,不能直接套用宿主机排查时的经验。还有一点,很多基础镜像没有安装jstack/jmap/jcmd,我在构建镜像时总会额外把JDK工具带进去,或者确保可以靠kubectl exec进去后用jcmd(JDK 9+自带)来替代传统命令。
5.5 常见问题排查速查表
| 现象 | 排查命令 | 关键输出 | 常见根因 |
|---|---|---|---|
| CPU飙高 | top → jstack | 线程nid匹配到业务代码行 | 死循环、正则回溯、GC频繁 |
| 接口卡死 | jstack 连续抓多份 | 多线程同锁、WAITING状态 | 死锁、线程池耗尽、上游依赖超时 |
| 频繁GC | jstat -gcutil | Full GC次数飙升、老年代持续增长 | 堆过小、内存泄漏、分配速率过高 |
| 内存OOM | jmap dump + MAT | Leak Suspects命中 | 静态集合、资源未关闭、ThreadLocal |
| 进程异常重启 | 查看OOM日志 + HEAPDUMP | OOM记录、堆快照 | 内存不足、泄漏 |
| 线程状态异常 | jstack grep 状态 | 大量BLOCKED/WAITING | 锁竞争、线程数配置不合理 |
6. 写在最后的排障心得
前面讲了工具和方法,最后聊点我自己的实际体会。
排查JVM问题,顺序和节奏特别重要。我现在的习惯是:先看现象定类型,再用监控缩小范围,然后命令行抓现场,最后用MAT做深度分析。CPU问题就上线程栈,内存问题就看堆和GC日志,不要一上来就开MAT大文件,那是最费时间的一条弯路。
另一个心得是:线程栈分析是“顿悟型”技能,堆分析是“积累型”技能。前者需要你熟悉Java并发模型和类库常用线程状态,多看几份真实dump就能建立直觉;后者需要你反复用MAT去对比正常与异常,才能培养出“看到某个对象直方图就觉得不对劲”的敏感度。
最后分享一个实用脚本思路:多节点采集时,手动一个个执行太慢,可以把命令封装成一段shell脚本,批量对多个PID执行线程dump和堆直方图,并统一打上时间戳存到指定目录。省下的时间足够你多喝一杯咖啡。这年头线上系统越来越复杂,但JVM堆栈分析这套基本功,依然是排查线上稳定性问题最可靠、最不会被绕过的一板斧。