2246 字
约 7 分钟
13
S05-A28 · 线程转储:为什么"卡住"的线程看起来都很正常
2024-12-31
2026-11-12

服务 P99 从 200 ms 涨到 3 s,日志里没有异常,CPU 也不高。jstack 打了一份转储,200 个线程里 178 个是 WAITING (parking)、20 个 RUNNABLE、2 个 TIMED_WAITING——看上去"没有卡住"。于是排查方向转到下游、转到网络、转到数据库,绕了两天才发现:那 20 个 RUNNABLE 里有 18 个趴在 socketRead0 上等下游响应。

线程转储最难的不是拿到它,而是读对它。五种状态里,最像"卡住"的(BLOCKED)往往不是元凶;最像"在干活"的(RUNNABLE),反而可能是干等。


1. 五种状态,四种误读

状态 字面含义 常见误读
RUNNABLE 在 JVM 里可运行 以为一定在跑 CPU
BLOCKED 等 monitor 锁 以为一定是性能瓶颈
WAITING 无限等(park/wait) 以为是"卡死"
TIMED_WAITING 限时等 以为无害
NEW / TERMINATED 未启动 / 已结束 少见但要注意

第一行的误读是全部混乱的源头:JVM 的 RUNNABLE 包含"正在进行 native 调用",比如读 socket、读文件、Thread.sleep 之外的系统调用。所以"RUNNABLE"并不等于"在用 CPU"。

// 三种 RUNNABLE,含义完全不同
java.lang.Thread.State: RUNNABLE
  at java.net.SocketInputStream.socketRead0(Native Method)   ← 在等网络(最像"卡住")
  at com.acme.OrderService.calc(OrderService.java:88)        ← 真的在算
  at sun.nio.ch.EPollArrayWrapper.epollWait(Native Method)   ← 在等 IO 事件

2. 线程池里的 WAITING 是健康信号

ThreadPoolExecutor 的空闲线程就停在 getTask() → workQueue.take() → LockSupport.park()。所以一个正常的池子里,大部分线程应该是 WAITING。

现象 含义 结论
N 个线程都 WAITING 在 getTask 池里线程数 > 并发任务数 健康(线程开多了)
大量 WAITING 在 pool-1-thread 但队列积压 池被别的阻塞点卡住 要看栈顶
全部 WAITING 在业务代码的 Future.get() 在等异步结果 是瓶颈(等下游)

第二行的判据是:WAITING 的位置决定它是否健康。停在 getTask 是空闲;停在 CompletableFuture.get / CountDownLatch.await / Semaphore.acquire 是等待,而等待的对象可能就是问题所在。


3. RUNNABLE 的三种真实含义

栈顶 实际在干什么 是否消耗 CPU
socketRead0 / epollWait 等下游/等 IO 否
业务方法(calc/parse) 真在计算 是
死循环(同一行反复出现) 逻辑 bug 是,且打满单核

第一行是最常见的"看起来正常":线程状态是 RUNNABLE、CPU 不高、也没有报错——因为它在等,而等的超时还没到。这类线程的数量才是关键:如果 20 个业务线程里 18 个都在 socketRead0,那问题一定在下游,而不在本服务。

// 一次转储里,同一栈顶出现的线程数就是权重
18 ×  j.n.SocketInputStream.socketRead0
      com.acme.client.PricingClient.quote(PricingClient.java:42)
 2 ×  c.a.OrderService.calc(OrderService.java:88)      ← 真正在算的只有 2 个
// 结论:并发能力被下游拖住,不是本服务的 CPU 问题

4. BLOCKED 才是锁竞争,且要看 owner 链

BLOCKED 表示"在等一个 monitor"。它天然带两个地址:自己等的锁、以及锁被谁持有。

转储片段 含义
- waiting to lock <0x1a> 我在等 0x1a
- locked <0x1b> 我持有 0x1b
- waiting on <0x1c> 我在 wait()(WAITING,不是 BLOCKED)

把"等 0x1a"的线程与"持有 0x1a"的线程对上,就得到一条链。链只要成形,就能算出"谁真正卡住了整条链"——而那个人往往不在最前面。

// 一条命令把 BLOCKED 与锁 owner 对上(awk 足够)
$ jstack <pid> > dump.txt
$ grep -n "waiting to lock" dump.txt | head
$ grep -n "locked <0x1a>" dump.txt            # 找持有者
// 若持有者自己也在 waiting to lock <0x1d>,就继续往下追,直到栈顶是 RUNNABLE 或 IO

5. 死锁:JVM 只报一半

JVM 会自动检测并打印 Found one Java-level deadlock,但它只检测 monitor(synchronized)死锁。

死锁类型 JVM 会自动报吗
synchronized 互相等待 会(转储末尾单独一段)
ReentrantLock / Semaphore 互相等待 不会
线程池互相等待(A 等 B 的任务,B 等 A 的任务) 不会
Future.get() 循环依赖 不会

第二、三行要靠"多份转储对比"发现:同样的等待关系在三次采样里都不变,就是死锁;只是偶发出现,才是竞争或超时。这也是为什么单份转储几乎没有诊断价值。


5.1 采样策略:一次快照看不出趋势

策略 做法 能回答什么
单次转储 jstack 一次 线程在等什么(静态)
三次间隔 5 s 连续三份,做差集 谁在动、谁一直不动
高频采样(100 ms × 30) async-profiler / jstack -l 循环 热点与锁竞争比例
与 GC 日志对齐 同时间段比对 是不是 STW 造成的"假卡住"

第四行常被忽略:GC 的 Stop-The-World 期间,所有应用线程都停住,而转储里它们的状态看起来完全正常。如果只盯着线程不看 GC,就会得出"线程没问题"的错误结论。

// 三份转储的差集:三次都一样 = 真的卡住
$ for i in 1 2 3; do jstack -l $PID > dump-$i.txt; sleep 5; done
$ for f in dump-*.txt; do grep -c "socketRead0" $f; done
// 三次都是 18 → 稳定卡在同一个下游;如果 12/5/20 → 是波动,不是死锁

6. 排错:五种"线程看起来很健康"

现象 八成原因 处理方向
大量 WAITING 但请求超时 等在 Future.get/信号量 查被等的下游
全是 RUNNABLE、CPU 低 在 native IO 等待 看栈顶是不是 socketRead
BLOCKED 数量随负载上升 锁粒度太粗 看 owner 链,缩临界区
三份转储完全一样 真死锁(可能非 monitor) 找环形等待
线程数正常但吞吐低 线程池被少数慢请求占满 隔离池 / 加超时

第五行是"线程池最常见的一种失效":线程数够、队列不深,但每个线程都被一个慢请求占住 30 秒,于是并发能力被少数请求吃掉。此时转储里看到的是"18 个 socketRead0",而不是"队列积压"。


7. 边界:什么时候别在线程上找原因

场景 该看什么 为什么
所有线程状态都正常但整体慢 GC 日志 / safepoint STW 期间状态正常
CPU 高且 RUNNABLE 全是业务代码 火焰图(async-profiler) 需要采样热点,不是快照
内存涨、GC 频繁 堆直方图 / MAT 线程状态无关
只有个别请求慢 链路追踪 全局转储看不出个体
偶发几分钟一次 定时任务 / 锁竞争 需要连续采样
// 一条排查路径:从转储出发,30 秒决定下一步
> 50% 线程在 socketRead   → 查下游,本服务不用改
> 20% BLOCKED             → 追 owner 链,缩小临界区
三份转储完全相同            → 死锁,找环形等待
状态全正常但整体慢          → 转去 GC 日志 / 火焰图
// 做完这一步,剩下的时间才花在对的地方

判断口径一句话:线程转储回答的是"此刻谁在等什么",不是"为什么慢"——它擅长定位"卡在哪个调用",不擅长定位"CPU 花在哪"。所以正确用法是先用它排除"等",再用火焰图找"算",两者顺序反了会浪费大量时间。


8. 自测

  1. 为什么 JVM 的 RUNNABLE 不等于"在消耗 CPU"?举两个典型栈顶。
  2. 线程池里大量 WAITING 是健康还是异常?判据是什么?
  3. BLOCKED 与 WAITING 的区别是什么?为什么 BLOCKED 必须看 owner 链?
  4. JVM 会自动检测哪类死锁、漏掉哪类?靠什么手段补上?
  5. 为什么单份转储几乎没有诊断价值?三份差集能回答什么?
  6. GC 的 STW 为什么会让线程转储"看起来正常"?

9. 一句话总结

线程转储回答"此刻谁在等什么",不是"为什么慢":RUNNABLE 可能趴在 socketRead0 上干等,WAITING 在 getTask() 里恰恰是健康,只有 BLOCKED 才是锁竞争且必须顺着 owner 链追到根。死锁只能靠多份转储的差集补上(JVM 只自动报 monitor 死锁),而 STW 期间所有线程"状态正常"——所以顺序是:先排除"等",再用火焰图找"算"。

S05-A28 · 线程转储:为什么"卡住"的线程看起来都很正常
https://www.clxhxhhr.top/posts/6747/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。