线上服务突然卡住不动,日志不刷了,请求全部超时,这时候你手头最趁手的武器是什么?答案多半是线程Dump。它相当于给JVM进程拍了一张快照,把所有线程在那一瞬间的状态和执行位置全部记录下来。但快照拍出来只是第一步,成百上千个线程堆栈摆在眼前,怎么看出门道才是关键。这篇文章就围绕一次完整的线程Dump分析流程展开,从抓取、解读到定位,把每一步都讲透。

一、如何正确抓取线程Dump
抓取Dump最常用的工具是JDK自带的jstack。先通过jps或者ps -ef | grep java拿到进程号,然后执行jstack命令即可把所有线程的堆栈输出到文件。要注意的是,如果目标是运行在Docker容器里的进程,需要在容器内部执行,或者借助宿主机的nsenter工具进入进程命名空间,否则会报找不到进程的错误。
# 找到Java进程 jps -l # 抓取线程Dump并保存 jstack -l 12345 > /tmp/thread_dump_1.txt # 等待5秒后再抓一份,用于对比 sleep 5 jstack -l 12345 > /tmp/thread_dump_2.txt
连续抓两到三份Dump是一个非常重要的习惯。因为快照只能代表某个瞬间,某个线程恰好在你抓取的那一刻在等锁,不代表它真的卡死了。多份Dump对比,如果同一个线程的栈顶位置完全没变,才能确认它真的堵在那里。另外如果jstack命令本身长时间没反应,说明JVM进程可能已经假死,可以尝试加-F参数强制抓取,虽然强制模式下部分锁信息可能丢失,但至少能拿到线程栈。
除了jstack,还可以用jcmd来抓取,命令是jcmd 12345 Thread.print -l,效果基本一致。在容器环境或者只能访问标准输出的场景下,向进程发送SIGQUIT信号也能触发Dump输出,即执行kill -3 12345,JVM会把线程信息打印到stdout日志中,进程本身不会退出。这招在Kubernetes环境里配合kubectl logs查看非常方便。
二、读懂线程Dump中的关键信息
拿到Dump文件后,每一段线程信息的结构大致是:线程名、是否守护线程、优先级、线程ID、原生线程ID(nid)、线程状态,以及完整的调用栈。其中线程名和nid是最先要看的两个信息。规范的框架会给线程起有意义的名字,比如Tomcat的工作线程叫http-nio-8080-exec-1,Dubbo的叫DubboServerHandler-xxx,看到大量同类名字的线程都卡在同一个位置,问题范围基本就锁定了。
"http-nio-8080-exec-3" #47 daemon prio=5 os_prio=0 tid=0x00007f8b0c012800 nid=0x3d2f waiting on condition [0x00007f8ae23bd000] java.lang.Thread.State: TIMED_WAITING (parking) at sun.misc.Unsafe.park(Native Method) - parking to wait for <0x000000078c5a2b08> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:215) at org.apache.commons.pool2.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:449) ...
线程状态是解读的核心,常见的有几种。RUNNABLE并不一定表示在消耗CPU,线程在做网络IO时状态也是RUNNABLE,但实际是在等数据返回;BLOCKED表示线程在等待进入一个synchronized块,即抢锁失败,Dump里会明确写出waiting to lock <0x...>以及哪个线程持有这把锁;WAITING和TIMED_WAITING通常是线程在Condition上等待资源,比如从连接池借连接、从队列取任务。WAITING状态的线程本身不一定是异常,空闲线程池里的线程大部分都在WAITING等任务,关键看它等的对象是什么。
三、通过Dump配合top定位CPU飙升
CPU飙升场景需要把操作系统层面的线程和Dump里的线程对应起来。先用top -Hp 12345查看进程内哪个线程占用CPU最高,把线程号换算成十六进制,然后到Dump文件里搜索对应的nid,就能直接找到那个吃CPU的线程在执行什么代码。假设top里看到线程号15662占用98%的CPU,换算成十六进制是3d2e,在Dump里搜索nid=0x3d2e即可。
# 查看进程内各线程CPU占用 top -Hp 12345 # 假设最耗CPU的线程号是15662,转十六进制 printf "%x\n" 15662 # 输出 3d2e # 在Dump中定位该线程 grep -A 30 "nid=0x3d2e" /tmp/thread_dump_1.txt
定位到线程后看栈顶。如果栈顶是自己的业务代码,比如某个循环计算逻辑,多半是死循环或者算法复杂度出了问题;如果栈顶是正则相关的类如java.util.regex.Pattern$Curly.match,可能是正则表达式发生了灾难性回溯;如果栈顶是GC线程或者频繁出现Unsafe.park这类调用,则要考虑是不是JVM层面的内存问题导致的CPU升高。同样建议连续抓多份,栈顶代码段一直不变,才是真凶。
四、死锁的识别与分析
死锁是Dump分析里最令人愉快的一种情况,因为JVM自带死锁检测,如果存在死锁,Dump文件末尾会直接打印出一段诊断信息,明确告诉你线程1持有锁A等待锁B,线程2持有锁B等待锁A,形成环路。同时两个涉事线程的状态都是BLOCKED,各自标注了持有和等待的锁地址。这时候顺着锁地址互相搜索,把两个线程的完整调用栈拉出来,加锁的代码位置就一目了然了。
解决死锁的常规思路有三个:一是统一加锁顺序,保证所有线程按相同顺序获取锁,环路自然打破;二是改用tryLock带超时的获取方式,拿不到锁就释放自己已持有的锁重试;三是评估业务上是否真的需要两把锁,很多时候嵌套加锁是设计不合理造成的。修复后可以借助压测复现场景,再次抓Dump确认死锁信息消失。
还有一种隐性的类初始化死锁也值得警惕:两个线程分别在初始化两个不同的类,而各自的静态初始化块里又互相引用了对方类,JVM的类初始化锁同样会形成环路。这种场景在Dump里表现为线程栈顶停留在<clinit>方法,末尾同样会有死锁报告,排查时注意区分它与普通对象锁死锁的区别。
五、总结一套可复用的分析流程
把前面的内容串起来,可以固化成五步。第一步看Dump末尾有没有死锁诊断,有就直接处理;第二步搜索最常见的可疑状态,比如大量BLOCKED在同一个锁地址上,或者业务线程全部WAITING在数据库连接池;第三步用top加nid的方式对号入座,解决CPU类问题;第四步多份Dump对比,确认线程卡点是否持续存在,排除瞬间抖动的干扰;第五步结合业务线程名分组统计,比如统计http-nio开头的线程状态分布,快速判断Web层是否整体阻塞。
实际排查中还可以配合一些辅助手段提升效率,比如用fastThread、jca这类工具做可视化分析,自动统计线程状态分布和重复栈。但工具只是加速器,底层逻辑还是要靠自己理解线程状态与调用栈的含义。把这套流程练熟,下次线上服务卡顿,从抓取Dump到给出结论,十几分钟足够了。