《Java 并发编程·性能诊断与事故》4.1 死锁的四个必要条件与检测

把死锁的四个必要条件逐条映射到 ReentrantLock 转账代码的具体行,用一份真实的 jcmd Thread.print -l 转储确认 JVM 报出的 ownable synchronizer 环,并给出基于 ThreadMXBean.findDeadlockedThreads() 的看门狗代码、真实输出与连续两次确认的防抖策略。

承接的问题

3.3 已经讲过怎么在一份线程转储里读 Found one Java-level deadlock:先看环,再拿 object 地址去配对 - waiting to lock 和 - locked。那是事后读证据。本节要往前一步:死锁为什么一定能被归结为四个条件,每个条件对应代码里的哪一行,以及怎么让 JVM 在运行期自己把环报出来,而不是等人去抓转储。

典型症状:接口 RT 从 90 ms 变成 2 s 后不再恢复,线程池活跃数打满,CPU 却只有 20% 出头;抓一份转储,两个线程都是 WAITING (parking),没有 BLOCKED,也没有异常日志。值班同学第一反应是“下游慢”,于是一直等下游,直到线程池彻底耗尽。

这一节用构造场景回答四个问题:

  • 互斥、请求并保持、不可剥夺、循环等待,分别在代码的哪个位置成立;
  • 为什么工程上真正能动的通常只有“循环等待”,其余三条要么消不掉、要么只能兜底;
  • 怎么用 jcmd <pid> Thread.print -l 让 JVM 报出基于 ReentrantLock 的死锁,它和 3.3 的监视器版本有什么不同;
  • 怎么把 ThreadMXBean.findDeadlockedThreads() 封装成一个可复用、不会因瞬时环而误报的看门狗。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3,Temurin 25.0.5+8;实验期间 load average 在 50–80 之间。下文的线程 ID、地址、耗时都来自当次运行,换一次就变。

四个条件不是理论,是四行代码

先把结论摆出来。下面这张表把每个条件落到一个具体动作上,并给出“想消除它要付什么代价”。

条件代码里的位置消除手段代价
互斥new ReentrantLock(),同一时刻只允许一个持有者不可变快照、CAS、单写者队列改数据模型,通常不现实
请求并保持from.lock() 之后才 to.lock()一次性 tryLock 全部资源,失败就全放需要可回滚,吞吐下降
不可剥夺Lock.lock() 不会被打断,也不会被抢走lockInterruptibly()、tryLock(timeout)引入超时与重试语义
循环等待transfer(A, B) 与 transfer(B, A) 并存全局锁顺序(4.3)需要统一的排序键

四条里,互斥是锁存在的理由。共享的可变库存要保证 stock 不为负,就必须有某种互斥;换成 CAS 只是把“锁”换成了“重试”,并没有消除互斥本身。请求并保持也很难彻底消除:业务上就是要在持有 A 的语义下修改 B,除非把两个资源合并成一个原子单元。不可剥夺是 Lock 的契约,JVM 不能在任意点安全地收回一个线程手里的锁。

所以工程上真正可动的只有两条:用固定顺序消除循环等待,用超时给不可剥夺加一个逃生口。前者是预防,后者是兜底,二者不能互相替代。4.3 会展开顺序规范和 tryLock 的代价;本节先把“环”构造出来,让 JVM 自己指认。

还有一个容易被忽略的点:四个条件是必要条件,不是充分条件。四条同时成立只说明“可能死锁”,真正形成死锁还需要一个特定的交错:两个线程都在对方拿第二把锁之前拿到了第一把。这也是为什么死锁常常在压测里偶发、在单测里复现不了。

构造:让 JVM 自己报出这个环

靶子是一个最小转账:两个账户各有一把 ReentrantLock,两个线程以相反顺序加锁。为了让 dump 里的行号可核对,下面的代码与实跑文件逐行一致。

import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
import java.util.concurrent.locks.ReentrantLock;

/**
 * 构造一个 Java 级死锁(ReentrantLock 版本),并用 ThreadMXBean 做看门狗。
 * 四个必要条件逐条落在代码里:
 *   1. 互斥   —— LOCK_A / LOCK_B 是 ReentrantLock,同一时刻只允许一个线程持有;
 *   2. 持有等待—— transfer 先 lock(from),再 lock(to),持有 from 期间等 to;
 *   3. 不可剥夺—— Lock.lock() 不可被抢占,只能等持有者 unlock;
 *   4. 循环等待—— t1 按 A→B 加锁,t2 按 B→A 加锁。
 */
public final class DeadlockLab {

    private static final ReentrantLock LOCK_A = new ReentrantLock();
    private static final ReentrantLock LOCK_B = new ReentrantLock();

    public static void main(String[] args) throws Exception {
        Thread t1 = Thread.ofPlatform().name("transfer-1").start(() -> transfer(LOCK_A, LOCK_B));
        Thread t2 = Thread.ofPlatform().name("transfer-2").start(() -> transfer(LOCK_B, LOCK_A));
        Thread watchdog = Thread.ofPlatform().name("deadlock-watchdog").daemon(true)
                .start(DeadlockLab::watch);
        System.out.println("pid=" + ProcessHandle.current().pid()
                + " t1=" + t1.threadId() + " t2=" + t2.threadId()
                + " watchdog=" + watchdog.threadId());
        t1.join();
        t2.join();
        System.out.println("both transfers finished");
    }

    private static void transfer(ReentrantLock from, ReentrantLock to) {
        from.lock();                       // 条件 1 + 条件 2:持有 from
        try {
            sleep(300);                    // 让两个线程都先拿到第一把锁
            to.lock();                     // 条件 3:不可剥夺,只能等
            try {
                System.out.println(Thread.currentThread().getName() + " acquired both");
            } finally {
                to.unlock();
            }
        } finally {
            from.unlock();
        }
    }

    private static void watch() {
        ThreadMXBean mx = ManagementFactory.getThreadMXBean();
        System.out.println("deadlock detection supported=" + mx.isSynchronizerUsageSupported());
        while (true) {
            sleep(2000);
            long[] ids = mx.findDeadlockedThreads();   // null 表示当前没有环
            if (ids == null) {
                System.out.println("[watchdog] no deadlock");
                continue;
            }
            System.out.println("[watchdog] findDeadlockedThreads -> " + java.util.Arrays.toString(ids));
            for (ThreadInfo info : mx.getThreadInfo(ids, true, true)) {
                System.out.println("[watchdog] thread=" + info.getThreadName()
                        + " id=" + info.getThreadId()
                        + " state=" + info.getThreadState()
                        + " waitingOn=" + info.getLockName()
                        + " owner=" + info.getLockOwnerName());
                StackTraceElement[] st = info.getStackTrace();
                for (int i = 0; i < Math.min(4, st.length); i++) {
                    System.out.println("[watchdog]   at " + st[i]);
                }
            }
        }
    }

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

四个条件和代码的对应关系可以直接背下来:

  1. 互斥在第 16、17 行的两个 ReentrantLock 字段。ReentrantLock 的语义就是同一时刻只有一个线程能进入 lock() 之后的区域,这是 LOCK_A 成为“资源”的前提。
  2. 请求并保持在第 33 行 from.lock() 之后。线程已经持有 from,却还没有拿到 to,并且没有释放 from,这就是“占有且等待”。
  3. 不可剥夺在第 36 行 to.lock()。这里的 lock() 不是 tryLock,没有超时、不可中断;只要 to 被另一个线程持有,当前线程就只能 park,JVM 不能替它把 from 收走。
  4. 循环等待在第 20、21 行。t1 拿到 LOCK_A 后要 LOCK_B,t2 拿到 LOCK_B 后要 LOCK_A,两个“持有 → 请求”的边首尾相接,形成环。

编译、启动、抓转储:

javac --release 25 -d classes DeadlockLab.java
java -cp classes DeadlockLab > out/watchdog.txt 2>&1 &
PID=$!
sleep 4
jcmd "$PID" Thread.print -l > out/deadlock-dump.txt

本机 jcmd 的转储末尾原样如下(只省略了公共栈帧):

Found one Java-level deadlock:
=============================
"transfer-1":
  waiting for ownable synchronizer 0x000000035fc19a30, (a java.util.concurrent.locks.ReentrantLock$NonfairSync),
  which is held by "transfer-2"

"transfer-2":
  waiting for ownable synchronizer 0x000000035fc19a00, (a java.util.concurrent.locks.ReentrantLock$NonfairSync),
  which is held by "transfer-1"

这和 3.3 里那份监视器死锁的结构一样,都是“我等你、你等我”,但措辞从 waiting to lock monitor 变成了 waiting for ownable synchronizer。原因是这次用的是 ReentrantLock 而不是 synchronized:前者是 AQS 的 ownable synchronizer,后者是对象监视器。读法不变,仍然是拿 0x... 地址去配对;变的只是它在转储里的叫法。

线程段里也能看到配对所需的另一半。transfer-1 的状态是 WAITING (parking),不是 BLOCKED:

"transfer-1" #25 [28163] prio=5 os_prio=31 cpu=0.33ms elapsed=7.35s tid=0x000000089b51f800 nid=28163 waiting on condition  [0x0000000171cae000]
   java.lang.Thread.State: WAITING (parking)
	at jdk.internal.misc.Unsafe.park(java.base@25.0.5/Native Method)
	- parking to wait for  <0x000000035fc19a30> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
	at java.util.concurrent.locks.LockSupport.park(java.base@25.0.5/LockSupport.java:223)
	at java.util.concurrent.locks.ReentrantLock.lock(java.base@25.0.5/ReentrantLock.java:323)
	at DeadlockLab.transfer(DeadlockLab.java:36)
	at DeadlockLab.lambda$main$0(DeadlockLab.java:20)

   Locked ownable synchronizers:
	- <0x000000035fc19a00> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)

parking to wait for <...9a30> 是它想要的,Locked ownable synchronizers 里的 <...9a00> 是它已经持有的;transfer-2 的段正好反过来。把两个线程的这两行交叉一读,环就闭合了。

这里有一个值得记住的细节:DeadlockLab.java:36 就是 transfer() 里的 to.lock(),也就是“不可剥夺”成立的那一行;DeadlockLab.java:20 是 main 里 transfer(LOCK_A, LOCK_B) 的 lambda,也就是“循环等待”成立的那一行。JVM 报出的栈帧不是随便截的,它恰好把两个条件的现场都指了出来。3.3 已经讲过转储的通用读法,本节不再重复状态表和 monitor/object 地址的区别。

为什么 JVM 能报出来

JVM 并不是“理解”业务,它维护的是一张等待图:每个线程是一个节点,- waiting to lock / parking to wait for 指向持有者,持有者由对象头或 AQS 的 owner 字段给出。Thread.print -l 在输出末尾做一次环检测,把长度大于 1 的环打印出来。findDeadlockedThreads() 走的是同一套 VM 逻辑,只是把结果返回成 long[]。

这也解释了它的边界:只有 JVM 自己知道的等待关系才在图上。数据库行锁、Redis 分布式锁、跨进程的 gRPC 调用,都不在这张图里;它们造成的“业务死锁”不会出现在 Found one Java-level deadlock 段里。4.3 和 11.1 会分别处理锁顺序和数据库锁叠加的问题。

程序化检测:把 findDeadlockedThreads 做成看门狗

jcmd 适合人工排查,不适合 7×24 小时守着。把检测放进进程里,用守护线程定时调用 ThreadMXBean.findDeadlockedThreads(),本机实跑输出如下:

pid=82070 t1=25 t2=26 watchdog=27
deadlock detection supported=true
[watchdog] findDeadlockedThreads -> [25, 26]
[watchdog] thread=transfer-1 id=25 state=WAITING waitingOn=java.util.concurrent.locks.ReentrantLock$NonfairSync@3f2c5530 owner=transfer-2
[watchdog]   at java.base@25.0.5/jdk.internal.misc.Unsafe.park(Native Method)
[watchdog]   at java.base@25.0.5/java.util.concurrent.locks.LockSupport.park(LockSupport.java:223)
[watchdog]   at java.base@25.0.5/java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:790)
[watchdog] thread=transfer-2 id=26 state=WAITING waitingOn=java.util.concurrent.locks.ReentrantLock$NonfairSync@49e8650 owner=transfer-1

findDeadlockedThreads() 返回的是线程 ID 数组,null 表示当前没有环。拿到 ID 之后,getThreadInfo(ids, true, true) 的两个布尔参数分别要求锁信息和完整栈;getLockOwnerName() 直接给出持有者名字,不需要再手工配地址。waitingOn 打出来的是锁对象的 toString(),带一个 identity hash,只适合人看,不适合做持久化键。

直接把这个循环放进生产代码还不够,有两个坑。

第一个坑是瞬时环。tryLock(timeout) 形成的等待也是 parking to wait for,JVM 的检测器并不区分“永久 park”和“带超时的 park”。4.3 的实跑里,一个用 tryLock(100ms) 兜底的程序在 60 份采样转储中有 9 份被 Thread.print 报成 Found 1 deadlock,而进程其实在 100 ms 后就自己解开了。看门狗如果只看一次,就会误报。

第二个坑是线程消失。findDeadlockedThreads() 返回 ID 之后,线程可能已经因为超时或中断退出;此时 getThreadInfo 的数组里会出现 null 元素,直接 .getThreadName() 就是 NPE。要在遍历时判空。

一个可复用的版本是“连续两次看到同一组 ID 才升级”:

import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
import java.util.Arrays;

public final class DeadlockWatchdog implements Runnable {
    private final ThreadMXBean mx = ManagementFactory.getThreadMXBean();
    private final long intervalMillis;
    private long[] lastSeen;

    public DeadlockWatchdog(long intervalMillis) {
        this.intervalMillis = intervalMillis;
    }

    @Override
    public void run() {
        while (!Thread.currentThread().isInterrupted()) {
            try {
                Thread.sleep(intervalMillis);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
            long[] ids = mx.findDeadlockedThreads();
            if (ids == null) {
                lastSeen = null;
                continue;
            }
            long[] sorted = ids.clone();
            Arrays.sort(sorted);
            if (lastSeen != null && Arrays.equals(sorted, lastSeen)) {
                report(sorted);
            } else {
                lastSeen = sorted;
            }
        }
    }

    private void report(long[] ids) {
        System.err.println("sustained deadlock: " + Arrays.toString(ids));
        for (ThreadInfo info : mx.getThreadInfo(ids, true, true)) {
            if (info == null) {
                continue;
            }
            System.err.println("  " + info.getThreadName()
                    + " waits on " + info.getLockName()
                    + " owned by " + info.getLockOwnerName());
        }
    }
}

排序是为了让两次返回的 ID 数组可比。findDeadlockedThreads() 没有承诺顺序稳定,同一组线程换个顺序就会让 Arrays.equals 失效,看门狗永远不报警。

间隔取 2 秒是一个折中:足够快,能在池子被打满之前抓到;又不至于把 findDeadlockedThreads() 变成热点。这个调用是 VM operation,会让线程在安全点短暂停顿,具体开销随线程数增长;不要把它设成 100 ms 轮询,尤其不要在每个请求里调用。

在 libraryd-conc 里落地

conc-ops 里把看门狗做成一个可注册的组件,而不是散落在业务代码里:

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public final class DeadlockMonitor {
    private final ScheduledExecutorService scheduler =
            Executors.newSingleThreadScheduledExecutor(r -> {
                Thread t = Thread.ofPlatform().name("deadlock-watchdog").unstarted(r);
                t.setDaemon(true);
                return t;
            });

    public void start() {
        scheduler.scheduleWithFixedDelay(new DeadlockWatchdog(2000),
                2, 2, TimeUnit.SECONDS);
    }

    public void stop() {
        scheduler.shutdownNow();
    }
}

落地时固定四件事:

  1. 只报不杀。 检测到持续死锁后,先写结构化日志、抓一份 Thread.print -l,再由人决定是摘流量还是重启。自动 System.exit 会把现场一起带走,除非有外部 dump 兜底。
  2. 记录锁对。 日志里至少要有两个线程名、两个锁的类名、持有者名字;只写“deadlock detected”等于没写。
  3. 与转储对齐。 看门狗报出的 ID 和 jcmd 转储里的 #25 是同一套线程 ID;值班时把两边对上,能快速确认是不是同一个环。
  4. 验证修复。 改完锁顺序后,看门狗应连续输出 no deadlock,同时 rg -c 'Found one Java-level deadlock' dump.txt 从 1 变 0。两个证据都要有,单看一个容易漏掉“环没了但竞争还在”的情况。

三种检测入口的分工可以记成一张表:

入口适用场景局限
jcmd Thread.print -l人工排查、留现场只有快照,需人工配对
ThreadMXBean.findDeadlockedThreads()进程内告警、自动化只覆盖 Java 级等待图
JFR事后回放锁竞争没有专门的死锁事件,需从 jdk.JavaMonitorEnter / jdk.ThreadPark 推断

JFR 这一行要特别说明:截至 JDK 25,JFR 没有名为“deadlock”的内置事件。可以在事后用 jdk.ThreadPark 和 jdk.JavaMonitorEnter 找到长时间等待,但“这是一个环”仍然要靠等待图来判定。不要照着某些博客去搜一个不存在的 jdk.Deadlock 事件。

常见错误与边界

  • 以为没报 deadlock 就没有死锁。 JVM 只检测 Java 级等待图;数据库行锁、分布式锁、线程池耗尽造成的等待都不在其中。4.1 的检测只能证明“Java 锁这一层没有环”。
  • 把 WAITING (parking) 一律当死锁。 空闲线程、条件等待、Future.get 都会 park;要看 parking to wait for 的目标类和持有者,而不是看状态。
  • 只看一份转储。 快照可能正好落在环形成之前或解除之后;间隔几秒抓三份,确认线程是否一直在同一对地址上。
  • 看门狗单次报警。 tryLock(timeout) 的瞬时环会被 JVM 报出来;本节和 4.3 的实跑都验证了这一点。至少连续两次确认,必要时把“等待是否带超时”也纳入判据。
  • getThreadInfo 不判空。 线程可能在两次调用之间退出,数组元素会是 null。
  • 在请求路径里调用 findDeadlockedThreads()。 它是 VM operation,会打断所有线程到安全点;放在独立守护线程里,按秒级间隔采样。
  • 检测当成恢复。 看门狗只能告诉你“有环”,不能替你决定牺牲哪个线程;强行中断一个持有锁的线程还可能留下半更新状态。

什么时候不要用死锁看门狗: 进程对停顿极其敏感、线程数上万、且死锁概率极低时,秒级 VM operation 的代价可能高于收益,此时更适合在压测和灰度阶段用外部 jcmd 采样;反过来,线程池很小、死锁一旦发生就全站不可用的服务,看门狗的成本可以忽略,应该常开。

小结

死锁的四个条件不是四句口号,而是四行可以指认的代码:new ReentrantLock() 提供互斥,from.lock() 制造请求并保持,to.lock() 体现不可剥夺,两个相反的 transfer(A, B) / transfer(B, A) 构成循环等待。工程上能稳定消除的是循环等待,能给不可剥夺加逃生口的是超时;互斥和请求并保持通常只能靠改数据模型来弱化。

检测有两层:jcmd Thread.print -l 适合人工看现场,ThreadMXBean.findDeadlockedThreads() 适合进程内告警。两者读的是同一张 Java 级等待图,因此都看不见数据库锁和分布式锁。看门狗要排序 ID、判空、连续两次确认,才能避开 tryLock(timeout) 造成的瞬时环误报。

本节构造的死锁在本机稳定复现:findDeadlockedThreads() 返回 [25, 26],jcmd 报 Found 1 deadlock.,两个线程分别持有对方等待的 ownable synchronizer。下一节处理另一种更隐蔽的失效:线程都在跑、锁也都能拿到,但系统就是不前进。

阅读导航:上一节 3.3 线程转储的读法 | 下一节 4.2 活锁、饥饿与优先级反转

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收