
线程快照这东西平时用不着的时候没人想起它一旦线上接口超时、CPU打满、服务假死它就是第一手现场证据。我见过不少同事遇到性能问题第一反应是看监控曲线、查日志折腾半天定位不到根因最后被迫在凌晨高峰期抓一份线程快照几分钟就锁定了问题。这篇文章就围绕线程快照的获取与分析把我在生产环境里实际用过的手段、踩过的坑、总结出来的套路一次说清楚。先说清楚它解决什么问题线程快照Thread Dump是JVM在某个瞬间对全部线程状态的“合影”能看到每个线程在干什么、卡在哪个方法、锁竞争什么样。适合谁看后端开发、运维、性能调优的工程师尤其是那些被线上问题逼到过墙角的人。有了它死锁、线程池耗尽、锁竞争、CPU飙高、IO阻塞都能有迹可循。1. 线程快照是什么性能排查的第一现场1.1 一次性能事故的切入点先讲个场景。某天线上接口突然大面积超时监控面板上一片飘红CPU没到100%但请求就是处理不过来。你打开日志看到的全是超时异常但异常本身不会告诉你为什么超时。这时候最有效的动作就是连续抓几份线程快照看看线程到底堵在哪。线程快照的价值在于它是“瞬时状态”不是事后推算。你看到的每一行都是那个毫秒级瞬间的真实情况哪个线程在RUNNABLE、哪个线程在WAITING、哪个线程握着锁不放。这种现场感是日志和监控给不了的。它解决的核心问题可以概括成三类一是死锁——两个线程互相等对方的锁谁也走不动二是线程池耗尽——核心线程全部卡住新任务全部排队乃至拒绝三是锁竞争与CPU空转——线程大量处于可运行状态但拿不到CPU时间片或锁。1.2 快照里每一行都在说什么一份标准的线程快照每条线程信息长这样http-nio-8080-exec-12 #25 daemon prio5 os_prio0 cpu12.34ms elapsed3021.55s tid0x00007f8e2c01a800 nid0x1f3a 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内部线程IDnid是操作系统线程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 21那就在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抓快照在里面搜这个十六进制nidjstack -l pid | grep -A 30 nid0x十六进制看到的调用栈就是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飙到1200msCPU只用了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甚至6jstack输出格式略有差异分析时别拿新版本的格式硬套。有些老版本连-l参数的行为都不完全一致踩过坑的人会懂。线程快照这项技术本身体量不大但价值非常实在。对一个后端开发者来说学会在合适的时间抓快照、快速读懂快照里的信息是把“线上出问题靠猜”变成“线上出问题靠证据”的分水岭。我个人在实际操作中体会最深的一条就是平时多抓几份正常时期的快照存着当参照物出问题时才有对比的基准。这比任何花哨的分析技巧都管用。