卡死 3 分钟,jstack 查出 4 条线程在跳探戈
场景:转账服务两个账号互相转账,同时请求导致全部线程 BLOCKED,服务 hang 死 路径:现象全景 → 现场还原 → 第一板斧(jstack) → 第二板斧(代码审查) → 第三板斧(lock chain) → 排查地图
上篇讲了 Executors 的无界队列 OOM——线程池没满,队列先爆了。这篇我们换个方向:不是排队太多,是都在等别人手里的锁。
A 账号给 B 转 100,B 账号给 A 转 200。两个请求,同一秒发出。
5 秒后,两个请求都没回来。10 秒后,超时。重试,又超时。运维看了一眼:CPU 10%、内存正常、没有 OOM、没有 Full GC——但服务像冻住了一样,敲什么键都没反应。jstack 打进去,4 条线程两两一对,各持一把锁,在等对方手里的另一把。
这不是死锁——等等,这就是死锁。而且 jstack 在末尾清清楚楚写给你看了,只是你以前没认真读那几行。
现象全景:死锁的 3 种面孔
死锁不是"所有线程都停了"那么简单。不同的死锁模式,jstack 上看的画面完全不同。你如果只认识其中一种,另外两种走到面前你都不认识。
经典死锁:两把锁互相等
这最直观。Thread A 拿了 L1 在等 L2,Thread B 拿了 L2 在等 L1。jstack 打出来,两线程都是 BLOCKED,各自停在 waiting to lock。
最友好的是——jstack 直接在最末尾告诉你"Found one Java-level deadlock"。你什么分析都不用做,jstack 把答案打印出来了。但前提是你要看那几行。

隐式死锁:看起来不像死锁
这是最阴的。代码里只有一段加锁逻辑:
transfer(from, to):
锁 from → 锁 to → 转账
看起来无懈可击?transfer(A, B) 和 transfer(B, A) 同时调用——参数顺序天然反转,锁的获取顺序就对不上了。你不需要两段不同的加锁代码,同一个方法传不同参数就能死锁。很多人排查到这一步还在想"我明明是同一个方法啊"——但死锁不关心你的代码逻辑,它只看运行时锁获取的顺序。
外部资源死锁:锁在 JVM 外面
这就更难认了。线程 1 持有 JVM 内的锁,在等数据库连接池放出连接。线程 2 拿着连接不释放,在等线程 1 持有的那把锁。
坑在哪?线程 2 的 jstack 状态是 WAITING(在 poll() 上等连接),不是 BLOCKED。新手一看"没有 BLOCKED 线程啊"就直接排除死锁。但实际上 A 等在 B 的锁上、B 等在 A 的资源上——闭环已经形成了,只是有一半的路走的是 JVM 外面的资源。
| 死锁类型 | jstack 状态 | 检测难度 | 认不出来的后果 |
|---|---|---|---|
| 经典死锁 | BLOCKED + waiting to lock |
低——jstack -l 直接显示 | 重启服务 |
| 隐式死锁 | BLOCKED | 中——需要看锁顺序 | 重启 + 认为"偶发" |
| 外部资源死锁 | WAITING/TIMED_WAITING | 高——需要结合连接池监控 | 换个连接池版本试试 |
BLOCKED——线程在等锁,不消耗 CPU。WAITING——线程在等条件信号(wait()/park()),不消耗 CPU。RUNNABLE——线程正在跑,消耗 CPU。这三者经常被混淆,但它们在 jstack 上泾渭分明,一分就知道了。
现场还原:4 条线程在跳探戈
我们用一个最简单的转账死锁来复现。两个账号,两个线程,同一段代码。
synchronized——Java 最基础的互斥锁,保证同一时刻只有一个线程进入临界区,但不保证锁获取顺序。
复现代码(JDK 17)
DeadlockDemo 的核心结构:t1 锁 accountA 再锁 accountB,t2 锁 accountB 再锁 accountA。
Thread t1 = new Thread(() -> {
synchronized (accountA) { sleep(100);
synchronized (accountB) { /* transfer A→B */ }
}
});
Thread t2 = new Thread(() -> {
synchronized (accountB) { sleep(100);
synchronized (accountA) { /* transfer B→A */ }
}
});
两段代码各自看都是对的——先锁 from 再锁 to。但 transfer(A, B) 和 transfer(B, A) 同时发起时,锁就是反转的。
复现条件:JDK 8+,2 个线程。sleep(100) 保证两线程同时分别拿到第一把锁后互相等对方的第二把锁。跑一次必死锁。

jstack 现场
运行 3 秒后,jstack 输出:
"Thread-0" #17 prio=5 os_prio=0
java.lang.Thread.State: BLOCKED
at DeadlockDemo.lambda$main$0(DeadlockDemo.java:11)
- waiting to lock <0x000000076b5f3a98>
- locked <0x000000076b5f39d0>
"Thread-1" #18 prio=5 os_prio=0
java.lang.Thread.State: BLOCKED
at DeadlockDemo.lambda$main$1(DeadlockDemo.java:20)
- waiting to lock <0x000000076b5f39d0>
- locked <0x000000076b5f3a98>
Found one Java-level deadlock:
=============================
"Thread-0": waiting to lock <0x000000076b5f3a98>,
which is held by "Thread-1"
"Thread-1": waiting to lock <0x000000076b5f39d0>,
which is held by "Thread-0"
Found 1 deadlock.
现在跟着我读这个 dump——不是看,是读:
从 Thread-0 开始看。第三行 - waiting to lock <0x...3a98> — Thread-0 在等这把锁。第四行 - locked <0x...39d0> — Thread-0 手里拿着这把锁。好,Thread-0 持有 39d0、等待 3a98。
再看 Thread-1。第三行 - waiting to lock <0x...39d0> — Thread-1 在等 39d0。第四行 - locked <0x...3a98> — Thread-1 手里拿着 3a98。
画一下:Thread-0 → 持 39d0 → 等 3a98;Thread-1 → 持 3a98 → 等 39d0。这两条线连起来,就是一个环。

两行关键线索:第一行 waiting to lock 指向对方持有的锁,第二行 locked 标注自己持有哪些锁。如果 locked 的对象恰好是对方线程 waiting to lock 的对象——闭环形成,死锁成立。
jstack 不是一个错误日志——它是你的线程在那一秒的现场照片。
第一板斧:jstack 现场取证
发现服务 hang 了,第一件事不是翻代码——是 jstack。
jstack 怎么用
# 基础:看所有线程状态
jstack <pid>
# 核心:看锁信息(-l 必须加!)
jstack -l <pid>
# 连续采样:隔 3 秒打一次,看线程状态是否变化
for i in {1..5}; do
jstack -l <pid> > dump_$i.txt
sleep 3
done
不加 -l 的 jstack 不显示 java.util.concurrent Lock(如 ReentrantLock)的锁信息——等于只看了一半 synchronized 的锁信息。synchronized 的 locked 和 waiting to lock 在基本 jstack 中就有,但显式 Lock 的锁信息只在 -l 输出中显示。

读 jstack 死锁的三步法
| 步骤 | 看什么 | 怎么判断 |
|---|---|---|
| ① 找 BLOCKED 线程 | java.lang.Thread.State: BLOCKED |
死锁的线程至少 2 条是 BLOCKED |
| ② 看 locked 对象 | - locked <0x...> |
线程当前持有哪把锁 |
| ③ 看 waiting to lock | - waiting to lock <0x...> |
线程在等哪把锁 |
| ④ 闭环 | 比较各线程的 locked 和 waiting to lock | A 等的锁被 B 持有,B 等的锁被 A 持有 = 死锁 |
区分:不要看到 BLOCKED 就判断死锁。如果所有 BLOCKED 线程在等同一把锁(同 <0x...>),那是锁竞争——等锁的线程会有多个,但持锁的线程最终会释放。死锁是循环等待,没有线程会释放锁。
不是死锁的"死锁"——三个常见误判
| 现象 | 误判 | 正解 |
|---|---|---|
| 所有线程 RUNNABLE + CPU 100% | "在自旋等锁" | 不是锁——是死循环或自旋。RUNNABLE 消耗 CPU,BLOCKED 不消耗 |
| 一条 BLOCKED + 一条 WAITING | "两线程死锁" | WAITING 线程可能在等条件变量(wait()/park()),不是锁 |
| 多条线程 BLOCKED 等同一把锁 | "死锁" | 锁竞争——持锁线程还在跑,会释放 |
第二板斧:代码审查根因锁定
jstack 告诉你"谁在等谁",但没告诉你"是哪两段代码反转了锁顺序"。这时候需要 grep。
grep 锁模式
找到 jstack 中 at ... 指向的类和方法,然后搜整个项目的锁使用:
# 搜索 synchronized 方法/块
grep -rn 'synchronized' src/main/java/com/example/transfer/
# 搜索显式 Lock
grep -rn '\.lock()' src/ --include='*.java'
# 搜索锁顺序反转——两个方法都锁了相同的两个对象但顺序不同
grep -rn 'synchronized.*account' src/ --include='*.java'

锁顺序一致性检查
死锁的根因 90% 是同一个问题:两段代码获取多把锁的顺序不一致。
// ❌ 有死锁风险的锁顺序
void transfer(Account from, Account to, int amount) {
synchronized (from) { // 调用顺序决定了锁顺序
synchronized (to) { // transfer(a,b) vs transfer(b,a) → 锁顺序反转
// transfer logic
}
}
}
// ✅ 保证锁顺序一致:按 hash 值排序 + tie-breaking
private static final Object tieLock = new Object();
void transferSafe(Account from, Account to, int amount) {
int fromHash = System.identityHashCode(from);
int toHash = System.identityHashCode(to);
if (fromHash < toHash) {
synchronized (from) { synchronized (to) {
// transfer logic
}}
} else if (fromHash > toHash) {
synchronized (to) { synchronized (from) {
// transfer logic
}}
} else {
// hash 碰撞(概率 ~1/2^32),用第三把锁做 tie-breaking
synchronized (tieLock) {
synchronized (from) { synchronized (to) {
// transfer logic
}}
}
}
}
按 System.identityHashCode() 排序是一种通用的锁顺序保证模式——不管外层调用者怎么传参,锁获取顺序永远一致。如果是两个不同的业务方法(比如 synchronized void transferIn() 和 synchronized void transferOut()),那就要确保方法间的调用顺序不产生循环等待。
锁分层模式
更高级的做法是按层次定义锁的优先级:
// 锁层级:Account锁 < User锁 < Global锁
// 任何代码段想同时获取多把锁,必须按此顺序
class LockHierarchy {
static final int LEVEL_ACCOUNT = 1;
static final int LEVEL_USER = 2;
static final int LEVEL_GLOBAL = 3;
}
规定"从低到高"或"从高到低"获取锁,反向加锁时触发异常。Spring 的 Ordered 接口、Google 的 CycleDetectingLockFactory 都体现了这个思路。
第三板斧:lock chain 预防体系
jstack 是事中排查,代码审查是事后弥补。lock chain 分析是预防——让死锁在发生前就被检测出来。
ThreadMXBean:JDK 自带的死锁检测
JDK 从 1.5 开始就内置了编程式死锁检测 API——ThreadMXBean.findDeadlockedThreads()。
核心只有三行:
ThreadMXBean mbean = ManagementFactory.getThreadMXBean();
long[] deadlockedIds = mbean.findDeadlockedThreads();
ThreadInfo[] infos = mbean.getThreadInfo(deadlockedIds, true, true);
findDeadlockedThreads() 返回的是死锁线程 ID 数组。传进 getThreadInfo() 拿到每个线程的状态、持有的锁、在等的锁——和 jstack 输出的信息一模一样,但可以在代码里用。用 ScheduledExecutorService 每 30 秒跑一次,死锁发现就是自动的。

用定时任务封装:生产环境建议用 ScheduledExecutorService 每 30 秒执行一次 check()。为什么 30 秒?死锁发生的前几秒请求正在超时重试,你在这期间检测到也帮不上忙——真正需要的是在 30 秒级别的延迟内发现并告警,而不是毫秒级实时检测。30 秒足够让探测线程进入一次 safe point 完成线程状态扫描,又不会对 CPU 产生可感知的负担。
检测到死锁后:
- 打印线程栈到日志(保留现场)
- 发送告警(让值班的人知道)
- 选择性地
interrupt()线程(某些情况下可以解除死锁——wait()和sleep()会响应中断,但synchronized不响应,所以这招对显式 Lock 更有效)
ThreadMXBean.findDeadlockedThreads() 检测的是 Java 级别的锁死锁(synchronized + ReentrantLock)。外部资源死锁(比如数据库连接池)无法通过这个 API 检测——需要结合其他监控手段。
Lock chain 等待图
死锁的本质是一个有向循环图:节点是线程和锁,边表示"持有"和"等待"。

用 DOT 把锁和线程的关系画出来:
digraph G {
rankdir=LR;
node [shape=box, style=filled, fillcolor="#1a1a2e", fontcolor="#e0e0e0"];
edge [color="#4fc3f7", penwidth=1.5];
"Thread-1" [color="#3a8ef0"];
"Thread-2" [color="#3a8ef0"];
"Lock A" [shape=component, color="#ff8844"];
"Lock B" [shape=component, color="#ff8844"];
"Thread-1" -> "Lock A" [label="holds", color="#ff8844"];
"Thread-1" -> "Lock B" [label="waits for", style=dashed, color="#ff4444"];
"Thread-2" -> "Lock B" [label="holds", color="#ff8844"];
"Thread-2" -> "Lock A" [label="waits for", style=dashed, color="#ff4444"];
}
Thread-1 持有 Lock A、等 Lock B;Thread-2 持有 Lock B、等 Lock A——闭环,死锁。每次写死锁排查文章都这么画,但每次看到这个环,你还是会觉得"这么明显怎么没发现"。
排查地图:从发现到预防的全链路
死锁发现决策树

决策树路径:
服务 hang 住 / 请求超时
└→ jstack -l <pid>
├→ 有 BLOCKED 线程?
│ ├→ 多条 BLOCKED 等不同锁 → jstack 输出尾部是否显示 deadlock
│ │ ├→ ✅ 是 → 经典死锁 → 代码审查锁顺序
│ │ └→ ❌ 否 → 隐式死锁 → 搜所有 synchronized/lock() 的嵌套
│ └→ 全部 BLOCKED 等同一把锁 → 锁竞争(非死锁),下一步找持锁线程
└→ 没有 BLOCKED?
├→ 全部 RUNNABLE → 死循环/自旋,不是锁问题
└→ 全部 WAITING → 外部资源死锁?检查连接池/网络
阅读路线
锁/AQS 类问题共 3 篇:
| 篇次 | 主题 | 定位 |
|---|---|---|
| ① | 死锁检测三板斧(本篇) | 排查决策树 + 三斧工具链 |
| ② | ReentrantLock 与 synchronized 选型 | 可重入/可中断/超时锁可解死锁 |
| ③ | 读写锁锁降级死锁 | Lock 高级特性陷阱 |
并发问题的本质不是代码错了——是代码的执行路径在你的脑子里和 JVM 里不一样。你写的锁顺序没问题?那只是因为你跑的时候没有两条线程同时从相反方向来。
标记:在你的项目里搜死锁隐患
# 搜索嵌套 synchronized
grep -rn 'synchronized.*synchronized' src/ --include='*.java'
# 搜索多个 .lock() 连续调用(可能锁顺序反转)
grep -rn '\.lock()' src/ --include='*.java' | grep -A2 'lock()' | grep 'lock()'
# 搜索可检测的 API 使用
grep -rn 'ThreadMXBean\|findDeadlockedThreads' src/ --include='*.java'
代码审查检查表
| 检查项 | 严重程度 | 说明 |
|---|---|---|
嵌套 synchronized |
🔴 高 | 两段代码锁顺序反转即可死锁 |
多个 Lock.lock() 连续调用 |
🔴 高 | 与 synchronized 嵌套相同风险 |
transfer(A,B) 模式 |
🟡 中 | 参数决定锁顺序时,调用方必须全局一致 |
没有 jstack -l 的排查文档 |
🟡 中 | 团队不知道加 -l 看锁信息 |
| 没有死锁检测定时任务 | 🟢 建议 | ThreadMXBean 30s 跑一次,开销极小 |
带走的三样东西
读完这篇,你带走三样东西:
| # | 工具 | 一句话用法 |
|---|---|---|
| ① | jstack -l |
服务 hang 住第一件事——看 BLOCKED 线程的 locked/waiting to lock 配不配对 |
| ② | grep 'synchronized.*synchronized' |
搜项目里所有嵌套锁——90% 死锁长在这里 |
| ③ | ThreadMXBean.findDeadlockedThreads() |
30 秒一次的自动死锁检测,三行代码的事 |
今天下班前做两件事:git grep 'synchronized' 看看项目里哪些地方有嵌套锁。给关键服务加一个 DeadlockDetector 定时任务——30 秒一次的检查,死锁当场发现。
死锁不是写出来的——是两段代码各自正确但一起运行时撞出来的。
下篇我们聊 ReentrantLock 与 synchronized 如何选型——当死锁发生时,可中断锁(lockInterruptibly())和超时锁(tryLock(3, TimeUnit.SECONDS))是最好的解药。
📺 公众号「Ai拆代码的曹操」 🌟 知识星球「Ai拆代码的曹操」