线程快照这东西,平时用不着的时候没人想起它,一旦线上接口超时、CPU打满、服务假死,它就是第一手现场证据。我见过不少同事,遇到性能问题第一反应是看监控曲线、查日志,折腾半天定位不到根因,最后被迫在凌晨高峰期抓一份线程快照,几分钟就锁定了问题。这篇文章就围绕线程快照的获取与分析,把我在生产环境里实际用过的手段、踩过的坑、总结出来的套路一次说清楚。
先说清楚它解决什么问题:线程快照(Thread Dump)是JVM在某个瞬间对全部线程状态的“合影”,能看到每个线程在干什么、卡在哪个方法、锁竞争什么样。适合谁看?后端开发、运维、性能调优的工程师,尤其是那些被线上问题逼到过墙角的人。有了它,死锁、线程池耗尽、锁竞争、CPU飙高、IO阻塞都能有迹可循。
1. 线程快照是什么:性能排查的第一现场
1.1 一次性能事故的切入点
先讲个场景。某天线上接口突然大面积超时,监控面板上一片飘红,CPU没到100%但请求就是处理不过来。你打开日志,看到的全是超时异常,但异常本身不会告诉你为什么超时。这时候最有效的动作就是:连续抓几份线程快照,看看线程到底堵在哪。
线程快照的价值在于它是“瞬时状态”,不是事后推算。你看到的每一行都是那个毫秒级瞬间的真实情况:哪个线程在RUNNABLE、哪个线程在WAITING、哪个线程握着锁不放。这种现场感是日志和监控给不了的。
它解决的核心问题可以概括成三类:一是死锁——两个线程互相等对方的锁,谁也走不动;二是线程池耗尽——核心线程全部卡住,新任务全部排队乃至拒绝;三是锁竞争与CPU空转——线程大量处于可运行状态但拿不到CPU时间片或锁。
1.2 快照里每一行都在说什么
一份标准的线程快照,每条线程信息长这样:
"http-nio-8080-exec-12" #25 daemon prio=5 os_prio=0 cpu=12.34ms elapsed=3021.55s tid=0x00007f8e2c01a800 nid=0x1f3a waiting for monitor entry [0x00007f8e2d4e9000] java.lang.Thread.State: BLOCKED (on object monitor) at com.example.service.OrderService.getOrder(OrderService.java:120) - waiting to lock <0x00000000f1a2b3c0> (a java.lang.Object) at com.example.service.OrderServiceImpl$$EnhancerBySpringCGLIB$$123456.execute(OrderServiceImpl.java:88)这一坨信息里有几个关键字段:
- 线程名称:
http-nio-8080-exec-12,一看就知道是Tomcat工作线程,编号12。如果是自定义线程池,命名规范直接决定排查效率。 tid是JVM内部线程ID,nid是操作系统线程ID,十六进制。后面定位CPU飙高时要靠它和系统线程对上号。elapsed表示线程存活时间,cpu表示累计消耗的CPU时间,这两个值对判断线程是否“活着但什么也没干”很有用。java.lang.Thread.State是线程状态,这个最重要,后面单独讲。- 调用栈:从当前正在执行的方法一路到顶层,这是定位问题的核心路径。
- 锁信息:
waiting to lock表示在等某个锁,locked表示持有某个锁。死锁分析全靠它。
新手拿到快照容易懵,因为一份大厂线上快照动辄几百上千行。我的经验是先不看细节,扫一遍状态分布,再看有没有deadlock字样,然后挑异常的线程深挖调用栈。这个流程后面会细说。
2. 获取线程快照的六种方式与选型
2.1 命令行派:jstack与jcmd
先介绍最常用的两种命令。jstack是老牌工具,用法简单:
jstack -l <pid> > thread_dump.txt-l参数会输出锁的附加信息,排查死锁和锁竞争时务必加上。pid是Java进程ID,用jps -l或者ps -ef | grep java拿到。
jcmd是JDK 7u4之后引入的替代品,功能更全,官方也更推荐:
jcmd <pid> Thread.print -l > thread_dump.txt这两条命令的效果基本等价,但jcmd还能做JFR、GC堆转储等更多操作,如果你只需要线程快照,jstack更简单直接。
有个容易忽略的点:jstack -l和jcmd Thread.print都建议在进程用户下执行,否则可能报Unable to open socket file之类的权限错误。生产环境一般用部署账号登录,问题不大,但如果是用root去抓普通用户的Java进程,会经常碰壁。
2.2 生产环境兜底方案:kill -3 与日志重定向
有些场景你根本没法登录到容器里,或者jstack连不上进程(比如进程假死、JMX未开),这时候kill -3是最后的救命稻草。
kill -3 <pid>不会杀掉进程,而是向JVM发送SIGQUIT信号,JVM收到后会把线程快照打印到标准输出。问题在于标准输出通常被应用日志接管了,你得知道它打到哪了。Spring Boot应用如果用了nohup启动,快照就在nohup.out里;如果是systemd管理,可能在journal日志里。还有的部署脚本会>> app.log 2>&1,那就在app.log里。
我踩过一回坑:线上容器重启后,nohup.out被清理了,kill -3打出来的快照全丢。后来养成了习惯,抓快照前先确认重定向路径,或者在启动脚本里给JVM加-XX:+UnlockDiagnosticVMOptions -XX:+LogVMOutput -XX:LogFile=/path/to/jvm.log,这样SIGQUIT信息会额外写到指定文件,稳妥得多。
2.3 程序内自助获取:ThreadMXBean与异步线程快照
还有一种更主动的方式,不用外部命令,直接在应用里用java.lang.management.ThreadMXBean获取全线程栈:
ThreadMXBean bean = ManagementFactory.getThreadMXBean(); ThreadInfo[] infos = bean.dumpAllThreads(true, true); for (ThreadInfo info : infos) { System.out.println(info); }参数含义:第一个true表示输出锁信息,第二个true表示输出监视器信息。这种方式适合做“定时自动抓快照”或“触发式抓快照”,比如当接口超时率达到阈值时自动dump一版,存下来供事后分析。
我在一个高并发服务里就是这么干的:写了一个定时任务,每隔5分钟抓一份快照存到磁盘,只保留最近2小时的。线上出问题时,直接翻出事前10分钟的快照,对比正常时期的快照,差异一目了然。这个做法尤其适合那种“问题稍纵即逝”的场景,比等人到现场再抓靠谱得多。
2.4 千亿级流量下怎么选:工具对比表
不同场景选不同方式,我根据自己的经验整理了一张选型表:
| 工具/方式 | 适用场景 | 优势 | 缺点 |
|---|---|---|---|
| jstack -l | 常规排查,可登录主机 | 简单直接,信息全 | 需要JDK环境,部分容器受限 |
| jcmd Thread.print | 同jstack,功能更全 | 官方推荐,可扩展性强 | 同样受环境和权限限制 |
| kill -3 | 进程异常、无法附加 | JVM内置兜底 | 输出位置不确定,可能丢失 |
| ThreadMXBean | 定时/自动采集 | 可控性强,可集成到应用 | 需要编码,有轻微性能开销 |
| Arthas thread | 在线诊断 | 带线程状态统计和锁分析 | 生产环境需要谨慎评估 |
| 容器平台自带工具 | k8s等平台集成 | 免登录,集中采集 | 依赖平台能力,格式不一 |
这里多说一句Arthas,它的thread命令可以打印线程状态汇总和线程栈,还支持thread -b直接找阻塞的锁在哪,排查效率很高。但生产环境引入诊断工具要慎重,尤其是Java Agent类工具,先做压测评估再上线。
3. 分析线程快照的核心方法
3.1 五大线程状态与它们的“潜台词”
JVM线程状态一共有六种,分析快照时看前五种就够了:
- NEW:还没启动,正常。
- RUNNABLE:正在执行,或等待CPU时间片。注意,RUNNABLE不是“正在干活”,也可能在等CPU。
- BLOCKED:被锁阻塞,等着进入
synchronized块。 - WAITING:无限期等待,典型的是
wait()、join()、park()。 - TIMED_WAITING:有限期等待,典型的是
sleep()、wait(timeout)、parkNanos()。 - TERMINATED:已结束,正常。
一张快照里,大量线程处于WAITING或TIMED_WAITING不一定是坏事,HTTP长连接、连接池空闲、定时任务等待都是正常的。真正要警惕的是这两种:
- 大量线程BLOCKED:说明锁竞争激烈,有人在持锁不放。
- 大量线程RUNNABLE但CPU也不高:说明线程在空转、忙等或做无意义的自旋。
我见过一份快照,200个线程全部RUNNABLE,调用栈全停在ConcurrentLinkedQueue.poll上,典型的CAS自旋导致CPU飙高,直接把服务器打挂。如果你只盯着WAITING看,这种问题就漏掉了。
3.2 死锁分析的黄金流程
死锁是线程快照最能“一眼定乾坤”的问题。JVM在dump时会自动检测死锁,在快照末尾输出类似:
Found one Java-level deadlock: "http-nio-8080-exec-12": waiting to lock monitor 0x00007f8e2d001a00 (object 0x00000000f1a2b3c0, a java.lang.Object), which is held by "http-nio-8080-exec-15" "http-nio-8080-exec-15": waiting to lock monitor 0x00007f8e2d001c00 (object 0x00000000f1a2b3d0, a java.lang.Object), which is held by "http-nio-8080-exec-12"分析流程分三步:
- 先看有没有
Found one Java-level deadlock字样,有就直接按提示找到两个线程。 - 顺着两个线程的调用栈,找到它们各自持有什么锁、等待什么锁。
- 确认锁的获取顺序是否存在交叉,确认之后就去找代码里加锁的先后顺序。
定位到代码后,修复死锁三板斧:一是调整加锁顺序,让所有线程按同一顺序获取锁;二是用tryLock加超时替代synchronized;三是缩小锁粒度,避免一个方法里锁住多个资源。
3.3 CPU飙高类问题的定位路径(top -H + jstack + 十六进制转换)
CPU飙高是线上最常见的性能问题之一,线程快照配合操作系统命令,能精确到代码行。完整的定位流程是这样:
第一步,top看哪个Java进程CPU最高,记下PID。第二步,top -H -p <pid>看这个进程里哪个线程CPU最高,记下线程PID(十进制的TID)。第三步,把TID转成十六进制:
printf '%x\n' <tid>第四步,用jstack抓快照,在里面搜这个十六进制nid:
jstack -l <pid> | grep -A 30 'nid=0x<十六进制>'看到的调用栈就是CPU消耗的源头。这个方法我用了无数次,每次都能快速定位到具体方法。有个细节要注意:top -H显示的TID是十进制的,jstack里的nid是十六进制的,转换那一步不能省,别拿十进制去搜,搜不到还误以为线程不存在。
如果是Arthas,一条thread -n 3就能打印CPU占用最高的3条线程的栈,省去手动转换的功夫。
3.4 线程池耗尽与锁竞争的经典特征
线程池耗尽的快照样貌非常典型。假设核心线程数是16,你会在快照里看到16个pool-1-thread-N全部处于BLOCKED或WAITING,调用栈全部停在同一个地方——比如都在等一个数据库连接、都在等CountDownLatch、都在调某个远程接口。剩下的提交到线程池的任务要么在队列里排队,要么直接触发拒绝策略。
这种特征说明:不是线程池配少了,而是线程池里的线程被某个慢依赖“粘住”了。正确的处理思路不是盲目调大线程池,而是找到让线程长期阻塞的点——可能是外部接口超时时间设置太长、数据库连接池被打满、或者某个锁的并发度太低。
锁竞争的典型特征是大量线程处于BLOCKED状态,都在等同一个monitor。看快照时注意waiting to lock后面的对象地址,如果多个线程的等待目标指向同一个地址,这个锁就是热点。结合jstack输出的栈,就能找到持锁的线程在干什么,是否有IO操作、长事务、循环等耗时行为。
3.5 结合其他指标:线程快照不是孤证
线程快照虽然强,但不能只看它。我通常会配合三类数据交叉验证:
- 系统指标:CPU、内存、磁盘IO、网络IO,确认是计算密集、等待密集还是IO密集。
- GC日志:如果FGC频繁,线程可能是在等GC暂停,或者GC本身消耗大量CPU。
- 应用日志:前后时间段的异常、超时记录,能还原出事发的上下文。
比如一份快照里看到大量线程RUNNABLE停在GC相关栈上,而GC日志显示FGC连续发生,那么根因大概率是堆内存分配压力过大而不是线程逻辑问题。先有整体判断,再结合快照精确定位,这才是完整的排查思路。
4. 实操案例:一次完整的线程快照排障
4.1 现象与初判
有一回我们线上一个核心服务,白天高峰期接口P99延迟从80ms飙到1200ms,CPU只用了40%左右,但请求大量超时。监控上看到了明显的线程池活跃度异常,但没有直接给出根因。我先在监控里确认几个基础信息:QPS没有暴涨、内存和GC平稳、下游依赖依赖健康,排除了流量突增和依赖故障的可能。
初步判断问题出在应用内部线程资源的竞争或阻塞上。这个时候,线程快照就是定位根因的最直接手段。
4.2 现场采集
我先用jstack连抓了3份快照,间隔3秒一份:
jstack -l 12345 > dump_1.txt sleep 3 jstack -l 12345 > dump_2.txt sleep 3 jstack -l 12345 > dump_3.txt连续抓多份的原因是:单份快照可能碰巧捕捉到某个瞬间的异常,对比多份能确认问题是持续性的还是偶发的。如果3份快照里同样的线程状态重复出现,基本可以认定是稳定复现的问题。
同时我还检查了线程数:
jcmd 12345 Thread.print -l > dump_all.txt从快照头部统计线程总数,发现Tomcat工作线程几乎全部处于活跃状态,和预期一致。
4.3 快照研读与定位
打开快照,先扫状态分布。第一份快照里,Tomcat工作线程绝大多数是BLOCKED,堆栈集中在com.example.service.OrderService.getOrder和com.example.dao.OrderDao.updateStatus。第二份快照,同样位置依然BLOCKED。
于是我把焦点放在这两个方法上。顺着调用栈往下看,发现它们都在等待同一个锁地址:
- waiting to lock <0x00000000f1a2b3c0> (a java.lang.Object)而持有这个锁的线程停在:
at com.example.service.OrderService.getOrder(OrderService.java:120) - locked <0x00000000f1a2b3c0>从栈上看,持锁线程在getOrder里调用了一个远程接口userService.getUserInfo,而这个远程调用没有设置超时,导致线程长时间挂起,手里的锁一直不放。后面所有抢同一把锁的线程全部堵死。
4.4 修复与验证
修复方案两步走:
- 给远程调用配置连接超时和读超时,这里我们统一设置成3秒和5秒,杜绝线程无限等待。
- 把原来方法级的
synchronized锁改成更细粒度的锁,或者用ReentrantReadWriteLock读多写少的场景分离读写。
改完上线,接口P99回落到100ms以内,线程池活跃度恢复正常。整个过程从抓快照到定位根因,大约20分钟,比对着日志瞎猜快了不知道多少倍。
4.5 复盘总结
这个案例里有几个宝贵的经验:一个是远程调用必须设置超时,否则就是埋雷;另一个是锁粒度太粗,一个方法里把外部调用包在锁里是并发编程的大忌;还有一个是快照要连续多抓几份,单份太容易误判。
复盘会上我们也定了一个规矩:所有外部调用、数据库操作、锁内逻辑都要定期审查,不把容易阻塞的操作放进临界区。后续这服务再没出过同类问题。
5. 常见误区与实战避坑
5.1 抓快照的时机不对等于白抓
线程快照是瞬态,错过问题发生的时刻,抓到的东西往往没有意义。比如CPU已经降下来了,你再去抓,线程状态早就恢复正常了。
正确的做法是建立“事前采集”意识。我在前面提过的ThreadMXBean定时自动抓取就是一种手段。还可以在监控平台上配置告警触发动作,当P99超过阈值或线程池活跃度异常时自动执行jstack。这些都是“等问题发生时已经在抓”的思路。
如果问题已经发生、你正站在现场,记得多抓几份,连抓间隔2到5秒,别只抓一份。对比分析能区分持续性问题和瞬时抖动。
5.2 十分钟前的问题现在抓不到
有次排查一个偶发超时,我等问题再次出现时抓快照,等了半小时没抓到。后来才发现那个服务已经自动重启过了,进程PID都换了,快照自然无从谈起。
这事给我的教训是:线上服务出了问题,先确认进程有没有重启,如果重启了就去找上一轮的崩溃日志或之前的自动快照。如果服务是多实例的,问题可能只出现在其中一两个实例上,需要针对出问题的实例抓,而不是随便挑一个抓。
5.3 误读状态的三种情况
误读状态是新手最容易犯的错。我总结最常见三种:
第一种是把RUNNABLE当成“正在干活”。JVM里的RUNNABLE包括正在等待CPU时间片,所以看到大量RUNNABLE但CPU不高,要怀疑线程在忙等或自旋。
第二种是把WAITING当成“线程挂了”。其实连接池里的空闲线程、FutureTask.get()等待结果的线程,常态就是WAITING。要结合业务判断这个等待是否合理、等待时间是否过长。
第三种是把TIMED_WAITING一律当成sleep。Object.wait(timeout)、LockSupport.parkNanos都会出现TIMED_WAITING,需要看调用栈判断是合理等待还是锁获取失败后的重试。
读快照前先明确业务正常情况下各线程池的状态分布是什么样的,有了基线,异常才容易被识别。这也是为什么我推荐保存正常时期的快照作为对比样本。
5.4 采样间隔与持续采集
抓线程快照不是一次性的,讲究“多时段、多次数”。我常用的策略:
- 实时排查:每2到5秒抓一份,连抓5到10份。
- 问题复现期:等第一次异常出现后,立即抓一份,然后每隔30秒抓一份,连续抓20分钟,观察变化趋势。
- 长期监控:每分钟抓一份,存最近1小时,配合告警触发。
持续采集有个副作用:频繁dump会对性能有轻微影响,尤其线程多的时候。实测下来,每分钟一次对绝大多数应用可接受,但每2秒一次高频抓取持续太久会放大开销。需要根据线程规模调节频率。
5.5 快照文件本身的注意事项
最后聊几个容易被忽视的细节:
- 文件编码和格式:jstack输出默认是UTF-8,直接用文本编辑器打开即可。如果是在Windows上看,注意换行符和中文乱码问题。
- 文件不要太大:几千行甚至上万行的快照,不要用眼睛硬扫,用grep筛选关键线程名、状态、方法名,或者写个小脚本做状态分布统计。Arthas的thread命令也支持汇总视图,省去手动统计。
- 注意保密:线程快照里可能包含业务方法名、包结构、SQL语句等敏感信息,外部排查时脱敏后再发。
另外提醒一句:如果进程使用的JDK版本过旧(比如JDK 7甚至6),jstack输出格式略有差异,分析时别拿新版本的格式硬套。有些老版本连-l参数的行为都不完全一致,踩过坑的人会懂。
线程快照这项技术,本身体量不大,但价值非常实在。对一个后端开发者来说,学会在合适的时间抓快照、快速读懂快照里的信息,是把“线上出问题靠猜”变成“线上出问题靠证据”的分水岭。我个人在实际操作中体会最深的一条就是:平时多抓几份正常时期的快照存着当参照物,出问题时才有对比的基准。这比任何花哨的分析技巧都管用。