上个月我半夜被叫起来原因是线上一个服务突然有一批请求卡了十几秒才返回CPU没爆内存也没报警但就是莫名其妙的慢。同事第一反应是去翻监控曲线但老江湖直接说先别猜抓一份线程Dump看看。于是jstack pid一下几秒钟后导出一个满是线程状态和调用栈的快照。读这份文件的过程比看监控曲线直观得多哪个线程卡在哪段代码、它在等哪把锁、是不是死锁全都清清楚楚。可以说jstack导出的线程Dump是排查Java应用线上问题最直接的一手现场证据也是每个Java后端、SRE、性能调优工程师必须熟练阅读的东西。这篇内容不打算讲太深的理论而是从一个真实排障的视角出发把线程Dump里那些看起来像天书的字段逐个拆开再配合几个最常见的故障现场告诉你到底怎么从里面定位问题。无论你是刚接触Java的新人还是已经见过几次线上线程堆积的老手照着这个思路去读都能少走很多弯路。1. 什么时候需要抓线程Dump怎么抓才算抓得准1.1 先判断该不该抓别一上来就jstack线程Dump会打印出JVM里所有线程的瞬时状态包括线程名、优先级、线程id、状态、锁信息、调用栈。它解决的核心问题只有一个此刻每个线程正在干什么为什么卡在这里。适合抓Dump的场景基本是这几类接口响应时间突然飙高RT响应时间从几十毫秒变成几秒甚至几十秒某个应用实例“假死”健康检查还活着但业务请求全部超时CPU占用居高不下肉眼可见GC频率也不低但不知道是哪个线程在消耗CPU怀疑有死锁、锁竞争、线程池耗尽、连接池耗尽发布新版本后出现偶发卡顿日志里又没有明显异常。但也不是所有卡顿都需要立刻jstack。如果只是短暂抖动、单次超时先看监控、日志和GC记录确认问题持续存在再抓。否则你抓下来一堆“正常等待”的线程池状态反而容易被误导。1.2 jstack命令的正确打开方式基本命令很简单# 查找Java进程pid jps -l # 导出线程dump普通输出 jstack pid thread.dump # 导出线程dump并打印锁信息重要推荐加 jstack -l pid thread.dump-l这个参数非常关键它会把锁的详情也就是AQS同步器、ownable synchronizers、monitor等锁对象信息打印出来。不加-l死锁分析和锁竞争分析就会缺少关键信息。JDK 9以上还有-e参数可以打印额外线程信息但实际排查用-l基本够了。如果jstack命令连不上进程比如进程hung住但PID还在可以试jstack -F pid。强制模式会用attach机制去取线程信息不过-F要求进程处于可调试状态有时取不到完整数据。还有个同级别的命令是jcmd pid Thread.print输出形式和jstack类似在JDK较新版本里几乎可以完全替代jstack。另外在容器环境或无法直接执行jstack时kill -3 pid也能把线程Dump打到应用的stdout或日志文件里但这种方式的输出位置不固定回收日志比重定向麻烦推荐度低于jstack。抓取的时间点要选准。一次Dump只是瞬时快照很可能刚抓到线程在sleep下一秒就恢复正常了。强烈建议每隔3-5秒钟连续抓5份左右保存成5个文件再做横向对比。比如接口一直卡死第二份、第三份抓到的都是同一个位置的调用栈那就基本实锤了如果每份位置都不同可能只是偶发调度或线程切换。附赠一个更有参考价值的抓法抓Dump之前或同时抓一份GC日志片段。jstat -gcutil pid 1000 5把GC变化情况和线程状态放在一起看往往能直接排除“到底是GC停顿导致还是业务锁竞争导致”的疑问。注意执行jstack要尽量和Java进程同一个系统用户否则可能因为权限问题无法attach。容器里执行时最好进入同一个PID namespace并确认临时目录有可写权限。2. 拆解线程Dump每一行到底在说什么2.1 线程头的身份信息先看一段真实样式的Dump片段http-nio-8080-exec-10 #20 daemon prio5 os_prio0 cpu12.32ms elapsed234.11s tid0x00007f1d2806c800 nid0x3e7b runnable [0x00007f1d201c3000] java.lang.Thread.State: RUNNABLE at java.net.SocketInputStream.socketRead0(Native Method) at java.net.SocketInputStream.read(SocketInputStream.java:150)逐项拆开看http-nio-8080-exec-10线程名。这个名字是业务代码或框架设置的Tomcat的HTTP线程池通常叫http-nio-8080-exec-*很多中间件也会用有业务含义的线程名。看到线程名基本能猜到线程是干嘛的。#20线程在JVM内部的编号用于阅读定位不是操作系统线程ID。daemon是否是守护线程。如果线程名后有daemon说明它是后台线程一般不影响进程退出。prio5Java线程优先级默认是5。cpu12.32ms该线程累计消耗的CPU时间JDK9以后才有这个字段对找CPU热点非常有帮助。tidJVM内部线程地址一般用于内部标识排查时很少直接用到。nid0x3e7b操作系统线程ID的十六进制形式。这个值非常关键因为top -Hp pid里看到的线程PID是十进制转成十六进制才能和Dump里的nid对上。runnable这是线程的调度状态是一个粗粒度信息。真正要关心的是下一行的java.lang.Thread.State。[0x00007f1d201c3000]是线程栈的地址绝大多数场景不用关心。真正读Dump要盯住的是java.lang.Thread.State字段和下面几行调用栈。2.2 读懂五种关键状态直接给一个速查表状态可能含义常见调用栈特征RUNNABLE正在“可运行状态”可能真的在执行指令也可能在等待CPU调度或非阻塞系统调用比如Socket读、文件读栈顶是native方法或业务代码正在执行BLOCKED等待monitor锁锁被别的线程持有waiting to lock 0x...WAITING无限期等待另一线程唤醒比如Object.wait()、LockSupport.park()Object.wait(...)、LockSupport.park(...)、parking to wait forTIMED_WAITING有超时时间的等待比如Thread.sleep(ms)、wait(timeout)、join(timeout)、parkNanosThread.sleep(...)、Object.wait(...)、LockSupport.parkNanos(...)TERMINATED线程已结束Dump中很少见-很多新人在看到WAITING状态时就很紧张其实线程池里的空闲线程几乎都是WAITING或TIMED_WAITING。比如pool-1-thread-1 #11 prio5 ... WAITING at jdk.internal.misc.Unsafe.park(Native Method) at java.util.concurrent.locks.LockSupport.park(...) at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(...) at java.util.concurrent.ThreadPoolExecutor.getTask(...)这是线程池没有任务时阻塞在获取队列任务上属于完全正常的状态。区分异常等待的关键是看调用栈是否能落到真实业务代码以及WAITING线程占整体线程数的比例。如果所有线程都WAITING在同一个业务方法上那就不是“正常休息”而是“全员卡死”了。2.3 调用栈线程的“案发现场”线程下面整块的at ...就是该线程当前的方法调用链从栈顶到栈底。栈顶是当前正在执行的位置栈底是入口。at com.example.service.OrderService.getOrder(OrderService.java:45) - waiting to lock 0x00000000f0a1b2c0 (a com.example.entity.OrderLock) at com.example.api.OrderController.detail(OrderController.java:88)如果只有一个线程这么走说明它正在等锁。如果大量线程都卡在同一个at位置还都waiting to lock同一个地址那极大概率是这里有个热点锁几乎所有请求都堆积在这。还有另一种情况线程状态是RUNNABLE栈顶一直在跑业务代码CPU还高那多半是死循环或超长计算。之前我遇到过一个正则匹配问题Dump里整个线程栈都在java.util.regex.Pattern的匹配方法里打转CPU飙到百分之百点开业务代码一看贪婪匹配在长文本上回溯爆炸了。经验之谈调优时我很少一上来就整篇读Dump而是先grep -c java.lang.Thread.State thread.dump统计状态数量再grep -A 20 业务线程名 thread.dump过滤关键线程。Dump动不动就几千行逐行读是给自己找罪受。2.4 锁信息与“Found one Java-level deadlock”-l参数会额外打印长列表锁信息典型内容是Locked ownable synchronizers: - 0x00000000f0a1b2c0 (a java.util.concurrent.locks.ReentrantLock$NonfairSync)这说明线程当前持有了一个AQS同步器锁。如果线程在等待monitor锁会看到- waiting to lock 0x00000000f0a1b2c0 (a com.example.entity.OrderLock)如果是synchronized锁Dump里还可能出现- locked 0x00000000f0a2b3d0 (a com.example.entity.User)重点来了真的发生死锁时jstack会在Dump末尾单独输出一段以Found one Java-level deadlock:开头的总结。它会直接列出“线程A正在等待线程B持有的锁”“线程B正在等待线程A持有的锁”最后还会给出版本和死锁线程数量。出现这段文字就可以直接认定是JVM层面死锁不用再费劲人肉分析。不过没有“Found deadlock”不代表没有锁问题数据库锁、分布式锁等不是JVM锁jstack看不到。后面会展开讲这类锁竞争怎么排查。3. 从线程Dump定位三类经典故障3.1 CPU飙高找出最热的那个线程这是最常见的问题。操作步骤分四步在Linux上先用top找到CPU占用高的Java进程PID用top -Hp pid列出该进程内所有线程的CPU占用记录占用最高的线程PID把PID转成十六进制比如线程PID是15995执行printf %x\n 15995得到3e7b去Dump文件里搜nid0x3e7b定位到线程读调用栈。实际项目里如果JDK版本比较新线程头里已经有cpu字段可以直接在Dump里按CPU耗时排序找热点线程不用手动去top -Hp对齐。当然两者结合仍然最稳。假设定位到这段order-thread-5 #12 prio5 ... cpu86712.30ms ... java.lang.Thread.State: RUNNABLE at com.example.service.PriceCalService.calculate(PriceCalService.java:33)这个线程CPU累计消耗86秒状态RUNNABLE调用栈停在一个业务方法上。那基本可以怀疑这段代码里有死循环或者正则匹配、XML解析、大量字符串拼接之类的耗时操作。打开源码看第33行一般就能找到问题。还有一类CPU高的线程是GC线程Dump里线程名是GC thread...、G1 Concurrent...堆栈在VM内部。这种情况需要配合GC日志和jstat -gcutil看内存回收压力而不是只盯着业务线程分析。3.2 大量线程阻塞锁竞争、连接池耗尽、线程池耗尽锁竞争是最容易从Dump中读出来的现象。一堆线程状态是BLOCKED并且分别waiting to lock 0x...同一个地址说明这把锁上堆了大量等待者。thread-1 #15 prio5 ... BLOCKED at com.example.service.StockService.deduct(StockService.java:20) - waiting to lock 0x00000000e1a2b3c4 (a com.example.service.StockLock)接下来要问到底是谁持有这把锁需要去所有线程里搜索0x00000000e1a2b3c4找到显示- locked 0x00000000e1a2b3c4的线程。如果在所有线程里都找不到同样的地址有一种可能是锁已经释放但等待线程还没被唤醒或者锁由底层VM、跨语言调用持有。还有一类常见场景是连接池耗尽。比如数据库连接池出现等待Dump里会看到大量线程卡在HikariCP.getConnection(...)或DruidDataSource.getConnection(...)状态是WAITING或TIMED_WAITING。这类现象表面是线程等待实际原因可能是数据库慢SQL、连接泄漏或者数据库连接被耗时的SQL占满。只看Dump还不够要配合连接池监控和数据库层面的慢日志一起排查。线程池耗尽的特征也很明显Tomcat线程池满了线程名http-nio-8080-exec-*全部跑到某个下游调用的地方等待或者自定义线程池满了队列排满提交任务时触发拒绝策略。要注意的是如果拒绝策略是AbortPolicy日志里会有RejectedExecutionException如果Dump里所有业务线程都阻塞在某处新任务才会无处放。判断时要结合日志里的拒绝异常次数。3.3 死锁与“假死”现场怎么区分真死锁看Dump末尾是否有Found one Java-level deadlock:并观察两个线程互相持有对方想要的锁。比如Found one Java-level deadlock: worker-A: waiting to lock 0x00000000f0a10001 (a com.example.LockB) held by worker-B worker-B: waiting to lock 0x00000000f0a10002 (a com.example.LockA) held by worker-A这就不必怀疑了直接检查代码加锁顺序。两个线程同时拿到相反顺序的锁典型的加锁顺序不一致导致。假死应用进程还在但业务完全不响应。Dump可能看到两种极端情况绝大多数线程停在Object.wait()/LockSupport.park()业务线程全部静默可能是某个核心组件初始化失败后进入“等待唤醒”状态绝大多数线程阻塞等待某个外部依赖比如Redis、MySQL、RPC下游调用超时。这时候单独的Dump还不够。需要结合线程名一起去对调用链线程都卡在自己系统内还是没有发出下游日志。把网络超时参数、线程池队列长度和Dump时间点一拼基本能圈定范围。别忘记看Dump头部的时间戳。jstack默认会输出Full thread dump开头带年月日和时区。排查过程中多份Dump之间的时间差可以帮助判断问题是持续性的还是偶发性的。4. 解读时的常见误区和经验技巧4.1 只抓一份Dump就是耍流氓这是我在踩过坑之后最想强调的。有一次线上服务偶发RT飙高我抓了一份Dump里面有个线程卡在Thread.sleep(30000)看起来很可疑但它其实只是一个定时任务在空转。真正的问题线程在第二份、第三份Dump里才现身——某个线程反复卡在同一个锁上锁拥有者在那几秒里一直在变。线程Dump是“快照”不是“录像”。一次快照只能说明那一个瞬间的情况线程状态随时可能改变。所以抓Dump的工作习惯应该是确认现场是持续性问题时立刻连抓3-5份间隔3秒对比不同份中出现频率高、位置一致的阻塞点对同一地址的锁观察持有锁的线程是否在变化。如果锁的持有者在多个线程之间“游走”且大量线程排队等待说明锁竞争严重如果固定永远由一个线程持有且不释放那更可能是不正常的长锁或死锁。4.2 别把线程Dump当唯一证据要三源交叉验证线程Dump不能直接告诉我们“为什么”CPU高只能告诉我们“在哪个线程、哪段代码上”。要形成完整闭环我会同时收集以下几类信息top / top -Hp确认哪个线程消耗CPU高jstat -gcutil确认是否GC压力太大业务日志确认调用方报错时间点和接口耗时如果有条件再抓一份jmap -heap看堆内存占用。举个例子Dump看到大量业务线程WAITING在AbstractQueuedSynchronizer.park同时GC日志里显示每次Full GC后线程开始堆积。真相可能是GC导致STW太久而不是业务锁问题。只靠一张Dump很容易把同一个现象归因到错误的根因上。4.3 过滤线程池/框架线程先看业务线程一个大型应用Tomcat线程、Netty事件循环、GC线程、心跳线程、监控线程加一起就有上百个。新手容易看到满屏线程头就慌了。建议按“业务 → 中间件 → 基础线程”的优先级来读先看业务线程名比如订单服务里的order-*、HTTP线程里的http-nio-8080-exec-*再看中间件线程池名比如pool-1-thread-*一般是用Executors创建的线程池默认名称最后才关心GC线程、Compiler线程、Signal Dispatcher等系统线程。如果Dump文件巨大可以用文本工具做初步统计# 统计各线程状态数量 grep -E java.lang.Thread.State thread.dump | awk -F : {print $2} | sort | uniq -c # 提取所有 http 业务线程的调用栈前5行 grep -A 5 http-nio-8080-exec- thread.dump | head -100用awk统计状态分布可以快速判断BLOCKED数量多则锁竞争WAITING多则有大量等待RUNNABLE且堆栈在业务代码则可能是循环计算或GC。先缩小范围再人工精读效率会高很多。4.4 在线工具和Arthas怎么配合手读Dump熟练后也可以借助工具提高效率。常见方式是把Dump文本粘贴到线程分析站点比如FastThread等它们会做状态统计、锁关联提示、死锁检测。这类工具适合团队新人快速上手。但要注意脱敏Dump里可能会出现完整的业务类名和方法名别把生产环境的Dump直接丢给不信任的外部站点。本地更实用的是Arthas。它能在线上直接执行类似命令# 查看所有线程概况 thread # 查看某个线程的调用栈 thread id # 查看当前阻塞其他线程的线程 thread -bthread -b能直接找出阻塞其他线程的线程比人肉翻Dump快得多。不过Arthas作为诊断工具本身会attach到JVM和jstack类似也要注意生产环境的权限与安全策略不要在核心交易链路高峰随意操作。4.5 抓Dump也要考虑代价不要滥用jstack在执行时会请求JVM线程安全点可能给正在运行的服务带来微小停顿。大多数情况下影响很小但如果JVM正处于频繁GC或Full GC中抓Dump可能会放大暂停时间甚至导致更长的STW。线上高峰期、核心交易链路原则上减少Dump次数如果一定要抓选在问题持续且流量可容忍的窗口抓完立刻保存退出。另外如果JVM进程完全hang住jstack命令本身也可能卡住或失败。此时优先看系统层top、strace或者考虑jstack -F强制模式。还有一个容易忽略的点不要把线程Dump文件无脑收集。我见过一台机器因为脚本每30秒抓一次jstack短短一天生成了几百MB文本最后把磁盘打满了。抓Dump要有目的性抓完及时清理归档别把线上环境变成日志回收站。4.6 常见问题速查一眼判断可能症状看到的现象大概率原因下一步动作大量BLOCKED 同一lock地址锁竞争/同步块临界区太大缩小同步范围、减少锁粒度Dump末尾出现Found deadlock确实发生JVM级死锁修复加锁顺序用并发工具类大量WAITING在park/Object.wait业务在等待条件或线程池空闲确认等待逻辑排查条件是否触发大量RUNNABLE且堆栈在Socket.read网络IO或下游慢线程在等待数据查下游、网络吞吐、超时设置RUNNABLE堆栈在本地业务代码且CPU高死循环/算法/正则/解析问题打印日志加统计点定位线程名GC thread堆栈在VM内GC压力大看GC日志、堆内存、调堆参数大量线程卡在getConnection连接池耗尽查慢SQL、连接泄漏、池大小http-nio-8080-exec-*全部WAITINGTomcat线程全部被占用查调用链耗时、线程池配置表格只是提供思路实际判断还要结合调用栈的具体位置和业务上下文。最后分享一点我个人的真实体会。我开始读线程Dump时习惯拿教科书式的状态定义去套一看到WAITING就觉得线程有问题后来才发现线程池里的空闲线程WAITING很正常。真正有效的方法是先把目标问题想清楚——是接口慢、CPU高还是假死再决定抓哪几份Dump、怎么过滤线程、看哪些关键信息。排查线程问题大多数时候不是靠更高级的工具而是靠对“线程状态 调用栈 锁信息”这三要素的敏感度。多读几次真实线上Dump自然就有手感了。下次再遇到上线后莫名其妙的卡顿别急着发版本先jstack -l pid看一眼线程比你更清楚问题在哪。