卡死 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 把答案打印出来了。但前提是你要看那几行。

经典死锁 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) 保证两线程同时分别拿到第一把锁后互相等对方的第二把锁。跑一次必死锁。

DeadlockDemo 复现代码结构

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。这两条线连起来,就是一个环。

jstack 死锁现场输出

两行关键线索:第一行 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 的 lockedwaiting to lock 在基本 jstack 中就有,但显式 Lock 的锁信息只在 -l 输出中显示。

jstack 命令与快速筛选

读 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'

grep 锁模式搜索输出

锁顺序一致性检查

死锁的根因 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 秒跑一次,死锁发现就是自动的。

ThreadMXBean 完整代码实现

用定时任务封装:生产环境建议用 ScheduledExecutorService 每 30 秒执行一次 check()。为什么 30 秒?死锁发生的前几秒请求正在超时重试,你在这期间检测到也帮不上忙——真正需要的是在 30 秒级别的延迟内发现并告警,而不是毫秒级实时检测。30 秒足够让探测线程进入一次 safe point 完成线程状态扫描,又不会对 CPU 产生可感知的负担。

检测到死锁后:

  1. 打印线程栈到日志(保留现场)
  2. 发送告警(让值班的人知道)
  3. 选择性地 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拆代码的曹操」