死锁详解:四个必要条件、Java 死锁排查与数据库死锁处理
死锁是一组执行单元互相等待对方手里的资源,等待关系绕成一个环,环上谁也走不下去。它最阴险的地方是程序没错、CPU 不忙,线程只是安静地卡住。本文把四个必要条件、三类应对策略和线上排查手段一次讲透。
它解决什么问题
严格说死锁不是被”解决”的问题,而是需要被”理解和管理”的状态。典型画面:线程 A 握着订单锁去申请库存锁,线程 B 握着库存锁去申请订单锁,两边都在等对方先放手,而对方也在等自己——没有异常、没有超时、没有高 CPU,只有接口永远不返回。
普通阻塞和死锁的分水岭在于有没有环。普通阻塞等的是一个终将到来的事件:锁会释放、I/O 会返回。死锁里每个等待者本身就是环上另一方的绊脚石,靠时间永远解不开。进程、线程、数据库事务、分布式锁,只要资源独占且允许持有后继续等待,都会踩进同一个形状。
核心原理:四个必要条件缺一不可
死锁发生必须同时满足 Coffman 提出的四个条件,少任何一个,环就闭不上:
- 互斥:资源在同一时刻只能被一个持有者占用。库存行、对象监视器、文件写锁天然如此,这是资源本身的属性。
- 占有且等待:已经拿着至少一份资源,同时还在申请新的资源。只拿一把锁的代码永远死锁不了,危险都出现在”边拿边等”。
- 不可剥夺:资源只能由持有者主动释放,外人抢不走。Java 的内置锁就没有超时和强夺机制,想撤销一个持锁线程只能kill 掉整个进程。
- 循环等待:等待关系构成闭环,A 等 B、B 等 A(或更长的链)。这是死锁的”成形”瞬间,前三个条件只是土壤。
四个条件也指明了四种破坏方向。工程上互斥和不可剥夺通常动不了——共享数据就是要排他,抢到一半的状态谁敢强行接管;真正可操作的是破坏”占有且等待”(一次申请齐全、拿不到就全放)和”循环等待”(全局锁排序)。
深入机制:预防、避免、检测三条路线
预防:让条件结构上不成立
最常用的一招是锁排序:给所有锁定义全局唯一的序号,任何线程只按序号从小到大申请。反序路径不存在,环自然无法闭合。
public void transfer(Account from, Account to, long amount) { |
另一招是限时申请:拿不到就放弃已持有的锁退出来重试,等待不再无限期。ReentrantLock.tryLock(timeout) 干的就是这件事,但注意超时只是把”卡死”变成”失败”,重试的幂等性要业务自己保证。
避免:银行家算法的思想
避免策略更精细:每次分配资源前先模拟一次——假设批准这笔申请,系统里还存不存在一个”所有进程都能陆续跑完”的安全序列?存在才批准,否则让申请者等着。
著名的银行家算法要求每个进程事先声明对各类资源的最大需求,分配时用”可用量 + 各进程已持有量”反复试探能否凑出安全序列。它精准,但落地门槛很高:业务系统很难提前说出自己的最大资源需求,请求分支随时在变,每次分配前做全局安全性检查的成本也不低。所以它的价值更多在于提供”安全状态”这个思考框架,而不是真的搬进生产代码。
检测与解除:先跑起来,出事再收尸
数据库走的是这条务实路线:允许死锁发生,后台持续维护等待图(Wait-for Graph,节点是事务,边是”在等谁”),发现成环后挑一个代价最小的事务回滚(比如修改行数少的),其余事务得以继续。代价转移给了应用层:收到死锁错误后必须重试整个事务,而不是只重发失败的那条 SQL——只补一条会破坏事务的原子语义。
工程实践:jstack 排查实战
线上接口卡住、CPU 却很低时,先连续抓两三次线程栈,间隔十秒以上:
jstack -l <pid> > t1.log |
-l 不能省,它会打印 ReentrantLock 这类 JUC 锁的持有信息。读栈时盯三类线索:
Found one Java-level deadlock: |
waiting to lock/parking to wait for:线程在等哪把锁;locked/Locked ownable synchronizers:线程已持有哪些锁;- 栈顶的业务方法名:回到代码确认加锁顺序。
如果两次抓栈都是同一批线程停在同两把锁上互等,基本可以坐实死锁;只有单次栈,可能只是瞬时阻塞。JConsole 的”检测死锁”按钮、ThreadMXBean.findDeadlockedThreads() 走的是同一套检测逻辑,适合本地复现和写进巡检脚本。
工程规避清单:全局锁排序并写进代码评审项;持锁期间不做 RPC、慢 SQL 等耗时操作;能用 tryLock 超时就别无限等;线程和锁都起有意义的名字,出事时日志里一眼定位。
对比与权衡
| 策略 | 思路 | 典型手段 | 代价 | 适用场景 |
|---|---|---|---|---|
| 预防 | 破坏四条件之一 | 锁排序、一次申请、超时放弃 | 并发度下降、编码约束 | 业务代码首选 |
| 避免 | 分配前校验安全状态 | 银行家算法 | 需预知最大需求、检查开销大 | 教学为主,通用系统少用 |
| 检测+解除 | 允许发生,成环后回滚 | 等待图检测、事务回滚重试 | 牺牲部分已做工作 | 数据库事务天然契合 |
顺带区分两个近亲:饥饿是某个线程长期抢不到资源,但系统整体还在前进(非公平锁下常见);活锁是各方不断让步又不断冲突,忙个不停却没有进展(重试节奏过于同步时容易出现)。死锁是静止的等待,活锁是忙碌的徒劳,饥饿是单向的忽略。
面试视角
死锁的四个必要条件是什么?
互斥、占有且等待、不可剥夺、循环等待,四者同时成立才发生死锁。能顺势说出每个条件对应的破坏手段(共享化、一次性申请、超时放弃、锁排序),就超出了背诵层面。
银行家算法是干什么的?
它是死锁避免算法:每次分配前检查分配后是否仍存在安全序列,存在才批准。前提是进程事先声明最大资源需求,所以实际业务系统很少直接使用,数据库更倾向事后检测加回滚。
线上 Java 程序疑似死锁怎么排查?
jstack -l 连续抓几次线程栈,找 Found one Java-level deadlock 或 waiting to lock 与 locked 的交叉引用,再回代码核对加锁顺序。记得带上 -l,否则 JUC 锁的信息不全。
死锁和活锁有什么区别?
死锁的线程停在等待态一动不动;活锁的线程一直在运行、一直互相让步重试,但没有任何一方真正完成工作。前者要解环,后者要给重试加随机退避。
小结
- 死锁的本质是等待关系成环,四个必要条件给了四个拆环的着力点。
- 工程首选预防:锁排序、缩小临界区、限时申请;银行家算法理解思想即可;数据库用检测加事务回滚兜底。
- 排查靠证据链:多次
jstack -l抓栈,确认互等环,再回到代码修顺序。

