JStack 排查指南:从线程状态到根因定位的通用路线


适用场景

当 Java 服务出现以下问题时,jstack 往往是最直接的入口之一。

  • 接口 RT 飙升
  • 请求大量超时
  • CPU 异常升高
  • 线程数暴涨
  • 服务假死
  • 某些任务长时间不执行
  • 怀疑死锁、锁竞争、外部依赖阻塞

这份指南不依赖具体业务,目标是给出一套通用、可复用的分析路线。

先建立一个基本认知

jstack 不是看某一个线程在干什么的工具,核心用途是

通过线程状态、线程分组、调用栈共性和锁关系,判断系统现在卡在哪一类问题上。

看 jstack 最忌讳两件事。

  1. 拿到 dump 就从第一条线程开始逐行看
  2. 看到很多 RUNNABLE 就直接下结论说 CPU 打满

正确方式是先分类,再归因,再收敛。整条路线画出来是这样,后面各节就是按图上的顺序展开。

flowchart TD
    A[拿到 dump] --> B{搜到 deadlock?}
    B -- 有 --> C[优先处理死锁]
    B -- 没有 --> D[统计线程状态分布<br/>按线程名前缀聚类]
    D --> E{最多的那类线程<br/>卡在什么栈顶?}
    E -- waiting to lock --> F[锁路线<br/>搜锁地址找持锁人]
    E -- RUNNABLE 挂在网络读 --> G[下游路线<br/>定位慢依赖]
    E -- park/take 等队列 --> H[池路线<br/>区分空闲与堵死]
    E -- 业务方法在栈顶 --> M[按模块归因]
    F --> I[沿调用链回溯到入口与下游]
    G --> I
    H --> I
    M --> I
    I --> J[区分触发点与放大器]
    J --> K[写出可验证的结论]

一、先明确采集方式

如果现场还在,优先不要只采一份线程栈。建议至少采 3 份。

jstack <pid> > jstack.1.log
sleep 10
jstack <pid> > jstack.2.log
sleep 10
jstack <pid> > jstack.3.log

或者

jcmd <pid> Thread.print > jstack.log

为什么要多份。单份只能看到某个瞬时切面,多份才能判断线程是持续卡住,还是正在流动。如果同一批线程在多份 dump 中都卡在同一位置,证据强度会高很多。

虚拟线程的采集盲区(JDK 21+)

普通 jstack 看不到虚拟线程。构造复现实测(OpenJDK 21.0.9,起 100 个阻塞在 Thread.sleep 的虚拟线程):

  • jstack <pid> 输出里只有 40 条平台线程,100 个虚拟线程一条都不出现,唯一的踪迹是一条名为 VirtualThread-unparker 的守护线程
  • jcmd <pid> Thread.dump_to_file -format=json /tmp/td.json 输出总计 126 条线程,每个虚拟线程的栈完整可见
{
  "tid": "28",
  "name": "",
  "stack": [
    "java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:635)",
    "java.base/java.lang.VirtualThread.sleepNanos(VirtualThread.java:807)",
    "java.base/java.lang.Thread.sleep(Thread.java:507)",
    "VthreadDemo.lambda$main$0(VthreadDemo.java:6)",
    "java.base/java.lang.VirtualThread.run(VirtualThread.java:329)"
  ]
}

排查虚拟线程应用时,jstack 里一片安静不等于没有堆积,采集命令要换成 jcmd <pid> Thread.dump_to_file。注意虚拟线程默认没有名字("name": ""),按线程名前缀聚类的方法对它们失效,只能按栈顶聚类。

二、排查前先同步外围信息

不要孤立看 jstack,建议同时拿到下面几类信息。

  • 故障时间点
  • 应用日志中的报错、超时、慢日志
  • CPU、Load、内存、GC 情况
  • 当前线程总数
  • 连接池、数据库、Redis、MQ、HTTP 下游监控

常用配套命令

top -Hp <pid>
ps -mp <pid> -o THREAD,tid,time
jstat -gcutil <pid> 1000 10
jcmd <pid> GC.class_histogram

目的不是多收集,而是避免误判。

  • jstack 看起来像 CPU 问题,结果是 I/O 阻塞
  • jstack 看起来像线程池满,结果是 Full GC 卡顿
  • jstack 看起来像业务死锁,结果只是数据库连接池耗尽

三、第一步:先判断故障大类

拿到 dump 后,先回答下面 5 个问题。

  1. 有没有死锁
  2. 哪类线程最多
  3. 哪种状态最多
  4. 最多的那类线程在干什么
  5. 是触发点问题,还是放大器问题

1. 有没有死锁

先搜索

Found one Java-level deadlock

如果有,优先处理死锁。但要注意,没看到 deadlock 不代表没有卡死,很多线上卡住并不是死锁,而是 I/O 阻塞、连接池耗尽、锁竞争、线程池排队。

2. 看线程状态分布

常见状态含义

  • RUNNABLE,不等于一定在耗 CPU。网络读、native 调用、epoll 等也可能显示成 RUNNABLE
  • BLOCKED,等待进入 synchronized 监视器,常见于锁竞争
  • WAITING,无限等待,常见于 park、wait、队列阻塞
  • TIMED_WAITING,带超时的等待,常见于线程池空闲、sleep、poll、定时任务

经验上,BLOCKED 特别多先看锁竞争;RUNNABLE 特别多且栈顶一致先看 CPU 热点或 I/O 阻塞;WAITING/TIMED_WAITING 多但线程池线程正常空闲,不一定有问题。

3. 先按线程名前缀聚类

不要逐条线程看,先按线程名做分组。常见线程名前缀及方向

  • http-nio-*,Tomcat 请求处理线程
  • qtp*,Jetty 线程
  • ForkJoinPool-*,并行任务池
  • boundedElastic-* / elastic-*,Reactor 阻塞任务线程池
  • pool-*,通用线程池,需要结合调用栈判断
  • HikariPool-* / Druid-*,连接池维护线程
  • kafka-* / consumer-*,MQ 消费线程
  • lettuce-* / redisson-*,Redis 相关线程
  • OkHttp* / grpc-*,HTTP/gRPC 客户端线程

聚类的目的,是快速找到哪一类线程堆积最多、哪类线程最异常、哪类线程最像根因路径。

四、第二步:找到共性栈顶

真正有效的分析,不是找一个最怪的线程,而是找最多线程的共同阻塞点。

重点看每个线程的

  • java.lang.Thread.State
  • 栈顶前 5 到 20 行
  • 是否有 waiting to lock
  • 是否有 parking to wait for
  • 是否有 locked <0x...>

常见共性栈顶及含义如下。

1. java.net.SocketInputStream.socketRead0

通常表示等待 HTTP / RPC / 数据库 / Redis / MQ 对端响应、外部依赖慢、网络链路抖动、读超时设置过大或未生效。这是最常见的线上线程堆积根因之一。

2. sun.misc.Unsafe.park

不能直接判定有问题。常见两种情况,线程池空闲属正常 park,等锁、等条件、等 Future 属异常 park。要继续看下面是不是

  • AbstractQueuedSynchronizer
  • SynchronousQueue
  • LinkedBlockingQueue
  • FutureTask.get
  • CountDownLatch.await

3. Object.wait

同样不能直接说有问题。常见于线程池工作线程空闲、连接池维护线程等待、消费线程等待任务、某个同步流程长期等待通知。

4. epollWait / accept / poll

通常是网络框架线程空闲等待事件,不一定有问题。例如 Netty EventLoop、Tomcat Poller、Redis 客户端网络线程。

5. 业务方法位于栈顶附近

如果大量线程都卡在同一个业务方法附近,比如

com.xxx.service.OrderService.submit
com.xxx.dao.OrderDao.query
com.xxx.client.UserClient.getUser

就说明已经可以按模块归因。

6. 三段真实 dump:上面这些签名长什么样

说明:下面的场景是人为构造的,但每一行输出都是 jstack 的原样输出,未做任何编辑。复现程序见本节末尾。

光认函数名不够,得见过真实输出。下面三段来自同一个构造进程的同一份 dump(Temurin JDK 1.8.0_211,macOS),覆盖前面讲的三类签名。

第一段,锁竞争。 一个线程在锁内 sleep(模拟锁内慢操作),四个线程争同一把锁:

"order-worker-0" #19 prio=5 os_prio=31 tid=0x00007f8c3f820800 nid=0x8727 waiting for monitor entry [0x000000030aa9a000]
   java.lang.Thread.State: BLOCKED (on object monitor)
    at JstackDemo$2.run(JstackDemo.java:24)
    - waiting to lock <0x000000066ab68f50> (a java.lang.Object)
    at java.lang.Thread.run(Thread.java:748)

按第九节的方法拿锁地址 0x000000066ab68f50 反搜 locked,一步找到持锁人,还能看到它正在锁内 sleep:

"order-lock-holder" #18 prio=5 os_prio=31 tid=0x00007f8c5086b800 nid=0xa703 waiting on condition [0x000000030a997000]
   java.lang.Thread.State: TIMED_WAITING (sleeping)
    at java.lang.Thread.sleep(Native Method)
    at JstackDemo$1.run(JstackDemo.java:17)
    - locked <0x000000066ab68f50> (a java.lang.Object)
    at java.lang.Thread.run(Thread.java:748)

第二段,网络阻塞读。 这个线程已经在一个不回包的连接上干等,状态却是 RUNNABLE,这就是「RUNNABLE 不等于耗 CPU」的实物:

"http-client-0" #24 prio=5 os_prio=31 tid=0x00007f8c4f82f000 nid=0x8f03 runnable [0x000000030afa9000]
   java.lang.Thread.State: RUNNABLE
    at java.net.SocketInputStream.socketRead0(Native Method)
    at java.net.SocketInputStream.socketRead(SocketInputStream.java:116)
    at java.net.SocketInputStream.read(SocketInputStream.java:171)
    ...

第三段,空闲线程池。 WAITING (parking) 加 LinkedBlockingQueue.take 加 getTask,三个特征齐了,就是池子在正常等任务,不是故障:

"pool-1-thread-1" #27 prio=5 os_prio=31 tid=0x00007f8c51833000 nid=0x9c03 waiting on condition [0x000000030b2b2000]
   java.lang.Thread.State: WAITING (parking)
    at sun.misc.Unsafe.park(Native Method)
    - parking to wait for  <0x000000066ab796f8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
    at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
    at java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442)
    at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1074)
    ...

复现程序是 60 行的 JstackDemo.java,三个场景:order-lock-holder 在 synchronized 块内 sleep(600000),四个 order-worker 争同一把锁;本地起一个只 accept 不回数据的 ServerSocket,三个 http-client 阻塞在 read();一个跑完任务的 FixedThreadPool(3)。

7. 同一段代码,JDK 8 和 JDK 21 的栈签名不一样

说明:同一个复现程序分别跑在 Temurin 8u211 和 OpenJDK 21.0.9 上,输出均为原样。

把上面的复现程序原样换到 OpenJDK 21.0.9 再抓一次,三处签名变了,两处直接影响排查手法。

网络阻塞读的栈顶换了名字。 JDK 8 的 socketRead0 在 21 的 dump 里零命中(grep -c socketRead0 = 0),同一个阻塞读长这样:

"http-client-0" #34 [42243] prio=5 os_prio=31 cpu=0.80ms elapsed=3.02s tid=0x000000078b211c00 nid=42243 runnable  [0x000000016e9ea000]
   java.lang.Thread.State: RUNNABLE
    at sun.nio.ch.SocketDispatcher.read0(java.base@21.0.9/Native Method)
    at sun.nio.ch.SocketDispatcher.read(java.base@21.0.9/SocketDispatcher.java:47)
    at sun.nio.ch.NioSocketImpl.tryRead(java.base@21.0.9/NioSocketImpl.java:256)
    at sun.nio.ch.NioSocketImpl.implRead(java.base@21.0.9/NioSocketImpl.java:307)
    at java.net.Socket$SocketInputStream.read(java.base@21.0.9/Socket.java:1099)
    ...

背景是 JDK 13 的 JEP 353 把老的 PlainSocketImpl 换成了 NioSocketImpl(文末参考)。在新版本上搜 socketRead0 找网络阻塞会一无所获,聚类关键词要换成 NioSocketImpl 或 SocketDispatcher.read0,「RUNNABLE 挂在网络读上」这个判断本身不变。

park 的包名换了。 空闲线程池在 21 上是 jdk.internal.misc.Unsafe.park,不再是 sun.misc.Unsafe.park(JDK 9 模块化时挪的),后续 LockSupport.park 到队列 take 的链路不变:

"pool-1-thread-1" #37 [40963] prio=5 os_prio=31 cpu=0.43ms elapsed=3.02s tid=0x000000078b213100 nid=40963 waiting on condition  [0x000000016f00e000]
   java.lang.Thread.State: WAITING (parking)
    at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method)
    - parking to wait for  <0x0000000b04859598> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
    at java.util.concurrent.locks.LockSupport.park(java.base@21.0.9/LockSupport.java:371)
    ...

线程行的 nid 变成十进制了。 JDK 8 是 nid=0x8f03(十六进制),21 是 #34 [42243] … nid=42243,方括号里多了系统线程号,nid 直接是十进制。第八节那步 printf '%x' 转换在 21 上不需要了,top -Hp 看到的线程号直接就能搜。另外 21 的线程行自带 cpu=0.80ms elapsed=3.02s,粗看谁在烧 CPU 可以直接扫这个字段。

方法不随版本变,签名随版本变。在不熟悉的 JDK 版本上排查,先抓一份正常状态的 dump 看这三类签名长什么样,再分析故障 dump。

五、第三步:沿调用链回溯,定位入口和下游

当你找到共性阻塞点后,不要停在基础设施层,要继续向上看。

  1. 请求入口是谁
  2. 经过了哪层 service/facade/handler
  3. 最终调用了哪个 DAO / HTTP client / RPC client / MQ client
  4. 阻塞发生在计算、锁、I/O 还是等待结果

一个完整归因链最好能写成下面这种格式。

Controller -> Service -> Facade -> Client / DAO -> Framework 层 -> Native / Socket / Lock / Wait

当这条链能稳定写出来时,问题通常已经不再是线程很多,而是某个 SQL 卡住、某个下游接口超时、某个锁竞争严重、某个线程池被堵死、某个批处理任务拿着锁不放。

六、第四步:区分触发点和放大器

很多事故的直接根因和最终故障表现不是一回事。举几个常见例子。

例子 1,下游 HTTP 很慢。 触发点是某个外部 HTTP 接口超时,放大器是线程池无界扩容、重试过多、没有熔断。

例子 2,数据库慢 SQL。 触发点是某条 SQL 执行极慢,放大器是连接池过小、调用方堆积、上游重试。

例子 3,锁竞争。 触发点是一个大锁持有时间过长,放大器是高并发全部打到同一临界区。

例子 4,消息消费阻塞。 触发点是消费逻辑调用慢下游,放大器是消费线程数少、重平衡、积压扩大。

做复盘时,必须把两层都写出来。否则很容易只修触发点,不修放大器,下次还会出同样事故。

七、常见问题模式速查

下面是一份比较通用的模式表。

模式 1:大量线程 BLOCKED

表现是很多线程出现 waiting to lock,都指向同一个对象地址或同一个业务方法附近。

高概率方向:synchronized 大锁、锁顺序不一致、某个慢操作放在锁内、锁内做 I/O。

排查重点:谁持有锁、锁持有线程在干什么、锁内有没有远程调用、数据库、sleep、大循环。

模式 2:大量线程卡在 socketRead0

表现是大量 RUNNABLE,栈顶一致是网络读,往上能追到 HTTP/RPC/DB Client。

高概率方向:下游慢响应、网络抖动、TLS 握手慢、连接复用异常、超时配置不合理。

排查重点:哪个下游最多、是否集中在某个接口、连接超时和读超时是否生效、是否有重试放大。

模式 3:大量线程卡在连接池获取

表现是 getConnection、borrowObject、take、pool.await。

高概率方向:数据库连接池耗尽、Redis 连接池耗尽、下游连接泄漏、慢 SQL 导致连接长时间不归还。

排查重点:活跃连接数、等待队列长度、慢 SQL、是否存在连接泄漏日志。

模式 4:大量线程卡在 Future.get / CountDownLatch.await

表现是线程都在等异步结果,上游线程被同步等待拖住。

高概率方向:异步任务本身卡住、线程池没有线程执行任务、任务间互相依赖、将异步写成了同步阻塞。

排查重点:被等待的任务跑在哪个线程池、线程池是否满、是否出现线程池互相等待。

模式 5:线程很多,但大多在 park / poll / wait

表现是看起来线程很多,实际大部分线程是空闲的。

高概率方向:不一定有故障,可能只是线程池配置偏大。

排查重点:真正忙的线程是哪一类、CPU 是否真的高、请求是否真的积压。

模式 6:GC 相关线程异常显眼

表现是 VM Thread、GC Thread、Finalizer、Reference Handler 显眼。注意它们出现并不代表 GC 就是根因,需要结合 GC 日志和 jstat。

高概率方向:Full GC 频繁、内存压力大、对象分配速率过高、某类对象暴涨。

八、如何判断是不是 CPU 问题

jstack 单独并不擅长精确判断 CPU 热点,但可以辅助。排查路线

  1. 用 top -Hp <pid> 找出高 CPU 线程
  2. 把线程 ID 转成十六进制
  3. 在 jstack 中搜索对应 nid=0x...(线程栈里的 nid 就是十六进制的系统线程号)
  4. 看它在执行什么

例如

printf '%x\n' 12345

注意版本差异:这套十六进制转换只适用于 JDK 8 系的 dump。OpenJDK 21 的线程行里 nid 直接是十进制(见第四节的版本差异小节),top -Hp 看到的线程号不用转换就能搜。

高 CPU 线程常见表现:大量业务计算、JSON 序列化/反序列化、正则、死循环、自旋锁、大量对象创建、热点 SQL 结果集处理。

如果高 CPU 线程的栈在多份 dump 中几乎不变,说明它很可能真的卡在热点计算上。

九、如何判断是不是锁问题

锁问题的关键不是有没有 BLOCKED,而是有没有很多线程等同一把锁、持锁线程是谁、持锁线程在锁里做什么。

排查步骤

  1. 搜 waiting to lock
  2. 记下锁对象地址 <0x...>
  3. 搜谁 locked <0x...>
  4. 找到持锁线程的业务栈

一旦发现持锁线程在做数据库操作、在做远程调用、在 sleep、在大循环,基本可以判定是设计问题。第四节第 6 小节的第一段 dump 就是一个完整的实例。

十、如何判断是不是线程池问题

线程池问题有三种常见形态。

形态 1,线程池线程都在忙。 同一线程名前缀很多,大量线程栈相似,都在执行阻塞或慢调用。方向是线程池被慢任务打满。

形态 2,线程池线程很少,但很多业务线程在等结果。 上层大量 Future.get,下层线程池很小。方向是线程池容量不足,或者任务排队严重。

形态 3,线程池线程不断增长。 elastic-*、pool-* 编号快速上升,线程数持续增加。方向是无界扩容承载了持续阻塞任务。

十一、如何把结论写清楚

好的结论不是「很多线程阻塞了」,而应该能回答 4 个问题:谁卡住了、卡在哪、为什么会卡、为什么会被放大。

推荐的结论模板

根因是 [某模块/某下游/某锁] 导致 [哪类线程] 长时间阻塞在 [哪个调用点];
由于 [线程池策略/重试/缺少超时/缺少隔离],阻塞持续放大,最终表现为 [线程堆积/RT 飙升/CPU 异常/请求超时]。

示例

根因是订单服务调用用户中心接口慢,导致 http-nio 工作线程长期阻塞在 socketRead0;
由于调用链没有超时降级,且业务线程池容量有限,阻塞逐步放大,最终表现为接口大量超时。

十二、推荐的一套标准排查顺序

如果你要把它变成团队 SOP,可以直接用下面这套顺序。

  1. 采 3 份线程栈,间隔 5 到 10 秒
  2. 同时采 CPU、GC、线程数、连接池信息
  3. 看是否存在死锁
  4. 统计线程状态分布
  5. 按线程名前缀聚类
  6. 找最多线程的共性栈顶
  7. 沿调用链回溯到入口、模块、下游
  8. 识别触发点和放大器
  9. 用日志和监控验证推断
  10. 给出根因、影响面、止血方案、长期治理方案

十三、最后给几个经验判断

经验 1:很多 RUNNABLE 不等于 CPU 高。先看是不是 socketRead0(JDK 11+ 是 NioSocketImpl)。

经验 2:很多 WAITING/TIMED_WAITING 不等于系统卡死。先区分是空闲等待,还是异常等待。

经验 3:线程多不是根因,线程多通常只是结果。

经验 4:如果大量线程都指向同一个下游、同一个 SQL、同一个锁,问题基本就已经收敛了。

经验 5:单点慢通常不会直接打垮系统,真正把故障放大的,往往是无超时、无隔离、无熔断、无界线程池、重试风暴、锁内做慢操作。

十四、一句话总结

jstack 排查的核心不是读懂每一条线程,而是先通过状态和线程分组判断问题类型,再用共性调用栈锁定阻塞点,最后把触发点和放大器连成完整因果链。

当你能把一份 dump 解释成

哪类线程 -> 因为什么动作 -> 卡在什么位置 -> 被什么机制放大 -> 最终造成什么故障

这份 jstack 基本就算分析到位了。

参考

相关文章

快来做第一个评论的人吧~