文档目录

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 核」这两个数字的巧合——这是识别调度器饥饿的关键线索。


七、本节小结

  1. 一张快照没用,连续多张才有——对比才能识别"持续阻塞"。
  2. 状态速查:BLOCKED → 锁竞争;大量 RUNNABLE + CPU 高 → 真在算;WAITING (parking) → 等池/队列。
  3. 栈特征比状态更有指示性:HikariPool → 连接池排队;socketRead → 等下游;park → 等锁。
  4. 对比方法:统计在所有快照里都出现的栈(comm -12)。
  5. 协程应用必须补一份协程快照——线程 dump 可能"看起来正常"。
  6. DebugProbes 开销很大(10%–30%),只能排障用;生产用自定义的"活跃协程数"指标。
  7. 「RUNNABLE 线程数 = CPU 核数」是一个重要信号——可能意味着调度器被占满。

八、自测

  1. 你取了 5 份线程快照,每份都有 40 个线程停在 HikariPool.getConnection。请说明根因方向,以及下一步该看什么指标。
  2. 一个 Kotlin 协程服务的线程 dump 显示「8 个 RUNNABLE,40 个 WAITING」,CPU 只有 25%,但所有接口都慢。你的第一反应是什么?怎么验证?
  3. 为什么单张线程快照几乎没有分析价值?在什么情况下单张快照反而有用?
  1. 根因方向:大量请求在等待数据库连接池分配连接——即"连接池排队",不是"数据库执行慢"。下一步要看的指标:① 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 章 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)。
  3. 单张快照价值低的原因:线程状态是瞬时的——一张快照里的 BLOCKED 可能只是恰好路过(比如正在等一个很快就会释放的锁),无法区分"瞬时"与"持续"。单张有用的三种情况:① 进程已经卡死(hang)——此时所有线程都冻结了,一张快照就能看出卡在哪(此时通常不需要对比);② 只想快速确认某个特征是否存在——比如"当前是否有线程在等连接池"(有 → 至少说明这个路径被走到了;但"多少、多久"还需要多份);③ 配合其他证据——比如已经通过指标确认了瓶颈,单张快照用来定位具体代码行。但要记住:只用单张快照下结论,很容易把"路过"当成"堵住"——这是本章反复强调的纪律。