
做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:formatb,filexxx.hprof导出堆转储文件。jstat查看JVM GC状态和类加载信息。常用-gcutil看各区使用率-gccause看最近一次GC的原因。jcmdJDK 8之后功能越来越强可以执行GC.heap_dump、Thread.print、PerfCounter.print等命令比调用jstack、jmap更灵活也是我在容器环境下的首选。jinfo查看和修改运行时JVM参数在不需要重启的情况下调优某些参数时很有用。初学阶段强烈建议把这些命令逐个在本地起一个Spring Boot项目实验一遍。你知道jmap -histo和jmap -dump的差别知道什么时候用jcmd而不是jmap到了线上才不会慌。2.2 可视化工具和在线诊断工具的取舍JDK自带的VisualVM和**JMCJava Mission Control**可以连接本地或远程JVM图形化查看CPU、堆、线程、GC导出dump文件也很顺手。但线上环境不一定开放JMX端口所以这类工具更多用于本地测试环境或者压测环境。JDK 11之后VisualVM不再随JDK发布需要单独下载这点要注意。这两年我用得最多的是Arthas。它是命令行交互式诊断工具不用重启进程能在线执行dashboard看全局实时数据thread命令看线程状态和锁信息sc、sm做类和方法查找甚至能做方法级热替换。对于容器内Java进程、不方便联调的线上环境Arthas非常好用。选择和取舍上我的原则是能命令行解决的用命令行需要图形分析堆的用MAT需要在运行中动态观察和定位的直接Arthas。工具不是越多越好而是在自己的排障流程里形成一套顺手的组合。2.3 堆转储深度分析工具MATMemory 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。但有几个细节实操中特别重要。第一抓取时机。线程栈是瞬时快照很多问题线程状态是动态变化的比如短暂出现的锁冲突可能一闪而过。所以遇到卡死、死锁这类问题我通常会连续抓35份dump间隔35秒。单份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:formatb,fileheap.hprof pid但线上抓堆转储有几个必须提前注意的点一定不要忽略第一文件大小约等于堆大小。一个4GB堆dump出来就是4GB文件磁盘不够就直接抓取失败。我建议抓之前先df -h检查磁盘空间并且最好在单独的数据盘上建目录不要放到系统盘。第二抓取过程有代价。jmap -dump会触发一次full GC并且要求进程进入安全点。对超大堆这个过程可能持续数秒到数十秒。我在64GB堆的机器上抓过dump最夸张的一回停顿了近10秒。生产环境操作前必须评估影响窗口或者干脆靠-XX:HeapDumpOnOutOfMemoryError让JVM在OOM前自动导出避免手动抓取事故。第三jmap -dump:live,formatb,fileheap.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好几GBMAT默认启动内存只有1GB打开就OOM。这时候要修改MAT安装目录下的MemoryAnalyzer.ini把-Xmx调大比如-Xmx6144m。操作系统内存不够的话就得退回用jmap -histo先看对象直方图定位嫌疑类后再针对性抓dump。分析工具不是越大越全越好要懂得量体裁衣。4.4 高频泄漏模式与识别要点基于我的排障经验Java后端最常见的堆泄漏逃不出这几类第一类静态集合持有对象。最常见。比如一个静态MapString, Object当成缓存用只往里放从不清理。泄漏特征是dump里这个Map的实例数持续增加每个value都“有入口”。修复建议是淘汰策略比如引入带过期时间的缓存组件或者定时清理。第二类资源未关闭。连接、IO流、文件句柄打开后没有close。很多这类代码是“明明写了关闭却因为没有放在finally里中途异常时泄露了”。例JDBC连接和Statement。建议用try-with-resourcesJVM会保证关闭。第三类ThreadLocal残留。线程池里的线程是复用的如果你往ThreadLocal里塞了值而没有在finally里remove()线程再次执行任务时还能读到旧值同时对象也一直被线程引用无法释放。这在异步任务和Web容器里特别容易踩。识别泄漏点主要看ThreadLocalMap里的EntryMAT的直方图里可以搜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 dumpdump文件用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进去后用jcmdJDK 9自带来替代传统命令。5.5 常见问题排查速查表现象排查命令关键输出常见根因CPU飙高top → jstack线程nid匹配到业务代码行死循环、正则回溯、GC频繁接口卡死jstack 连续抓多份多线程同锁、WAITING状态死锁、线程池耗尽、上游依赖超时频繁GCjstat -gcutilFull GC次数飙升、老年代持续增长堆过小、内存泄漏、分配速率过高内存OOMjmap dump MATLeak Suspects命中静态集合、资源未关闭、ThreadLocal进程异常重启查看OOM日志 HEAPDUMPOOM记录、堆快照内存不足、泄漏线程状态异常jstack grep 状态大量BLOCKED/WAITING锁竞争、线程数配置不合理6. 写在最后的排障心得前面讲了工具和方法最后聊点我自己的实际体会。排查JVM问题顺序和节奏特别重要。我现在的习惯是先看现象定类型再用监控缩小范围然后命令行抓现场最后用MAT做深度分析。CPU问题就上线程栈内存问题就看堆和GC日志不要一上来就开MAT大文件那是最费时间的一条弯路。另一个心得是线程栈分析是“顿悟型”技能堆分析是“积累型”技能。前者需要你熟悉Java并发模型和类库常用线程状态多看几份真实dump就能建立直觉后者需要你反复用MAT去对比正常与异常才能培养出“看到某个对象直方图就觉得不对劲”的敏感度。最后分享一个实用脚本思路多节点采集时手动一个个执行太慢可以把命令封装成一段shell脚本批量对多个PID执行线程dump和堆直方图并统一打上时间戳存到指定目录。省下的时间足够你多喝一杯咖啡。这年头线上系统越来越复杂但JVM堆栈分析这套基本功依然是排查线上稳定性问题最可靠、最不会被绕过的一板斧。