当你的线程“互相等待”时:死锁的四个必要条件与 Java 代码中的“致命拥抱”
你打开
jstack,发现几个线程都停在BLOCKED状态,谁也动不了。
日志里最后一行是“正在获取锁 A”,下一行“正在等待锁 B”……永远等不到。
恭喜,你遇到了并发编程的经典噩梦——死锁。
这不是随机故障,而是四个条件恰好同时满足的必然结果。
学会识别它们,你就能从“死锁受害者”变成“死锁预防者”。
大家好,我是Evan,一个在知识汇秒杀系统中用tryLock避免过死锁的 Java+AI 学生。
今天,我们从操作系统的死锁四个必要条件出发,用真实的 Java 代码还原死锁现场,再用jstack亲手抓出它。
读完这篇,你不仅能背出“互斥、持有并等待、不可剥夺、循环等待”,还能在自己的代码里提前嗅到死锁的味道。
📌 写在前面
大二学操作系统,老师讲死锁时,我背了四个条件,但总觉得那是银行家算法里的抽象概念。
直到我在知识汇的优惠券秒杀中,用两个synchronized嵌套更新库存和用户额度,压测时线程池居然全部卡死。jstack一拉,清清楚楚看到 Thread-1 持有锁 A 等待锁 B,Thread-2 持有锁 B 等待锁 A。
那一刻我才明白:死锁不是教科书上的古董,它就藏在你每天写的synchronized里。
一、死锁的四个必要条件(一个都不能少)
死锁的发生必须同时满足以下四个条件:
只有这四个条件同时成立,死锁才会发生。因此,打破任意一个,就能预防或解除死锁。
二、用 Java 代码还原一个经典死锁
public class DeadlockDemo { private static final Object LOCK_A = new Object(); private static final Object LOCK_B = new Object(); public static void main(String[] args) { Thread t1 = new Thread(() -> { synchronized (LOCK_A) { System.out.println("Thread-1 持有 LOCK_A,等待 LOCK_B"); sleep(100); // 模拟业务处理 synchronized (LOCK_B) { System.out.println("Thread-1 同时持有 LOCK_A 和 LOCK_B"); } } }); Thread t2 = new Thread(() -> { synchronized (LOCK_B) { System.out.println("Thread-2 持有 LOCK_B,等待 LOCK_A"); sleep(100); synchronized (LOCK_A) { System.out.println("Thread-2 同时持有 LOCK_B 和 LOCK_A"); } } }); t1.start(); t2.start(); } private static void sleep(int ms) { try { Thread.sleep(ms); } catch (InterruptedException e) {} } }运行结果(大概率):
Thread-1 持有 LOCK_A,等待 LOCK_B Thread-2 持有 LOCK_B,等待 LOCK_A (然后程序卡死,永不退出)四个条件检查:
互斥:
synchronized保证互斥 ✅持有并等待:t1 持有 A 等 B,t2 持有 B 等 A ✅
不可剥夺:t1 不会释放 A 去等 B(除非主动 unlock) ✅
循环等待:t1 等 t2 释放 B,t2 等 t1 释放 A ✅
完美死锁。
三、如何检测死锁:jstack是你的火眼金睛
3.1 找到 Java 进程 PID
jps -l3.2 用jstack打印线程堆栈
jstack <pid>输出中会明确提示Found one Java-level deadlock:
Found one Java-level deadlock: ============================= "Thread-1": waiting to lock monitor 0x00007f8c1400a500 (object 0x000000076b5a3b20, a java.lang.Object), which is held by "Thread-2" "Thread-2": waiting to lock monitor 0x00007f8c1400a2e0 (object 0x000000076b5a3b10, a java.lang.Object), which is held by "Thread-1"还会告诉你哪一行代码导致的。
3.3 其他工具
VisualVM:图形化监控,可自动检测死锁。
JConsole:连接进程 → 线程 → 检测死锁按钮。
Linux
kill -3 <pid>:打印堆栈到标准错误(老式方法)。
四、开发中常见的死锁场景
4.1 嵌套synchronized
如上面的例子,两个锁顺序相反。
4.2 数据库行锁 + 表锁
-- 事务1 SELECT * FROM orders WHERE id = 1 FOR UPDATE; -- 锁住行1 SELECT * FROM users WHERE id = 1 FOR UPDATE; -- 等待锁住行2 -- 事务2 SELECT * FROM users WHERE id = 1 FOR UPDATE; -- 锁住行2 SELECT * FROM orders WHERE id = 1 FOR UPDATE; -- 等待锁住行1数据库也会死锁,通常数据库会检测并回滚其中一个事务(返回Deadlock found when trying to get lock)。
4.3 线程池 + Future.get() 相互等待
ExecutorService pool = Executors.newFixedThreadPool(2); Future<?> f1 = pool.submit(() -> { Future<?> f2 = pool.submit(() -> {}); f2.get(); // 等待 f2 }); Future<?> f2 = pool.submit(() -> { Future<?> f1 = pool.submit(() -> {}); f1.get(); // 等待 f1 });如果线程池只有 2 个线程,且两个任务互相提交并等待对方完成,就会死锁。
五、预防死锁的四种武器(打破任一条件)
5.1 顺序加锁(打破循环等待)
java
// 规定总是先锁 A 再锁 B synchronized (LOCK_A) { synchronized (LOCK_B) { // 安全 } }5.2 使用tryLock超时(打破持有并等待 + 不可剥夺)
ReentrantLock lockA = new ReentrantLock(); ReentrantLock lockB = new ReentrantLock(); boolean gotA = lockA.tryLock(100, TimeUnit.MILLISECONDS); boolean gotB = lockB.tryLock(100, TimeUnit.MILLISECONDS); if (gotA && gotB) { try { // 执行业务 } finally { if (gotB) lockB.unlock(); if (gotA) lockA.unlock(); } } else { // 释放已获得的锁,避免死锁 if (gotA) lockA.unlock(); if (gotB) lockB.unlock(); // 重试或降级 }5.3 一次性申请所有资源(打破“持有并等待”)
// 使用 All-or-Nothing 模式 ReentrantLock[] locks = {lockA, lockB}; while (true) { boolean acquired = true; for (ReentrantLock lock : locks) { if (!lock.tryLock(100, TimeUnit.MILLISECONDS)) { acquired = false; // 释放已获得的锁 for (ReentrantLock l : locks) l.unlock(); break; } } if (acquired) { try { /* 业务 */ } finally { for (ReentrantLock l : locks) l.unlock(); } break; } // 短暂等待后重试 }六、数据库死锁的特殊处理
数据库死锁时,InnoDB 会检测并自动回滚其中一个事务(通常是开销较小的一方)。
你可以通过SHOW ENGINE INNODB STATUS查看最近死锁信息。
Java 中捕获:
try { // 执行 SQL } catch (DeadlockLoserDataAccessException e) { // 重试 }预防:
统一对表的访问顺序(例如先更新主表,再更新从表)。
使用
SELECT ... FOR UPDATE NOWAIT(部分数据库支持)。
📝 总结
核心结论:
死锁是并发编程中“四个条件同时满足”的必然结果。
jstack是检测 Java 死锁的第一利器。预防死锁最实用的两个方法:固定加锁顺序+使用
tryLock超时。数据库死锁不同,引擎会自动回滚,但你的代码仍需要重试机制。
🤔思考题:
你有一个方法,需要同时获取三个锁:LOCK_A、LOCK_B、LOCK_C。你规定所有线程都按 A → B → C 的顺序获取。
但是,某些情况下,你调用的第三方库内部会反向获取LOCK_C→LOCK_B。你的代码无法修改第三方库。
问题:这种情况下,如何避免死锁?请给出至少两种方案。
欢迎在评论区留下你的想法 —— 下一篇我会聊聊“I/O 多路复用与 Agent 循环:epoll 如何支撑你上千个并发 Tool 调用”。
