死锁的四个必要条件:从多线程事故到系统化排查

发布时间:2026/10/14 18:34:10
死锁的四个必要条件:从多线程事故到系统化排查
1. 死锁不是玄学从一次真实的多线程事故说起三年前我在某团队维护一个订单处理服务有一阵子线上频繁报警线程池被占满请求大量超时。翻日志发现一段很诡异的循环A任务在等B任务释放锁B任务又在等A任务释放锁两边谁都不肯让步像两个堵在窄巷里的司机你看着我我看着你后面的车排成长队谁都动弹不得。当时的我只会重启服务临时止血治标不治本。直到后来系统学习操作系统与并发编程知识才真正弄明白那天事故背后的机制——死锁以及它产生的四个必要条件。死锁这个词在并发编程里几乎和“崩溃”同义但它比崩溃更难定位。崩溃至少会有堆栈、异常、错误码死锁则往往表现为“系统还活着但什么都不动”CPU占用不高、内存没有爆掉、日志停在某个位置再也不往后走。它不是一个瞬间事件而是一个系统状态——多个线程或进程各自握着资源又在无限等待对方释放资源于是集体卡死。这种状态一旦进入没有任何外力介入就永远不会自己解除。这篇文章要讲清楚的就是死锁产生的四个必要条件互斥、持有并等待、不可抢占、循环等待。这四个条件不是四个孤立的巧合而是一环扣一环的组合拳。我会一步步拆开每一条的含义、它为什么必须存在、以及现实代码里最常见的触发形式。适合正在学操作系统原理的人、写多线程代码的开发者以及那些被线上死锁折磨过但没深究根因的运维和架构师。我当时学这个知识点的时候最大的感受是教科书里那四个条件写得简单但真正到了代码里四个条件常常不是同时可见的它们被业务逻辑、中间件、连接池层层包裹隐藏得很深。所以这篇文章不会只停在理论背诵我会把每个条件对应的真实编码场景也一并说清楚。学完这四个条件你拿到一段代码或者一堆线程堆栈至少能判断出“这里存不存在死锁风险”“如果死了是哪几个条件共同导致的”。知道了条件下一步才是怎么破局。死锁的解决思路其实也很直接既然四个条件必须同时满足才会死锁那么只要破坏其中任何一个死锁就不可能发生。这就是后文要展开的重点。2. 条件一互斥——资源一次只能给一个线程互斥条件说的是资源同时只能被一个线程或进程占用。也就是说同一时刻只有一个执行流能访问某个资源或某段临界区其他线程想进来必须等。这个资源可以是共享变量、数据库里的某一行记录、一把文件锁、一个连接池里唯一空闲的连接任何“同一时间只能被一个人持有”的东西。我当年理解互斥条件时踩过的误区是以为互斥是死锁的“罪魁祸首”一看到锁就觉得危险。实际上互斥本身是必需的——如果资源可以被无限并发访问那共享数据的正确性就无从谈起。多线程写同一个变量没有互斥保护轻则数值错乱重则整个数据结构的指针都被搞坏。互斥不是问题问题是“互斥之外还叠加了后面三个条件”。举个最直观的例子。假设系统里只有一个数据库连接线程A获取了连接准备写订单表事务还没提线程B也想要这个连接但连接只有一个所以B必须等A释放。这种场景下互斥条件天然成立。如果不加互斥那是在设计上允许两个线程同时操作这个连接那就不叫死锁叫数据竞争那是另一类更隐蔽的问题。在代码里的体现通常是这样的synchronized (lock) { // 临界区对共享资源进行操作 }或者用显式锁ReentrantLock lock new ReentrantLock(); lock.lock(); try { // 临界区 } finally { lock.unlock(); }只要你用了锁、用了原子操作如CAS环里的某段逻辑、用了数据库的悲观锁或乐观锁本质上都在依赖互斥。互斥条件在所有“正常情况下应该存在”的并发程序里几乎必然成立所以它也是最难被“破坏”的一个条件——除非你重新设计数据结构让资源可被并发读或者用不可变对象、无锁数据结构来绕开互斥。但这通常成本很高不是所有场景都适合。需要注意的是互斥条件不仅仅存在于显式加锁的代码里。JVM内部的类加载锁、数据库的行级写锁、文件系统对配置文件写入时的排它锁、分布式系统中对分布式锁例如某些依赖Redis实现分布式锁的场景我提的是通用概念的占有都是同一类现象。判断一段代码是否满足互斥条件不要只看有没有lock关键字而是问自己在某个时间段内这个资源能否同时被两个执行流操作如果能它就没有互斥如果不能它就满足互斥。我自己的经验是在排查死锁时第一步不是急着找循环等待而是先列出“当前有哪些资源”。互斥条件是死的资源就摆在那里先圈出资源的边界范围后面分析其他三个条件才有落点。很多时候一个资源到底“互斥的是哪一段范围”代码里并没有写清楚这就是隐患的起点——比如用同一个锁保护两个毫不相关的资源或者用两个不同的锁保护同一个资源的两个相关字段都会导致后续的死锁逻辑变得错综复杂。顺带说一句互斥条件还有一个容易被忽略的变体同一个线程重入自己的锁。很多锁实现允许持有者重复进入这并不违反复用语义但在分析死锁时要留意因为重入可能掩盖锁的嵌套关系让后续的“持有”分析变得混乱。后面的第三个条件会涉及这点。3. 条件二持有并等待——手里占着资源腰上还别着下一把锁持有并等待翻译成大白话就是一个线程已经握住了一把锁而且在等待获取另一把锁的同时不释放自己已经持有的那把。它既不想放弃手里的又想要更多的。这在资源分配上像极了“占着茅坑不拉屎”——虽然不雅但确实准确。这个条件的关键在于“等待期间不释放已有资源”。如果线程在尝试获取第二把锁失败后主动释放掉第一把锁那死锁的第二条就不成立。可惜的是标准的加锁写法没这么智能绝大多数情况下线程获取第二把锁的方式是阻塞式的拿不到就一直挂在那里手里的第一把锁始终不放。看看这段代码典型到几乎每个讲死锁的教材都会出现// 线程A synchronized (resourceA) { System.out.println(A拿到了资源A); synchronized (resourceB) { System.out.println(A拿到了资源B); } } // 线程B synchronized (resourceB) { System.out.println(B拿到了资源B); synchronized (resourceA) { System.out.println(B拿到了资源A); } }如果线程A先拿resourceA线程B先拿resourceB然后A想拿resourceBB想拿resourceA——两个线程各自握着一把锁又在等待对方手里的锁持有并等待条件成立。这里的核心是等待是无限期的、阻塞式的、不释放已有资源的。为什么要强调“不释放已有资源”这一点因为如果加锁逻辑写成了“拿不到第二把锁就回退释放第一把”虽然不会死锁但需要额外的逻辑处理回退重试这在实现上往往更复杂。大多数语言的锁API默认不支持“尝试拿锁失败立刻返回并释放已有锁”这种模式需要你显式使用带超时的锁获取方法。比如Java里的tryLock(timeout)用它的话就能绕开这个条件后面讲对策时我们会细说。持有并等待条件的现实形态远不止两个同步块嵌套这么简单。更常见的是服务代码先对数据库某行上了锁再去调用远程服务或者先持有本地缓存锁再去访问数据库又或者先获取连接池里的一个连接然后再去申请另一个连接池的连接。中间件层面的死锁往往比教科书示例更隐蔽因为资源类型不同一个锁是JVM对象锁另一个锁是数据库行锁肉眼看不出来它们有什么上下游关系但它们确实构成了“持有等待”的链条。分析这个条件时有个很实用的小技巧把每个线程从入口到当前执行点经过的所有锁资源列成一条“锁链清单”。清单里最后一个是“当前正在持有”的清单之后再想获取的叫做“等待目标”。如果每个线程的清单末尾都有“等待目标”而且这些等待目标正好是对方清单里的末尾项那持有并等待几乎可以坐实了。另外一个值得留意的点是持有并等待不一定只涉及两把锁三把、四把甚至更多锁嵌套也是同样原理。锁嵌套的层数越多死锁发生的组合可能就越多排查越困难。这也是业界一直推崇“尽量缩小锁粒度、减少锁嵌套”的原因——每多一层嵌套就多一分意外锁交叉的隐患。4. 条件三不可抢占——别人的锁你抢不走不可抢占条件的含义很直白线程已经持有的资源不能被其他线程强行抢走只能由持有者自己主动释放。换句话说锁不是“公共汽车上的座位”——谁力气大谁就能把别人挤开它更像是酒店房间——你办完退房手续之前别人就是进不去哪怕你只是躺在床上刷手机保洁阿姨也只能在门外等着。在操作系统层面这个条件其实是所有“用户态锁”的默认行为。你把锁拿在手里别人无法通过任何手段强行打断你的持有过程更不可能把锁从你手里夺走。除非持有锁的线程自己调用了释放方法或者因为异常、崩溃导致锁被运行时环境回收比如JVM里如果持有锁的线程抛出异常退出同步块锁会自动释放否则别的线程只能无限等待。不可抢占和“优先级”无关。即便你给某个线程设置了更高的优先级它也不能从另一个线程手里抢锁。抢占资源的能力通常只存在于操作系统内核层面做某些调度决策时但那是另外一个话题了——对应用层程序员来说我们面对的锁基本都是不可抢占的。为什么死锁要求这个条件成立试想一下如果锁可以抢占持有锁的线程在等第二把锁时第一把锁被别人抢走了那持有并等待链条立刻断裂死锁循环就不成立。所以凡是允许抢占的资源分配系统天然不具备死锁的温床。但现实世界里的业务代码几乎不会允许这种抢占因为一旦允许抢占锁保护的临界区数据就可能处于不一致状态——A线程正在修改链表B线程把锁抢走也去改同一个链表数据不出错才怪。在操作系统试卷和面试题里这个条件经常被用“剥夺”这个词来描述资源不可剥夺。意思就是资源一旦分配给某个进程就只能由它主动归还其他进程无权强制拿走。经典教材里也会提到“如果某资源的竞争中出现剥夺则可以避免死锁”但这通常指的是系统设计层面的调度策略而不是应用层开发者的日常手段。代码层面和这个条件有关的一个常见陷阱是忘记释放锁。如果你持有锁的线程因为某个分支逻辑提前return了、或者抛出异常但没被正确捕获导致锁没释放比如没用try/finally那么其他线程等待的其实是一把“永久不可抢占”的锁——这把锁在持有者死后也没被释放就变成了经典的“死锁变体永久阻塞”。这种情况虽然严格来说不是四个条件都成立的死锁但症状和死锁几乎一样排查起来也极为困难。所以我在写锁相关代码时永远把unlock()放进finally块里这个习惯帮我少踩了很多坑。还有一点要分清不可抢占指的是“其他线程不能抢走我的锁”而不是“我在持有锁的同时不能被调度出去”。持有锁的线程完全可能因为时间片耗尽被切换出CPU这是完全正常的不影响锁的归属。恰恰是因为这种调度切出才出现了“持锁线程停在一个阻塞调用上其他线程干瞪眼”的场景进一步加剧了死锁的视觉迷惑性——你看起来这个线程还在运行其实它卡在某个锁获取上。5. 条件四循环等待——锁链首尾相接每个人都等别人手里的那把循环等待是四个条件里最容易被观察到的也是最“形象”的一个。它的意思是一组线程之间形成了循环的等待关系线程A等线程B手里的资源线程B等线程C手里的资源线程C再等线程A手里的资源。等你画完这张等待关系图会发现它是一个环没有起点也没有终点每个线程都在等待这个环里的下一个线程释放资源。局限于两个人之间的场景是循环等待的最简形式A持有锁1等待锁2B持有锁2等待锁1。这就是一个二人闭环。扩展到多个执行流时链条会更复杂但本质一样——所有参与者构成一个有向循环。这个环不要求“每个人只等一个人”也不要求“每人只持有一把锁”核心是“等待关系图上存在环”。循环等待和前面的三个条件是什么关系前三个条件互斥、持有并等待、不可抢占单独看都只是“风险因素”它们让死锁有可能发生但不一定发生。第四个条件——循环等待——是最终的临门一脚前三个条件都成立的基础上只要等待关系正好形成环死锁即刻成立。换句话说前三个条件是必要条件第四个条件加上前三个条件才构成死锁的充分且必要条件准确说这是死锁的充分条件组合教材里也常以四个必要条件来表述。不过严格讲前三个条件都成立的情况下死锁一定满足这四个条件四个条件同时满足时循环等待必然出现系统必定死锁。为什么说循环等待是最容易被代码审查发现的一个因为只要有死锁线程堆栈上就会出现“锁所有权沿着线程链互相指向对方”的特征。用jstack抓一次线上线程堆栈你会看到Thread-A waiting to lock 0x00000000abcd1234 (a ReentrantLock) held by Thread-B Thread-B waiting to lock 0x00000000abcd1235 (a ReentrantLock) held by Thread-A这个输出就是活生生的循环等待证据——两条线程互锁谁也走不动。看到这种堆栈死锁基本可以当场判定不需要再去反复看业务日志。很多程序员误以为“只要加锁顺序一致就不会死锁”这句话说的是“锁顺序一致可以破坏循环等待”但它并不等于锁顺序一致就是唯一解法。比如线程A拿锁1再拿锁2线程B也拿锁1再拿锁2这种顺序统一的前提下确实不会形成环因为每个线程的“锁链方向”一致等待关系只有单向的。这就是后面要讲的“锁排序法”的理论基础。循环等待还有一层容易忽略的维度等待的对象不一定是另一个线程显式持有的锁还可能是信号量、条件变量、共享资源的引用计数、甚至是分布式服务里的某个租约。只要多个执行流的“等到资源”和“持有资源”形成闭环就满足循环等待。比如“线程A占用本地连接池的最后一根连接正在等待远程服务返回线程B占用了远程服务的某个令牌正在等待本地连接池释放连接”这同样可以构成一个跨系统、跨网络的循环等待——只不过堆栈里不会直接看到两个锁名得靠调用链分析才能梳理出来。到这里四个条件已经全部介绍完毕。把它们放一起再嗡嗡一遍第一资源不能共享同一时刻只能被一个线程用互斥第二线程握着自己的资源继续申请别人的资源且不放手持有并等待第三别人的资源抢不过来只能等对方主动释放不可抢占第四等着等着等待关系正好绕成一个环循环等待。这四条凑齐系统必然死锁。6. 体系化排雷用四个条件快速定位线上死锁理论讲完得落地。这部分分享一套我自己在排查线上死锁时实践过的方法论核心思路就是逐条套用四个条件做筛选。拿到疑似死锁的现场我建议按下面的步骤走。第一步收集线程堆栈和系统快照。线上环境推荐把jstack、pstack工具提前为运维配好出事时尽量在第一时间抓堆栈死锁状态一旦被外部干预比如重启就再也看不到现场了。如果用的是分布式系统还需要把涉及的所有节点的调用链日志聚齐因为死锁可能跨服务存在。第二步找“互斥资源”。在一张白纸上画出当前系统里所有可能被独占的资源本地锁对象、数据库行锁、连接池连接、文件句柄、分布式锁、信号量等。标记出它们的当前归属。这一步看起来笨但其实非常有效它能防止你在后续分析中被业务概念绕晕——你只需要资源层面的关系图。第三步检查“持有并等待”。逐个线程追踪它当前持有哪些资源、正在尝试获取哪些资源。把尝试获取但没成功的操作列出来这些就是“等待目标”。这里的判断要点是等待的目标是否和某个已持有资源属于同一个线程的锁链中。如果一个线程持有了A资源还在等B资源那“持有并等待”就在它身上成立。第四步确认“不可抢占”。这一步通常不需要额外验证因为我们用的锁几乎都是不可抢占的。只要确认没有使用带抢占或超时抢占语义的特殊调度机制这一条默认成立。不过在操作系统层面如果涉及内核线程或实时调度需要额外确认是否有优先级反转或优先级继承机制改变了抢占行为应用层分析时默认成立即可。第五步画“等待关系图”找环。把每个线程和它等待的目标画成有向边从等待者指向资源持有者。检查图中是否存在环。存在环且前面四条都成立确诊死锁不收质疑。这个方法论我用在了之前那次订单服务事故的复盘上。当时我们收集了线程堆栈发现核心业务线程池里的多条线程全部卡在数据库连接获取上有些线程已经占了数据库的行锁正在等连接池另一些线程占了连接池连接正在等数据库行锁。表面看是“数据库锁”和“连接池”两类毫不相干的资源但画完资源关系图之后循环等待一目了然线程组A持有数据库行锁等待连接池线程组B持有连接池等待数据库行锁两边各执一手互不相让。这次复盘让我彻底意识到四个条件不只是考卷上的名词解释它们是一套异常实用的排雷工具。顺带也讲一下怎么预防。既然死锁需要四个条件同时成立开发阶段的代码评审就应该带着这四个条件去扫看到锁嵌套就问一句“是否持有并等待”看到多个线程以不同顺序申请相同资源就问一句“是否存在循环等待”看到资源数量有限就问一句“互斥后谁可以释放”。这比单纯的“小心死锁”这种空洞提醒有用得多。7. 对症下药破坏其中一个条件死锁当场失效现在到了最实战的部分触发死锁之后怎么设计对策。前面反复说过四个条件必须同时成立这反过来就是解决方案的思路——破坏掉任意一个条件死锁就不可能发生。以下是我在实际项目中用过、也见过别人用得比较多的四类手段。第一类破坏互斥条件让资源不被独占。代表性做法是使用读写锁允许多个线程同时读共享数据只有写操作才互斥。更激进的做法是使用不可变对象数据从不修改线程无需加锁就能安全并发访问。还有无锁数据结构如CAS用原子操作替代锁从根上绕开独占资源的问题。这类手段的局限是适用场景有限——并不是所有资源都能做读多写少拆分也不是所有数据都适合做成不可变。所以破坏互斥条件通常只在设计阶段考虑运行时很难临时处理。第二类破坏持有并等待要么一次性拿全所有资源要么拿不到就退让。一次性全部申请的方式在数据库事务里表现为“按固定顺序在同一事务内锁定所有需要访问的表”而在内存锁层面可以通过“锁排序同时获取”来实现但实现复杂度较高。更通用的做法是“获取锁可以失败”使用带超时获取的锁方法例如Java的tryLock(3, TimeUnit.SECONDS)。如果规定时间内拿不到第二把锁就放弃已经握在手里的第一把锁等待一段时间后重试。这种做法直接破坏了持有并等待——线程在等第二把锁时不再紧攥第一把锁不放而是主动退场让出资源。第三类破坏不可抢占允许系统强制收回资源。这个在应用层很少用因为它容易导致临界区数据不完整。操作系统级别的资源剥夺、事务回滚机制算是这一类的变体——数据库检测到死锁后回滚其中一个事务等价于“强行收回该事务持有的所有行锁”。所以很多数据库死锁的“自动化解决”其实是系统层面动的手某个事务被选为牺牲者回滚剩下的继续执行。这种策略对业务有侵入通常只在数据库、消息队列等基础设施里体现。应用层设计时不建议自己实现抢占逻辑。第四类破坏循环等待统一资源获取顺序或者采用层级锁。所谓统一顺序就是说所有线程都按相同的顺序申请资源。比如规定“必须先拿资源A再拿资源B”那么两个线程都不会出现“一个先A后B、另一个先B后A”的交叉环就断了。用代码的形式就是// 强制所有加锁遵循先 resourceA 后 resourceB public void doSomething() { synchronized (resourceA) { synchronized (resourceB) { // ... } } }锁排序法是最简单、成本最低的预防手段缺点是它要求所有相关代码都自觉遵守同一套顺序没法在语言层面强制。一旦有人写出反序的加锁死锁又会在新的代码路径里重生。所以很多大型项目会在代码规范里明令禁止并发代码中出现不同顺序的多把锁获取再配合代码评审、静态检查工具来保障。还有一个更硬的方案是“完全避免锁嵌套”在任何一把锁的临界区里绝不获取另一把锁。这个规则一旦被约束住循环等待的环就无从构造——每个线程在任何时刻最多只持有一把锁。代价是很多业务逻辑需要重构特别是那些需要跨多个资源联合操作的场景可能要拆成两阶段操作。但从工程稳健度讲收益极大。从我的实践来看线上死锁处理不是单选题。最合理的策略通常是第一在代码设计阶段就用锁排序规范防死锁第二在会持锁等待的阻塞调用处加上超时回退逻辑第三在数据库层依赖系统的事务死锁检测与回滚机制兜底第四在监控层面定期采集线程堆栈设置疑似死锁告警。四条防线叠加基本能把死锁风险压到很低。8. 两个容易误判的场景资源耗尽 与 活锁后端开发做久了会发现一个现象——有些故障看起来像死锁线程堆栈也停在某处不动但严格说并不是四个必要条件都满足。这里挑两个最容易被误判的场景说一说。资源耗尽是最常见的“伪死锁”。比如线程池里的线程全部在等待某个远程接口返回而这个远程接口本身处理能力有限所有线程都卡在IO等待上。从外面看线程池满了、请求超时了、日志不动了像极了死锁。但你把线程堆栈拉出来看会发现这些线程并没有持有另一把锁在互相等它们只是在等一个外部系统。这种情况的本质是“某个关键资源不足”不是四条件成立。处理方式是扩容、限流、降级或给外部调用加超时和死锁的解法完全不同。所以排查时第一件事是确认是否存在锁循环等待不要被“线程卡住”这个表面现象带偏。活锁是另一个经典误判。活锁中线程并没有阻塞它一直在运行不断尝试拿资源但拿不到——可能因为拿锁条件过于苛刻也可能因为多个线程都在相互礼让。最形象的例子是两个人面对面走窄路双方都想给对方让路结果同时向左、又同时向右、再同时向左……谁都没法前进。活锁里线程是“活”的CPU占用可能明显升高但业务进展为零。这也不满足互斥持有并等待不可抢占循环等待的全部条件因为线程并没有无限阻塞在锁获取上而是在不断地重试或跳出。处理活锁的常用手段是引入随机退避让双方的重试时间错开而不是每次都在同一时刻重试。分清死锁、资源耗尽、活锁这三者的意义在于“对症下药”。死锁的核心关键词是“循环等待闭环”排查对象是锁依赖图资源耗尽的核心关键词是“容量不足”排查对象是线程池、连接池、缓冲区的水位活锁的核心关键词是“信号空转”排查对象是重试逻辑和退避算法。拿死锁的方案去解资源耗尽或者拿扩容去解死锁都不可能奏效。回到最开始的那次线上事故如果当时我掌握这套四条件分析框架处理过程会快很多。当时我们用了将近半天时间反复重启、调整超时参数却治标不治本事后画清楚锁依赖图才发现是数据库行锁与连接池形成的循环等待。从此以后每次写多线程代码我脑子里都会过一遍这四个条件这个加锁路径会不会出现两个线程以相反顺序获取同一组锁持锁的代码段里有没有可能发生阻塞等待而等待的资源又持有别的线程已经占用的东西这些问题想到前面线上就能少折腾一半。死亡锁的四个必要条件不是一块知识拼图而是一张安全检查清单值得刻在每一个写并发代码的程序员脑子里。