死锁:四个条件缺一不可,破坏一个就能免疫
一句话结论(30s)
死锁是四个必要条件同时满足的必然结果,因为互斥、持有并等待、不可剥夺、环路等待缺一不可,所以工程上只需破坏任意一个(最常用破坏环路等待),就能让死锁无从发生。
核心原理(2min)
四个 Coffman 条件 + 各自的破解法:破坏持有并等待用一次性申请 / tryLock,破坏不可剥夺用 tryLock 超时,破坏环路等待用资源有序分配(最常用)。银行家算法在分配前预判安全序列,存在安全序列则必不死锁;InnoDB 用等待图检测环并回滚最小代价事务。
底层深入(5-10min)
死锁的四个必要条件(Coffman 条件)
死锁不是偶然发生的——它是四个条件同时满足的必然结果:
- 互斥(Mutual Exclusion):资源不能被共享,同一时间只能被一个线程持有
- 持有并等待(Hold and Wait):线程已经持有至少一个资源,同时在等待获取其他线程持有的资源
- 不可剥夺(No Preemption):资源不能被强制剥夺,只能由持有者主动释放
- 环路等待(Circular Wait):存在线程集合 {T0, T1, …, Tn},T0 等 T1 持有的资源,T1 等 T2…,Tn 等 T0
这四个条件是死锁的充要条件——破坏任何一个,死锁就不可能发生。
想一想:为什么说”破坏一个就能免疫”?因为四个条件是”与”的关系——必须同时满足才死锁;破坏任意一个,整套条件就不成立,死锁自然无从发生。
如何破坏这四个条件
破坏”互斥”
很难。大多数锁本身就是互斥的,除非用无锁数据结构(CAS/原子变量)或读写锁(读可共享)。
破坏”持有并等待”
一次申请所有需要的资源,没拿到就释放已持有的重新等:
// 不好:分步加锁
lock1.lock(); // 获取锁1
lock2.lock(); // 获取锁2 — 此时持有锁1等锁2,可能死锁
// 好:一次申请+tryLock
while (true) {
if (lock1.tryLock() && lock2.tryLock()) break;
if (lock1.isHeldByCurrentThread()) lock1.unlock();
if (lock2.isHeldByCurrentThread()) lock2.unlock();
Thread.sleep(randomBackoff());
}
破坏”不可剥夺”
ReentrantLock.tryLock(timeout, unit) 可以在超时后放弃等待并释放已持有锁。
想一想:为什么分步加锁会死锁、tryLock 一次申请就不会?因为 T1 拿锁1 等锁2、T2 拿锁2 等锁1 就同时满足了”持有并等待”+“环路等待”;tryLock 拿不到就全部释放重来,破坏了”持有并等待”。
破坏”环路等待”(最常用)
资源有序分配法——所有线程按统一顺序申请资源:
// 约定:永远先锁A,再锁B
// 线程1: lockA → lockB ✓
// 线程2: lockA → lockB ✓
// 永远不会形成 A→B 和 B→A 的环
数据库中也一样:所有事务按主键顺序加行锁,有效规避死锁。
想一想:为什么”永远先 A 后 B”能破坏环路等待?因为环路需要 A→B 和 B→A 两条相反方向的等待,统一加锁顺序后最多只剩 A→B 一个方向,环就无法闭合。
银行家算法:提前预判安全性
银行家算法在分配资源之前模拟判断:如果把这个资源分配给这个进程,系统是否仍存在一个”安全序列”让所有进程都能完成。有则分配,无则拒绝。
// 银行家算法核心(伪码)
bool isSafe(int process, int request[]) {
// 假设分配
Available -= request;
Allocation[process] += request;
Need[process] -= request;
// 寻找安全序列
bool finish[N] = {false};
int work = Available;
for (int i = 0; i < N; i++) {
if (!finish[i] && Need[i] <= work) {
work += Allocation[i];
finish[i] = true;
i = -1; // 重新扫描
}
}
return all(finish); // 如果所有进程都能完成 = 安全
}
存在安全序列 → 系统处于安全状态 → 一定不会死锁。但反之不成立:不处于安全状态不一定会死锁,银行家算法是最保守的策略。
想一想:为什么”不安全状态”不一定死锁?因为银行家算法是保守的——它只在”存在安全序列”时才分配,但系统可能还有别的调度方式能完成,只是算法不愿赌;所以不安全状态是死锁的必要非充分条件。
工程实践:MySQL InnoDB 的死锁检测
InnoDB 维护一个等待图(Wait-for Graph):节点是事务,有向边表示”事务 A 等待事务 B 持有的锁”。后台线程定期扫描等待图,检测到环 → 选择回滚”撤销代价最小”的事务(undo log 最少的)。
SHOW ENGINE INNODB STATUS\G 的 LATEST DETECTED DEADLOCK 部分详细记录了死锁涉及的事务、持有和等待的锁。
总结
| 策略 | 做法 | 适用场景 |
|---|---|---|
| 资源有序分配 | 约定锁顺序 | 通用,最简单有效 |
| tryLock 超时 | 放弃等待释放已持有 | 无法统一顺序时兜底 |
| 银行家算法 | 分配前预判安全序列 | OS 资源调度、连接池管理 |
| 死锁检测 | 扫描等待图,回滚最小代价事务 | 数据库(InnoDB) |
章末提问
追问 1:死锁的四个必要条件是什么?为什么说”破坏一个就能免疫”?
回答思路:结论先行——互斥、持有并等待、不可剥夺、环路等待,四个是”与”的关系、同时满足才死锁,所以破坏任意一个即可。因为缺了任何一个条件,剩余三个都无法构成死锁:比如破坏环路等待(资源有序分配),线程就不会互相等成环。
追问 2:工程上最常用的破死锁手段是什么?怎么落地?
回答思路:结论先行——破坏”环路等待”的资源有序分配法,约定所有线程按统一顺序加锁。因为环路需要 A→B 和 B→A 两条相反方向的等待,统一”永远先锁 A 再锁 B”后只剩一个方向、环无法闭合;数据库里也按主键顺序加行锁,这是最简单有效的通用方案。
追问 3:银行家算法和 InnoDB 的死锁检测有什么本质不同?
回答思路:结论先行——银行家算法是”事前预防”,分配前预判是否存在安全序列;InnoDB 是”事后检测”,用等待图找环再回滚。因为银行家算法在分配资源前模拟判断”分配后是否仍存在让所有进程完成的安全序列”,有才分配、无则拒绝,代价是最保守、吞吐受限;InnoDB 则后台扫描等待图、检测到环就回滚撤销代价最小(undo log 最少)的事务。