1. 完整回答
死锁是指并发执行时,两个或多个线程、进程或者数据库事务互相等待对方持有的资源,导致相关任务都无法继续推进。它和普通的锁竞争不同:普通等待只要持锁方执行完就能结束,而死锁形成了无法自行解除的等待闭环。
比如线程 A 先拿到锁 1,再申请锁 2;线程 B 恰好先拿到锁 2,再申请锁 1。此时 A 等 B 释放锁 2,B 又等 A 释放锁 1,双方都无法往下执行。经典的死锁需要同时具备四个必要条件:互斥、占有且等待、不可抢占、循环等待。处理死锁的关键,就是破坏其中一个条件,或者在死锁发生后检测并恢复。
在业务开发中,通常优先采用统一加锁顺序:比如两个账户之间转账,无论转账方向如何,都先锁账户编号小的,再锁编号大的,这样就不会出现相反的锁依赖。同时,尽量缩短持锁时间;需要灵活退出等待时,可以使用带超时的锁获取,并在失败后释放已经拿到的锁。对于数据库,像 MySQL 的 InnoDB 通常会检测死锁并回滚其中一个事务,应用侧还要能够重试完整事务。
所以,死锁问题既涉及操作系统里的资源分配,也经常出现在 Java 多线程、数据库并发更新和多资源协调的业务场景中。实际治理时,我会把固定资源顺序、减少锁持有时间、检测等待关系和失败后正确恢复结合起来使用。
2. 核心原理与关键概念
2.1 核心原理
形成过程:
线程 A 获取锁 L1,线程 B 获取锁 L2 → A 持有 L1,等待 L2 → B 持有 L2,等待 L1 → 等待关系形成闭环,双方都无法继续执行并释放已有锁。
死锁的四个必要条件:
| 条件 | 含义 |
|---|---|
| 互斥 | 某个资源同一时刻只能被一个执行者占用。 |
| 占有且等待 | 已经持有资源的执行者,在不释放它的情况下继续等待其他资源。 |
| 不可抢占 | 已分配的资源不能被外部安全地强行收回,只能由持有者主动释放。 |
| 循环等待 | A 等 B、B 等 C、C 又等 A,形成环形依赖。 |
这四个条件是死锁的必要条件,一般不能仅凭“四条件都成立”就断定已经死锁;还要看实际资源分配和等待关系。
判断过程:
记录谁持有资源、谁在等待资源 → 建立资源分配图或线程/事务等待关系 → 查找循环依赖。如果每类资源只有一个实例,资源分配图出现环就意味着死锁;如果某类资源有多个可替代实例,有环不一定已经死锁,因为环外的执行者可能释放另一个实例,让等待继续推进。
处理过程:
设计阶段通过固定资源获取顺序等方式预防死锁 → 资源申请时根据安全状态决定是否分配,属于死锁避免 → 运行时识别等待闭环,再通过回滚事务、终止任务或释放可恢复资源进行检测与恢复。这些是不同的策略,并不是每个系统都会全部采用。
2.2 关键概念
| 概念 | 含义 |
|---|---|
| 锁持有者与等待者 | 持有者已经占用资源;等待者正在申请资源。排查时要同时看二者。 |
| 等待关系图 | 以线程或事务为节点,A → B 表示 A 正等待 B 释放资源,用来观察依赖闭环。 |
| 全局加锁顺序 | 所有执行者按相同、稳定的规则获取多个锁,从设计上消除循环等待。 |
| 安全状态 | 存在一种顺序,能让当前所有任务依次获得所需资源并完成;银行家算法据此决定是否批准资源申请。 |
| 死锁牺牲者 | 检测到数据库死锁后,被选中回滚以打破循环等待的事务。 |
3. 高频面试追问
3.1 死锁为什么必须满足四个条件?满足了就一定死锁吗?
四个条件缺一不可,但在一般资源模型下,同时满足也不一定已经死锁。 比如只要规定线程必须按照统一顺序获取锁,就破坏了循环等待条件;只要获取第二把锁失败时主动释放第一把锁,就能打破持续的占有且等待。
之所以不能反过来直接推断,是因为某些资源可能有多个实例。即使图上有环,也可能有其他执行者释放一个资源实例,使环上的任务恢复执行。判断是否真正死锁,最终还是要看等待能否被解除。
3.2 如何判断两个线程是否发生了死锁?
最直接的方法是看有没有无法解除的循环等待。 比如 A 等 B 持有的锁,B 又等 A 持有的锁,就形成了 A → B → A。对于普通互斥锁,如果每个等待的锁都只有一个持有者,这种闭环可以判断为锁死锁。
但不能把“线程长时间处于阻塞状态”直接等同于死锁。它也可能只是持锁线程运行较慢,或者在等待数据库、网络、线程池任务。因此我会同时确认等待什么、谁持有它、持有者又在等待谁。
**继续追问:**资源分配图有环就一定是死锁吗?
不一定。每类资源只有一个实例时,有环是充分条件;存在多个可替代实例时,有环只能说明可能死锁,不能只凭环下结论。
3.3 死锁预防、死锁避免和死锁检测有什么区别?
预防是从规则上不让必要条件同时成立;避免是每次分配资源前判断是否安全;检测是允许死锁发生,再找出来并恢复。
比如统一加锁顺序属于预防。银行家算法属于避免:它需要知道各任务最大资源需求、当前已分配资源和剩余可用资源,只在分配后仍存在安全执行顺序时批准请求。数据库引擎自动发现循环等待并回滚某个事务,则属于检测与恢复。
**继续追问:**进入不安全状态,是不是说明已经死锁?
不是。不安全状态表示无法保证所有任务都能按某个顺序完成,但当前不一定存在死锁;它只是存在将来进入死锁的风险。
3.4 为什么统一加锁顺序能避免死锁?
因为它从根本上消除了锁之间的环形依赖。 如果所有线程都必须先获取编号小的锁,再获取编号大的锁,就不可能有人持有大编号锁,又反过来等待小编号锁。
例如账户 A 向 B 转账和 B 向 A 转账同时发生,我会让两个操作都按账户 ID 从小到大加锁,而不是按“转出账户、转入账户”的业务顺序加锁。这样即使出现竞争,也只是一个事务先等另一个完成,不会形成这两把锁之间的死锁。
前提是相关代码路径都遵守同一规则;如果其他模块使用相反顺序,风险依然存在。
3.5 synchronized 和 ReentrantLock 会发生死锁吗?tryLock 能解决吗?
两者都可能死锁,可重入也不等于不会死锁。 可重入只是同一个线程可以再次获得自己已经持有的那把锁,不代表它能获得另一个线程持有的锁。
区别是,synchronized 进入监视器锁时不支持设置获取超时;ReentrantLock 提供 tryLock、限时获取和可中断获取。比如先拿到锁 A,再限时尝试锁 B,如果失败就释放 A,避免一直占着 A 等下去。这里必须把释放逻辑放在 finally 中,并配合合理的退避策略;否则频繁同时重试,虽然没有死锁,也可能出现活锁。
3.6 Java 服务疑似死锁,线上怎么排查?
我会先拿线程栈,再还原锁的持有和等待关系,而不是只看 CPU 使用率。 例如用 jcmd <pid> Thread.print 获取线程信息,重点看线程卡在哪个调用位置、等待哪把锁、这把锁被哪个线程持有,再向下追踪是否构成闭环。
如果是 Java 监视器锁或支持检测的独占同步器,也可以用 ThreadMXBean.findDeadlockedThreads() 辅助定位。找到涉及的业务代码后,我会检查加锁顺序、锁内是否有耗时操作,以及异常路径是否正常释放锁。需要注意,线程死锁检测工具不一定能识别数据库等待、线程池任务相互等待等所有业务层面的卡住问题,这类情况还要结合调用链和运行日志分析。
3.7 MySQL InnoDB 为什么会出现死锁?数据库怎么处理?
核心原因同样是事务之间产生了循环锁等待。 例如事务 T1 先更新记录 A,再更新记录 B;事务 T2 先更新 B,再更新 A。并发执行时,T1 持有 A 的锁等待 B,T2 持有 B 的锁等待 A,就会死锁。
InnoDB 默认启用死锁检测,发现死锁后会选择一个事务回滚,让另一个事务继续执行。被回滚的事务需要由应用重新执行,数据库并不会替应用自动重试整个业务操作。排查时可以用 SHOW ENGINE INNODB STATUS 查看最近一次死锁记录,再结合 SQL、索引和事务执行顺序分析。
**继续追问:**只更新一条业务数据,也可能发生死锁吗?
可能。数据库实际加锁对象可能涉及多条索引记录或索引范围,不能只根据 SQL 表面上更新了几行来判断是否有死锁风险。
3.8 数据库死锁和锁等待超时有什么区别?应该怎么重试?
死锁是循环等待,锁等待超时只是等待时间超过阈值,两者不能混为一谈。 在默认的 InnoDB 行为下,检测到死锁会回滚整个事务;普通锁等待超时通常只回滚当前失败的 SQL 语句,不会自动回滚整个事务。
所以遇到死锁,我会在明确的事务边界上重新执行完整事务;遇到锁等待超时,则先确认当前事务状态,业务上通常会主动回滚后再决定是否重新执行。重试需要设置次数上限和退避时间,涉及外部支付、消息发送等副作用时还要考虑幂等,不能简单地无限循环重试。
3.9 项目中怎样降低死锁发生率?
我会优先改资源访问顺序和事务范围,而不是只调大超时时间。 具体来说,多个账户或多张表的更新采用固定顺序;事务尽量短,拿到锁后快速完成计算和提交,不在事务里等待用户输入或执行慢速外部调用;对更新条件建立合适的索引,减少不必要的索引扫描和加锁范围。
如果已经在线上出现过死锁,我会保留死锁日志、定位具体 SQL 或代码路径,再针对相反的加锁顺序设计并发测试,验证修改后的行为。同时保留事务失败重试机制,因为复杂系统里很难保证任何时刻都不出现数据库死锁。
**继续追问:**降低事务隔离级别,就能彻底消除死锁吗?
不能。隔离级别可能改变部分读操作和锁的行为,但并发事务之间的写冲突仍然可能形成循环等待,不能把调整隔离级别当成根治方案。
4. 易混淆概念对比
4.1 死锁、普通锁等待、活锁与饥饿
| 概念 | 关键区别 | 典型表现 |
|---|---|---|
| 死锁 | 存在无法自行解除的相互等待 | A 等 B,B 又等 A,都无法推进。 |
| 普通锁等待 | 等待关系没有形成无法解除的闭环 | A 等 B 释放锁,B 正常执行完就能释放。 |
| 活锁 | 执行者一直在活动,但始终没有有效进展 | 双方不断获取失败、释放、立即重试。 |
| 饥饿 | 某个执行者长期得不到执行机会或资源 | 低优先级线程始终抢不到锁。 |
4.2 死锁预防、避免与检测恢复
| 策略 | 核心思路 | 代表做法 |
|---|---|---|
| 预防 | 从设计上破坏死锁必要条件 | 统一加锁顺序、失败时释放已持有资源。 |
| 避免 | 分配前判断会不会进入不安全状态 | 银行家算法、安全序列判断。 |
| 检测与恢复 | 允许出现死锁,发现后解除 | 构建等待关系、回滚牺牲者事务。 |
4.3 资源分配图与等待关系图
| 概念 | 图中节点与边 | 使用时的判断 |
|---|---|---|
| 资源分配图 | 既有执行者也有资源;边表示申请或占有 | 单实例资源有环可判断死锁;多实例资源有环不一定死锁。 |
| 等待关系图 | 只有线程或事务;A → B 表示 A 等 B | 适合分析互斥锁和数据库事务之间的循环等待。 |
4.4 数据库死锁检测与锁等待超时
| 对比项 | 死锁检测 | 锁等待超时 |
|---|---|---|
| 触发依据 | 发现循环等待 | 等待时长达到阈值 |
| 是否说明有死锁 | 是,针对能够检测到的死锁 | 不一定,可能只是锁持有时间较长 |
| InnoDB 默认处理 | 选择事务并回滚整个事务 | 通常只回滚超时的 SQL 语句 |
| 应用侧处理 | 重试完整事务 | 明确事务状态,必要时主动回滚后重试 |
5. 总结
死锁就是多个线程或事务互相占着对方需要的资源,形成循环等待,导致谁都无法继续执行。它涉及四个必要条件:互斥、占有且等待、不可抢占、循环等待。实际开发中,我首先会通过统一加锁顺序破坏循环等待,再通过缩短持锁时间、减少锁范围来降低风险;对支持限时获取的锁,可以在获取失败后主动释放已经持有的资源。排查时,核心是沿着“谁在等谁”的关系找到闭环。数据库方面,InnoDB 通常能够检测死锁并回滚一个事务,业务端需要正确重试。要注意,死锁不等于普通的锁等待超时:前者是资源依赖成环,后者只是等待超过了规定时间。