承接的问题
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();
}
}
}
四个条件和代码的对应关系可以直接背下来:
- 互斥在第 16、17 行的两个
ReentrantLock字段。ReentrantLock的语义就是同一时刻只有一个线程能进入lock()之后的区域,这是LOCK_A成为“资源”的前提。 - 请求并保持在第 33 行
from.lock()之后。线程已经持有from,却还没有拿到to,并且没有释放from,这就是“占有且等待”。 - 不可剥夺在第 36 行
to.lock()。这里的lock()不是tryLock,没有超时、不可中断;只要to被另一个线程持有,当前线程就只能 park,JVM 不能替它把from收走。 - 循环等待在第 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();
}
}
落地时固定四件事:
- 只报不杀。 检测到持续死锁后,先写结构化日志、抓一份
Thread.print -l,再由人决定是摘流量还是重启。自动System.exit会把现场一起带走,除非有外部 dump 兜底。 - 记录锁对。 日志里至少要有两个线程名、两个锁的类名、持有者名字;只写“deadlock detected”等于没写。
- 与转储对齐。 看门狗报出的 ID 和
jcmd转储里的#25是同一套线程 ID;值班时把两边对上,能快速确认是不是同一个环。 - 验证修复。 改完锁顺序后,看门狗应连续输出
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 活锁、饥饿与优先级反转
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。