6.4 线程与协程快照
上一节:6.3 火焰图读法 | 下一节:6.5 瓶颈模式库(一):池化类 配套代码:06-analysis-and-profiling/04-thread-and-coroutine-dumps
一句话结论
一张线程快照没有价值,连续多张才有。 单张快照告诉你「此刻谁在哪」(可能只是路过),多张对比才能看出「谁一直卡在那里」。这就是「瞬时状态」与「持续问题」的区别。
一、用「工厂巡检拍照」理解快照
你去工厂检查为什么产线慢了。拍一张照片:
- 照片里 3 个工人站着不动。
- 但这可能只是巧合——他们可能刚好在等下一个零件。
连拍 5 张(每 3 秒一张):
- 5 张照片里,同样的 3 个工人站在同一个位置不动。
- 而其他工人都在移动。
- 结论:这 3 个工人被卡住了。
线程快照完全一样:单张是「瞬时状态」,多张才能识别「持续阻塞」。
二、线程状态的五种含义
| 状态 | 含义 | 大量出现说明 |
|---|---|---|
RUNNABLE |
正在运行或等待 CPU | CPU 饱和,或在自旋 |
BLOCKED (on object monitor) |
等 monitor 锁 | 锁竞争 |
WAITING (parking) |
无限期等待(LockSupport.park) |
等锁、等队列、等任务 |
TIMED_WAITING |
有时限等待(sleep、await(timeout)) |
有超时等待(可能是重试退避) |
WAITING (on object monitor) |
Object.wait() |
等条件变量 |
关键栈特征速查
| 栈里出现 | 含义 |
|---|---|
HikariPool.getConnection |
连接池排队 |
socketRead0 / socketWrite0 |
等网络(数据库/下游) |
Unsafe.park / LockSupport.park |
等锁或等任务 |
LinkedBlockingQueue.take |
线程池空闲(正常)或队列空 |
ArrayBlockingQueue.put |
队列满,生产者被阻塞(背压生效) |
Object.wait |
等条件变量 |
Thread.sleep |
主动睡眠(重试退避或轮询) |
FileOutputStream.write |
同步日志刷盘 |
classloader 相关 |
类加载锁(启动期常见) |
三、多个快照的对比方法
方法一:统计「持续存在」的栈
PID=$(jcmd | grep app.jar | awk '{print $1}')
# 连续取 5 份,间隔 3 秒
for i in $(seq 1 5); do
jcmd "$PID" Thread.print > "threads-$i.txt"
sleep 3
done
# 统计哪些栈在【所有快照】里都出现
comm -12 <(grep -E '^\s+at ' threads-1.txt | sort -u) \
<(grep -E '^\s+at ' threads-2.txt | sort -u) | \
comm -12 - <(grep -E '^\s+at ' threads-3.txt | sort -u) | \
comm -12 - <(grep -E '^\s+at ' threads-4.txt | sort -u) | \
comm -12 - <(grep -E '^\s+at ' threads-5.txt | sort -u) | \
sort | uniq -c | sort -rn | head -20
输出示例:
47 at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:196)
47 at com.example.repo.OrderRepository.findById(OrderRepository.kt:42)
47 at com.example.service.OrderService.get(OrderService.kt:28)
解读:47 个线程在所有 5 份快照里都停在同一处 → 持续阻塞在获取数据库连接上。
方法二:统计状态分布的变化
for f in threads-*.txt; do
echo "── $f ──"
grep -oE "java.lang.Thread.State: [A-Z_]+" "$f" | sort | uniq -c | sort -rn
done
判读:
| 分布变化 | 含义 |
|---|---|
| 每份快照的分布几乎相同,且大量 RUNNABLE | CPU 饱和(真的在算) |
每份快照都大量 BLOCKED |
锁竞争 |
WAITING (parking) 的数量持续增长 |
线程/任务堆积(泄漏) |
| 状态分布每份都不同 | 分析价值低(没有持续问题,或采样太稀疏) |
四、协程快照(Kotlin 特有)
为什么协程需要单独的工具
线程 dump 看不到协程——因为多个协程复用一个线程,而且协程挂起时线程是空闲的(可能显示为 WAITING (parking) 或 RUNNABLE)。
线程 dump 显示:io-8 线程 WAITING (parking)
↑ 看起来很"闲"
但实际: 这个线程上有 200 个协程在排队等它
这就是「CPU 不高但很慢」的经典场景——线程 dump 看起来一切正常。
用 DebugProbes 看协程
// 仅在排障时启用(开销很大)
import kotlinx.coroutines.debug.DebugProbes
DebugProbes.install()
// ... 出问题时 ...
DebugProbes.dumpCoroutines() // 打印所有活跃协程及挂起点
DebugProbes.uninstall()
// build.gradle.kts
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-debug:1.9.0")
}
输出示例:
Coroutine "coroutine#12345":Active
at com.example.service.OrderService.find(OrderService.kt:28)
at com.example.db.OrderRepository.queryJdbc(OrderRepository.kt:42)
at com.example.repo.JdbcQuery.execute(JdbcQuery.kt:15)
Coroutine "coroutine#12346":Active
at com.example.service.OrderService.find(OrderService.kt:28)
...
关键判读:
| 观察 | 结论 |
|---|---|
| 大量协程挂在同一个阻塞调用上 | 调度器被阻塞调用污染(第 2 章 2.7) |
| 大量协程挂在调度器排队处 | Dispatchers.Default/IO 并行度不足 |
| 协程数持续增长 | 协程泄漏(GlobalScope 未取消) |
协程挂在 Mutex 上 |
协程级锁竞争 |
⚠️ DebugProbes 的开销
它会给每个协程创建调试信息,开销可能达到 10%–30%——只能用于排障,绝不能常开(第 2 章 2.7 节)。
替代方案(生产可用):
// 自己埋一个"活跃协程数"指标,开销低
private val activeCoroutines = AtomicInteger(0)
fun CoroutineScope.tracked(block: suspend () -> Unit): Job = launch {
activeCoroutines.incrementAndGet()
try { block() } finally { activeCoroutines.decrementAndGet() }
}
// 注册为 Gauge
Gauge.builder("app.coroutines.active") { activeCoroutines.get().toDouble() }
.register(registry)
判读:如果这个数字持续增长,说明协程在泄漏或堆积。
五、四步分析流程
第 1 步:看状态分布
→ 大量 BLOCKED?→ 锁竞争
→ 大量 RUNNABLE 且 CPU 高?→ 真的在算
→ 大量 WAITING (parking)?→ 等池/队列
第 2 步:看关键栈特征(用上面的速查表)
→ HikariPool → 连接池排队
→ socketRead → 等下游
→ park → 等锁/任务
第 3 步:多份对比,找「持续存在」的栈
→ 这才是真正的问题
第 4 步:如果是协程应用,补一份协程快照
→ 因为线程 dump 可能"看起来正常"
六、一个完整的分析实例
现象:所有接口偶发性卡顿,CPU 30%,线程数正常,GC 正常
① 线程状态分布(5 份快照)
每份都是:8 个 RUNNABLE,42 个 WAITING (parking)
→ 没有明显的 BLOCKED(排除锁竞争)
② 看 8 个 RUNNABLE 的栈
全部停在 socketRead0 / pg_sleep 相关调用
→ 但只有 8 个线程,为什么所有接口都卡?
③ 关键发现:这 8 个 RUNNABLE 是 Dispatchers.Default 的 worker
而 CPU 核数也是 8 !
→ Dispatchers.Default 的 8 个线程全部被阻塞调用占住
④ 协程快照验证
DebugProbes.dumpCoroutines() 显示 240 个协程挂在同一个 JDBC 调用点
→ 证实:调度器被阻塞调用污染(第 2 章 2.7)
⑤ 结论
根因:某个接口在 Dispatchers.Default 上执行 JDBC 查询,
8 个 worker 被占满 → 所有使用 Default 的协程排队
证据:线程 dump(8 个 RUNNABLE 全在 JDBC)+ 协程 dump(240 个协程排队)
被排除:锁竞争(无 BLOCKED)、GC(停顿与尖刺不对齐)、
连接池(pending = 0)
注意第 ③ 步的关键:「8 个 RUNNABLE」与「8 核」这两个数字的巧合——这是识别调度器饥饿的关键线索。
七、本节小结
- 一张快照没用,连续多张才有——对比才能识别"持续阻塞"。
- 状态速查:
BLOCKED→ 锁竞争;大量RUNNABLE+ CPU 高 → 真在算;WAITING (parking)→ 等池/队列。 - 栈特征比状态更有指示性:
HikariPool→ 连接池排队;socketRead→ 等下游;park→ 等锁。 - 对比方法:统计在所有快照里都出现的栈(
comm -12)。 - 协程应用必须补一份协程快照——线程 dump 可能"看起来正常"。
- DebugProbes 开销很大(10%–30%),只能排障用;生产用自定义的"活跃协程数"指标。
- 「RUNNABLE 线程数 = CPU 核数」是一个重要信号——可能意味着调度器被占满。
八、自测
- 你取了 5 份线程快照,每份都有 40 个线程停在
HikariPool.getConnection。请说明根因方向,以及下一步该看什么指标。 - 一个 Kotlin 协程服务的线程 dump 显示「8 个 RUNNABLE,40 个 WAITING」,CPU 只有 25%,但所有接口都慢。你的第一反应是什么?怎么验证?
- 为什么单张线程快照几乎没有分析价值?在什么情况下单张快照反而有用?
- 根因方向:大量请求在等待数据库连接池分配连接——即"连接池排队",不是"数据库执行慢"。下一步要看的指标:①
db_pool_pending——如果 > 0,直接证实排队;同时看db_pool_active/db_pool_total(是否已打满);②pg_stat_activity(第 4 章 4.7)——看数据库侧的连接数、是否有长事务、wait_event_type是什么;③pg_stat_statements——查是否有慢查询导致连接被长时间占用;④ 关键是区分原因:如果同时有慢查询 → 根因是慢查询(连接被占住),先优化 SQL 而不是加池;如果查询都很快但仍然 pending → 才考虑按 Little’s Law 调整池大小。⑤ 还可以看应用侧dep.postgres的 P99——它包含了"等连接"的时间,如果它显著高于数据库侧的查询耗时,差值就是排队时间。 - 第一反应:怀疑调度器被阻塞调用污染(第 2 章 2.7 节)——8 个 RUNNABLE 恰好等于 CPU 核数,说明
Dispatchers.Default的 worker 全被占住了(很可能在做阻塞 IO),而其余 40 个线程在等待。这完美解释了「CPU 不高但所有接口都慢」。验证方法:① 采 wall-clock 火焰图(asprof -e wall)——会看到 8 个 Default worker 停在阻塞调用栈上(socketRead0、jdbc、Thread.sleep);② 协程快照——DebugProbes.dumpCoroutines()显示大量协程挂在同一个阻塞调用点;③ 代码检查——搜索Dispatchers.Default与runBlocking,看是否在 Default 上下文里执行 JDBC/同步 HTTP;④ 连续多份线程 dump,确认这 8 个 RUNNABLE 的栈始终是同一个阻塞调用(而不是恰好路过)。修法:把阻塞调用切到Dispatchers.IO,或改用异步驱动(第 2 章 2.7)。 - 单张快照价值低的原因:线程状态是瞬时的——一张快照里的
BLOCKED可能只是恰好路过(比如正在等一个很快就会释放的锁),无法区分"瞬时"与"持续"。单张有用的三种情况:① 进程已经卡死(hang)——此时所有线程都冻结了,一张快照就能看出卡在哪(此时通常不需要对比);② 只想快速确认某个特征是否存在——比如"当前是否有线程在等连接池"(有 → 至少说明这个路径被走到了;但"多少、多久"还需要多份);③ 配合其他证据——比如已经通过指标确认了瓶颈,单张快照用来定位具体代码行。但要记住:只用单张快照下结论,很容易把"路过"当成"堵住"——这是本章反复强调的纪律。