ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

死锁的四个必要条件深度解析:从原理到线上排查实战

死锁的四个必要条件深度解析:从原理到线上排查实战

1. 死锁是什么,以及为什么偏偏是"四个必要条件"

干并发开发或者系统设计的人,几乎都绕不开死锁。面试里它是经典题,工作里它是线上事故的常客。所谓死锁,就是一组线程或者进程,每个都在等一个由别人持有的资源,结果大家互相等,谁也没法推进。资源可能是数据库行锁、文件句柄、内存块,也可能是你代码里一个简单的synchronized锁。我见过最典型的线上案例,就是两个服务互相调用,各自持着数据库连接不放,又去等对方的接口返回,最后连接池耗尽,整个服务像冻住了一样。

很多人背过死锁的四个必要条件:互斥、持有并等待、不可剥夺、循环等待。但说实话,光会背没什么用。真正的价值在于:线上出问题时,你能拿着这四个条件一条条对照,判断当前到底是不是死锁、卡在哪个环节、该从哪里下手。这篇文章我想从这个角度重新梳理一遍,结合我实际踩过的一些坑,把每个条件背后的原理、常见误区和应对思路讲透。

这个内容适合正在学操作系统或并发编程的学生,也适合写后端服务、做中间件维护的开发者。哪怕你只是日常写点多线程工具,理解了这四个条件,以后排查问题会少走非常多的弯路。

2. 四个必要条件逐个拆解:每个条件到底卡在哪一环

四个条件不是四个独立的知识点,它们分别代表了死锁形成过程中"资源属性、持有行为、分配策略、等待拓扑"这四个维度。下面一个一个看。

2.1 互斥条件:资源一次只能归一个线程用

互斥条件说的是,资源本身是排他的,同一时刻只能被一个线程或进程占用。数据库里的一条行记录锁、一把可重入锁、一个写文件的操作,本质上都是排他资源。如果资源可以同时被多个线程使用,那根本不存在"你占用着所以别人必须等"这种前提,死锁自然无从谈起。

这里要特别注意一个容易踩的误区:不是所有资源都在讨论范围内。像纯只读的配置数据、无状态的接口服务,这些天然可以并发共享,它们不会构成死锁条件。死锁讨论的资源,专指那些"必须独占才能保证数据一致性"的资源。理解了这一点,你就明白为什么很多系统设计里极力避免全局排他锁——因为每引入一个排他资源,就相当于给死锁增加了一分可能性。

一个生活化的类比:卫生间就是典型的互斥资源。同一时刻只能一个人在里面,其他人想用只能排队。但注意,只有"一个人占着卫生间又等着别人手里的东西"这种情况,才会埋下死锁的种子。单独一个"互斥"条件本身是无害的,它是死锁的土壤,但不是死锁本身。

2.2 持有并等待条件:占着不放,还想要更多

这个条件描述的是线程的典型行为:手上已经攥着一个资源,不释放,同时又在申请另一个被别人持有的资源。翻译成人话就是——"我手里这个我不放,我还要你手上那个"。

举个具体场景,代码里常见的嵌套锁:

synchronized (lockA) { // 做一些需要 lockA 的操作 synchronized (lockB) { // 做一些还需要 lockB 的操作 } }

当两个线程分别持有 lockA 和 lockB,同时又都试图进入对方持有的锁时,"持有并等待"就出现了。这是死锁最容易发生的形态:多个资源之间存在依赖关系,而获取顺序又不一致。

想破坏这个条件,思路很简单粗暴:要么一次性把所有需要的资源都拿到手再开工,拿不到就全放弃,也就是"全有或全无"的申请方式;要么在等待期间放掉已经持有的资源。实际工程里,"一次性申请全部资源"有时候做起来很别扭,因为你未必一开始就知道后面要碰哪些锁。所以这个条件更多是帮助我们意识到:锁的嵌套越深,死锁风险越大,代码审查时要格外留意一个方法里嵌套了几个锁。

2.3 不可剥夺条件:资源只能由持有者主动释放

不可剥夺说的是,一个线程持有了资源之后,其他线程没办法强行把资源抢走,只能等持有者自己释放。这个条件在操作系统层面和锁实现层面体现得非常明显。

对比一下两种资源:CPU 这种资源是可剥夺的,操作系统调度器看时间片用完,直接就把 CPU 从当前进程手里抢走给下一个进程。但锁资源不一样,synchronized或者数据库行锁,一旦某个线程拿到了而它不主动释放,别人就只能干等。你没法从外部把一个持有锁的线程的锁"扯"下来,除非让它崩溃回滚。

这就是为什么"不可剥夺"在死锁分析里特别关键:如果资源可被剥夺,死锁就不会长期维持。比如ReentrantLock提供了tryLock(long timeout, TimeUnit unit),等待超时就可以自动放弃,相当于在软件层面给"不可剥夺"开了一个口子——等不到就撤,不让死锁真的形成。后面讲实操时我会重点说这个。

2.4 循环等待条件:等待关系连成一个环

这四个条件里,循环等待是最直观、也最容易识别的。它说的是,线程之间的资源等待关系形成了一个首尾相接的环。A 等着 B 手里的资源,B 等着 C 手里的资源,C 又在等 A 手里的资源,大家围成一圈,谁也出不去。

判断一个死锁是否成立,最直接的方法就是画出"等待图":线程是圆圈,资源是方块,谁持有谁连线,谁等待谁连线。如果图上存在一个由"等待边"首尾相连的环,那循环等待条件就满足了。配合另外三个条件,基本可以断定死锁已经发生或即将发生。

破坏循环等待的方法也最经典:给所有资源排一个全局顺序,所有线程都按同样的顺序获取资源。比如有转账场景里,两个账户要互相扣钱、加钱,如果线程 A 先锁账户 1 再锁账户 2,线程 B 也先锁账户 1 再锁账户 2,那等待环就断掉了。因为你永远不会出现"一个线程拿着编号大的锁去等编号小的锁"这种情况。这个方法之所以被广泛使用,是因为它不改资源属性、不强迫一次性申请,只需要在编码规范层面约定好获取顺序,成本最低、效果最直接。

3. 用完整的例子把四个条件串起来:一个真实会发生的死锁

理解了单个条件,还是得看它们组合起来是什么样子。我拿一个几乎所有并发教程都会提到的转账例子,把复现过程完整走一遍。你没有实际复现过死锁,就很难对它有真正的体感。

3.1 经典转账场景:问题怎么写的

假设有一个账户类,每个账户有一把锁,转账操作需要锁住转出账户和转入账户两个对象:

public class Account { private int balance; private final Object lock = new Object(); public void transfer(Account target, int amount) { synchronized (this.lock) { // 先锁当前账户 synchronized (target.lock) { // 再锁目标账户 if (this.balance >= amount) { this.balance -= amount; target.balance += amount; } } } } }

表面上看没什么问题。线程 A 调用account1.transfer(account2, 100),线程 B 同时调用account2.transfer(account1, 100)。A 拿到 account1 的锁,B 拿到 account2 的锁,然后 A 等着 account2 的锁,B 等着 account1 的锁。你等一等,我等一等你,程序就卡死在这里。

这段代码我建议你亲手跑一遍,加个循环让两个线程反复同时转账。你会看到程序大概率在某次运行中卡住,而且不一定每次都卡——这正是死锁最令人头疼的地方:它不是必现的,而是依赖线程调度时序的偶发问题。很多人在测试环境没复现就以为没问题,结果一上生产就出事故,原因就在这。

3.2 用四个条件逐条验证:是真的死锁,还是只是卡顿

光看现象"程序卡住"还不够,得用四个条件做一个严格判定。一条条对照:

  • 互斥:账户锁同一时间只能被一个线程持有,满足。
  • 持有并等待:A 持有 account1 的锁还在等待 account2 的锁,B 持有 account2 的锁还在等待 account1 的锁,满足。
  • 不可剥夺:Java 的synchronized锁只能持有者主动退出同步块才释放,外部无法强行夺取,满足。
  • 循环等待:A 等 B,B 等 A,形成一个长度为 2 的环,满足。

四个条件同时满足,可以确认这就是死锁。注意"满足四个条件"判断的是"死锁已经发生"或"必然发生",而不是"可能会发生"。一个系统只要具备这四个条件的组合,死锁就是确定性的,差别只是触发频率和时间点。

这也揭示了一个非常重要的观念:四个条件是死锁的"充分条件"还是"必要条件"?准确地说,四个条件同时满足是发生死锁的"充分必要条件"——四个同时满足,死锁必然发生;反过来,只要死锁发生,四个条件一定同时满足。正是因为这种等价关系,我们才能在预防死锁时"破坏任意一个条件即可":破坏一个,就等价于让"同时满足"不可能发生,死锁自然被杜绝。

3.3 有了例子之后:四种破坏思路怎么落地

对着四个条件,分别有不同的破解方法:

  • 破坏互斥:把排他资源改成共享资源。比如用读写锁让多个读线程并行,只有写线程才需要排他;再比如用无锁数据结构(原子变量、不可变对象)彻底避开锁。这个方法最激进,适用场景也有限,但效果最好。
  • 破坏持有并等待:改成一次性获取所有资源。比如转账时先同时锁住两个账户,操作完一起释放;拿不到任何一个就一个都不拿。Java 里可以用tryLock获取多个锁,全部成功才继续。
  • 破坏不可剥夺:给等待加超时。ReentrantLock的tryLock(3, TimeUnit.SECONDS)就是典型做法,等不到就放弃并释放自己已持有的资源,避免死等。
  • 破坏循环等待:锁排序。这是工程里最推荐、改动也最小的方案。给账户按 ID 排序,无论从哪个账户转哪个,都先锁 ID 小的那个,等待环就不可能形成。

第四种是我自己项目中用得最多的。实现也不复杂:

public void transfer(Account a, Account b, int amount) { Account first = a.getId() < b.getId() ? a : b; Account second = a.getId() < b.getId() ? b : a; synchronized (first) { synchronized (second) { // 转账逻辑 } } }

这个方案最大的优点是不用改资源模型、不用引入复杂的重试机制,只要团队遵守"所有多资源操作都必须按固定顺序加锁"这一条约定,死锁就等于从系统里消失了。

4. 实践经验:项目里真正遇见死锁时,怎么定位和解决

理论说再多,最终还是要落到"线上出了问题怎么办"。这一节我分享一些真实干活时积累的东西:用什么工具看现场、常见误判有哪些、为什么死锁修复后还会反复出现。

4.1 判断死锁的三个快速方法

第一种,看现象。死锁出现时,系统不会崩溃,而是表现为:相关线程全部卡死、CPU 占用率反而很低、请求积压、接口超时。因为线程都在阻塞等待,没在计算,所以 CPU 往往很闲,这是死锁和死循环最大的区别。死循环是 CPU 飙高,死锁通常是 CPU 空闲加线程全 BLOCKED。

第二种,看线程转储。Java 环境下用jstack <pid>或者kill -3 <pid>,里面会有非常明显的线索:多个线程状态为BLOCKED,并且waiting to lock的对象互相指向。我随便写一个简化版示例:

"Thread-1" #13 prio=5 os_prio=0 tid=0x... nid=0x... waiting for monitor entry [0x...] java.lang.Thread.State: BLOCKED (on object monitor) at Demo.transfer(Demo.java:25) - waiting to lock <0x000000076b3a32e8> (a com.example.Account) at Demo.lambda$main$1(Demo.java:49) at java.lang.Thread.run(Thread.java:748) "Thread-2" #14 prio=5 os_prio=0 tid=0x... nid=0x... waiting for monitor entry [0x...] java.lang.Thread.State: BLOCKED (on object monitor) at Demo.transfer(Demo.java:23) - waiting to lock <0x000000076b3a32d0> (a com.example.Account) ...

看到两个线程各自"waiting to lock"的对象彼此是对方持有的,基本就可以下结论了。数据库场景则看information_schema.INNODB_TRX和INNODB_LOCK_WAITS,能直接看到事务等待链。

第三种,如果不想逐条看,也可以直接用 Java 自带的ThreadMXBean,在代码里定期探测:

ThreadMXBean tmx = ManagementFactory.getThreadMXBean(); long[] ids = tmx.findDeadlockedThreads(); if (ids != null) { System.out.println("Deadlocked threads: " + Arrays.toString(ids)); }

这个方法适合做监控告警,避免死锁发生后没人发现、一直拖到用户投诉。

4.2 排查时的几个容易踩的坑

第一个坑:把"活锁"和"饥饿"当成死锁。活锁的线程虽然没有陷入永久阻塞,但一直在互相谦让、反复重试,导致没有任何进展,CPU 占用会偏高;饥饿则是某个线程一直拿不到资源,但其他线程运行正常。这两种情况在jstack里一般不会看到互相持锁的 BLOCKED 环,需要区分处理。

第二个坑:只盯着代码层面的锁,忽略了分布式锁。很多现代系统里的死锁,发生在分布式锁层面——两个服务各自持着对方的分布式锁,互相等。这种死锁在单一机器的线程转储里根本看不到,要把每个服务的锁持有情况汇总到一块,画全局的等待依赖图才能看清。排查难度比单机死锁高一个量级。

第三个坑:以为修好一处就万事大吉。我遇到过某个项目,按照锁排序修好了转账的死锁,过了两个月又出了新死锁,一查才发现团队后来新加了一个批量操作接口,没按排序规则锁账户。死锁治理不是写一次就结束,而是在代码审查里持续检查"所有多资源锁获取是否遵循同一顺序"。

4.3 几个日常编码就能执行的避坑建议

结合我这几年写并发代码的经验,给你几条比较具体的建议:

  • 锁的粒度宁小勿大。锁住的范围越大,持有锁的时间越长,与其他线程的交集越多,死锁概率越高。我见过有人图省事,直接在服务方法上挂大锁,把一堆无共享变量的逻辑都锁了进去,结果并发一上来就出事。
  • 嵌套锁用之前先问自己:能不能拆成无锁或者单锁操作?很多嵌套锁是因为数据模型没设计好,导致不得不跨多个资源做一致性更新。能通过调整表结构、引入事件补偿机制解决的问题,比硬扛嵌套锁要优雅得多。
  • 能用tryLock的地方,别用lock()死等。给锁加超时,等于给死锁设了一个熔断器。就算真的碰上了资源竞争,至少超时后你可以选择降级、报错、重试,不会把线程永久挂死。
  • 记录加锁顺序的约定。代码规范里明确写"所有多锁操作必须按资源 ID 升序获取",并在 code review 时把这作为硬指标。这是成本最低、见效最快的防死锁手段。
  • 加锁的代码块里避免做耗时操作,尤其是网络 I/O、磁盘 I/O。持锁时间越长,别人等待的时间越久,持有并等待的窗口就越大。

5. 我的个人体会

死锁这四个必要条件,初学的时候觉得是八股文,干得久了才发现它们其实是排查并发问题的底层框架。我自己的习惯是,不管多复杂的死锁现场,都先按这四个条件过一遍:卡在哪个资源上、这个资源是不是排他的、持有者是否还想要别的资源、等待链能不能闭合成环。每一环都检查完,问题基本就定位得八九不离十。

最后再分享一个小技巧:在代码评审时,如果看到某个方法里出现了多层锁嵌套,不要只问"会不会死锁",而是直接追问"这几把锁的获取顺序在所有调用路径上是不是一致的"。很多时候,每个调用点单独看都合理,串起来才会发现顺序交叉。把锁获取顺序画成一张表贴在模块文档里,比事后排查要省事太多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进