服务 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. 自测
- 为什么 JVM 的
RUNNABLE不等于"在消耗 CPU"?举两个典型栈顶。 - 线程池里大量
WAITING是健康还是异常?判据是什么? BLOCKED与WAITING的区别是什么?为什么BLOCKED必须看 owner 链?- JVM 会自动检测哪类死锁、漏掉哪类?靠什么手段补上?
- 为什么单份转储几乎没有诊断价值?三份差集能回答什么?
- GC 的 STW 为什么会让线程转储"看起来正常"?
9. 一句话总结
线程转储回答"此刻谁在等什么",不是"为什么慢":RUNNABLE 可能趴在 socketRead0 上干等,WAITING 在 getTask() 里恰恰是健康,只有 BLOCKED 才是锁竞争且必须顺着 owner 链追到根。死锁只能靠多份转储的差集补上(JVM 只自动报 monitor 死锁),而 STW 期间所有线程"状态正常"——所以顺序是:先排除"等",再用火焰图找"算"。