1. 一场深夜故障给我的死锁启蒙
先讲个我自己的真实经历。几年前的一个深夜,线上系统突然大面积卡死,所有涉及订单创建的接口全部超时,但数据库的CPU和内存占用却低得反常,连接数倒是被占满了。重启应用后恢复正常,过半小时又复现,如此反复。那会儿我刚接手这个系统,排查了很久才发现,两个服务在交叉更新同两张表,各自持有一部分锁不放,又都在等对方释放另一部分锁。那一刻我才真正理解教科书上那句"死锁就是两个或多个进程因竞争资源而造成的互相等待"——原来死锁不是只在考卷上出现的概念,它是会真实咬人的。
死锁的定义其实很朴素:一组并发执行的线程或进程,各自持有一些资源,同时又在等待其他线程持有的资源,而大家都不愿意释放自己手里的资源,于是形成了一个谁也无法前进的循环等待状态。国内计算机统考408里对死锁的定义也是这个意思:多个进程因竞争资源而造成的一种僵局,若无外力作用,这些进程都将无法向前推进。注意关键词:无外力作用。也就是说,死锁不会自己好转,必须靠外部手段打断。
如果你是刚接触并发编程的初学者,或者一直在写业务代码但从来没深究过线程之间为什么会互相卡住,这篇文章就是给你准备的。我会从死锁产生的底层机制讲起,结合线程死锁和数据库死锁两个最常见的实战场景,把死锁是怎么发生、怎么定位、怎么破解的完整链路拆开揉碎。看完你不仅能看懂jstack日志里那些让人头大的线程状态,也能在写代码的时候下意识避开这类设计缺陷。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 死锁产生的四个必要条件:理论不是背出来的
当年背操作系统课本的时候,死锁的四个必要条件几乎是必考题。但直到自己踩过坑,才明白这四个条件不是用来背的,是拿来对着代码逐条核对的。
2.1 互斥条件与资源独占
第一个条件是互斥:资源在同一时刻只能被一个线程占用。这是最底层的约束,比如一把数据库的行锁、一个Java对象的内置锁(synchronized的monitor)、一个文件句柄,它们天然就是互斥的。如果资源可以被多个线程同时使用,那就根本不存在"争抢"的问题,也不会死锁。
这一点没什么可商量的。我们能在工程上做文章的,是后面三个条件。
2.2 持有并等待:死锁的温床
第二个条件是持有并等待:线程已经持有了至少一个资源,又在等待获取其他线程持有的资源。这就像一个人手里攥着一把钥匙,还要去拿另一把钥匙,而另一把钥匙在别人手里,那个人也在等他把手里的钥匙交出来。
在实际代码里,最常见的持有并等待场景就是嵌套锁——在一个同步块内部再去获取另一个锁。比如synchronized(A)里面又写了synchronized(B)。很多死锁问题追根溯源,都是因为这种嵌套加锁的结构。
2.3 不可剥夺与循环等待:扣上死锁的锁环
第三个条件是不可剥夺:线程已获得的资源在自己使用完之前不能被其他线程强行抢走。第四个条件是循环等待:存在一个线程等待环路,线程T1等着T2释放资源,T2等着T3释放,T3又等着T1释放,形成一个环。
这四个条件合在一起,才构成了死锁的充分必要条件。反过来说也成立:只要我们打破其中任何一个条件,死锁就不可能形成。这句话听着简单,实际上就是所有死锁破解策略的理论根基。后面讲到的预防、避免、检测与解除,本质上都是在针对这四个条件做文章。
我记得有本操作系统教材用十字路口的例子讲死锁:四个方向的车都开进了路口,每一辆车都占着当前的车道,又都想穿过路口,结果互相堵死。这个类比很形象,它同时体现出了"持有并等待"和"循环等待"两个特征。
3. 线程死锁的排查实例:从现象到根因的完整链路
理论知识再扎实,到了排查线上问题的时候,还是需要一套可落地的实操方法。这里我用自己的一个Java死锁案例,完整走一遍从现象到根因再到验证的排查链路。
3.1 一个典型到不能再典型的死锁代码
先看一段典型的死锁代码,这种代码在真实项目里出现的频率远比你想象得高:
java复制public class DeadlockDemo {
private static final Object LOCK_A = new Object();
private static final Object LOCK_B = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (LOCK_A) {
System.out.println("线程1持有LOCK_A,等待LOCK_B...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (LOCK_B) {
System.out.println("线程1获取了LOCK_B");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (LOCK_B) {
System.out.println("线程2持有LOCK_B,等待LOCK_A...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (LOCK_A) {
System.out.println("线程2获取了LOCK_A");
}
}
});
t1.start();
t2.start();
}
}
两个线程,两把锁,加锁顺序完全相反。线程1先拿A再等B,线程2先拿B再等A,sleep(100)是为了人为放大这个时间窗口,制造必现的死锁。跑一下,控制台会打印出两个线程各自持有锁并等待的日志,然后程序就永远停在那里了。因为两个线程都陷入了不可前进的阻塞状态,没有任何一方会主动释放锁。
3.2 用jstack精准定位阻塞现场
程序卡死之后,第一步当然是拿线程转储。JDK自带的jstack是定位线程死锁最直接的工具,执行jstack <pid>就能输出当前JVM所有线程的状态快照。
code复制Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor 0x0000000018e0c480 (object 0x00000000ecb2dcc0, java.lang.Object),
which is held by "Thread-0"
"Thread-0":
waiting to lock monitor 0x0000000018e0bd00 (object 0x00000000ecb2dcc8, java.lang.Object),
which is held by "Thread-1"
Java stack information for the threads listed above:
===================================================
"Thread-1":
at DeadlockDemo.lambda$main$1(DeadlockDemo.java:24)
- waiting to lock <0x00000000ecb2dcc0> (a java.lang.Object)
- locked <0x00000000ecb2dcc8> (a java.lang.Object)
at DeadlockDemo.lambda$main$1(DeadlockDemo.java:22)
...
jstack日志的头部会在检测到死锁时直接输出"Found one Java-level deadlock",并且明确告诉你哪个线程持有了哪把锁、正在等待哪把锁。这就是最清晰的定位结果。有了这个信息,回到代码里看加锁顺序,问题基本就浮出水面了。
3.3 排查线程死锁的三板斧
实际线上环境比这个demo复杂得多,线程数动辄几百上千,日志刷屏严重。我的经验是三板斧:
第一板斧,优先看jstack日志最上方的死锁检测段。JVM在生成线程转储的时候会自动做一次死锁检测,如果存在Java级别的死锁,会直接在日志顶部列出来。盲搜堆栈信息之前先看这里,能省很多时间。
第二板斧,如果没有自动检测出来,搜索所有处于BLOCKED状态的线程,看它们各自在等什么锁。把等待关系列出来,A等B、B等C、C等A,环路一目了然。
第三板斧,对照代码检查加锁顺序。同一个锁被多个线程获取时,获取顺序是否一致,这是死锁最核心的诱因。绝大多数线程死锁都可以追溯到加锁顺序不一致。
线程状态这一块我再多说一句。WAITING状态的线程是没有持有锁、单纯在等待被唤醒(比如调用了wait()或LinkedBlockingQueue的take()),TIMED_WAITING是带超时的等待,而BLOCKED才是卡在锁上。区分清楚这些状态,能帮你快速缩小排查范围。
4. 数据库死锁:为什么你的SQL会"卡死"
线程死锁是一个进程内部的资源竞争问题,数据库死锁则更复杂,因为数据库锁是由数据库引擎管理的,你写的每一条SQL背后都涉及加锁、解锁、锁升级、锁转换这些机制。数据库死锁的触发频率在真实业务里其实比线程死锁更高,因为大多数后端服务都要访问数据库,而数据库并发控制天然依赖锁机制。
4.1 数据库死锁的典型成因
数据库死锁最常见的一个场景就是两个事务以不同的顺序更新多张表或多个行。我举个例子:
事务A先更新订单表(对订单表某行加了行级排他锁),再更新库存表;事务B先更新库存表(对库存表某行加了行级排他锁),再更新订单表。两个事务同时启动后,A持有订单表锁等库存表锁,B持有库存表锁等订单表锁,于是死锁发生。
另一个高发场景是**间隙锁(Gap Lock)**导致的死锁。在MySQL InnoDB的可重复读隔离级别下,范围查询不仅会锁住命中的记录,还会锁住记录之间的间隙,防止其他事务在这个间隙插入数据。两个事务的间隙锁发生交叉重叠的时候,就可能困住彼此。这种死锁光看业务代码几乎不可能察觉,只有看数据库的死锁日志才能明白。
数据库通常会通过锁等待超时机制来处理死锁:事务等待锁的时间超过innodb_lock_wait_timeout(默认50秒),数据库会主动报错,让这个事务回滚。而真正的死锁检测机制会让数据库直接选一个牺牲者回滚并抛出死锁异常。所以你在应用层看到的数据库死锁,大多数是以Deadlock found when trying to get lock; try restarting transaction这种异常形式出现的。
4.2 Oracle锁表类型和死锁的区别
顺着热搜词里"oracle锁表类型和死锁区别"这个话题展开讲。Oracle的锁机制比MySQL更丰富,常见的有行级锁(TX)、表级锁(TM)、数据字典锁(DDL)等。很多人会混淆"锁表"和"死锁"这两个概念,我在这里帮大家理清楚。
锁表本质上是一种锁竞争,不是死锁。比如一个事务更新了大量行但没有提交,另一个事务想更新其中某些行,就会一直等待,现象上表现为那张表"卡住了"、"锁死了"。但实际上第一个事务只要提交或回滚,第二个事务的等待马上就结束了。这是正常的并发控制,谈不上死锁。
死锁是另一个层面的东西:两个或两个以上事务互相持有对方需要的锁,形成循环等待。Oracle的死锁检测机制会在一个事务等待另一个事务持有的资源时启动,当检测到循环等待,就会选择一个事务作为牺牲者,回滚它的语句或整个事务,并抛出ORA-00060: deadlock detected while waiting for resource。关于锁表,排查的重点是找出阻塞源——谁持有了锁、持有了多久、执行了什么SQL。关于死锁,排查的重点是分析事务之间的加锁顺序和资源访问模式。
用一张表格把二者的区别说清楚:
| 对比项 | 锁表(锁等待) | 死锁 |
|---|---|---|
| 本质 | 单方向等待 | 循环等待 |
| 是否会自动解除 | 持有者提交/回滚后解除 | 需要检测机制介入,牺牲事务回滚 |
| 典型错误 | 超时等待 | ORA-00060 / MySQL Deadlock found |
| 排查重点 | 找到阻塞源头SQL | 分析加锁顺序与事务交叉 |
| 对应用的影响 | 接口变慢、超时 | 事务回滚,偶发报错 |
每次我看到有人把"表被锁了"说成"死锁了",都忍不住纠正一句。这两个问题的处理方式完全不同:锁表你只要找到那个没提交的事务就行,死锁你需要重新设计事务的加锁顺序。
4.3 通过死锁日志还原完整因果链
MySQL里解析死锁的首选途径是SHOW ENGINE INNODB STATUS,这个命令的输出里有专门的LATEST DETECTED DEADLOCK段。下面是一段真实死锁日志的简化版:
code复制------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 8184966, ACTIVE 12 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 28 page no 4 n bits 72 index PRIMARY of table `test`.`orders`
*** (2) TRANSACTION:
TRANSACTION 8184967, ACTIVE 9 sec starting index read
mysql tables in use 1, locked 1
2 lock struct(s), heap size 1136, 1 row lock(s)
*** (2) HOLDING THE LOCK(S):
RECORD LOCKS space id 28 page no 4 n bits 72 index PRIMARY of table `test`.`orders`
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 28 page no 5 n bits 72 index PRIMARY of table `test`.`inventory`
日志里标注得清清楚楚:事务1在等待orders表上的锁,事务2持有这个锁又等待inventory表上的锁,死锁环就闭合了。看这种日志的要点是抓三个字段——每个事务的WAITING FOR THIS LOCK和HOLDING THE LOCK(S),把它们对应起来就是完整的锁等待图。再结合binlog或者应用日志里这两个事务执行的SQL,就能还原出是哪两条SQL因为加锁顺序问题撞了车。
5. 破解死锁的四大策略:从理论到工程实践的完整落法
破解死锁,操作系统教材给了四套经典思路:预防(Prevention)、避免(Avoidance)、检测(Detection)、解除(Recovery)。很多文章把它们写成了理论口号,我在实际工程中体会它们各自的优劣和适用场景,这里展开说。
5.1 预防策略:直接打破四个必要条件
预防的思路最简单粗暴:从设计上让死锁的必要条件无法同时满足。
打破"互斥"条件,在工程上基本不可行,因为很多资源天生就是互斥的,你是没法改变锁的互斥性的。
打破"持有并等待"条件,要求线程在开始执行前一次性申请所有需要的资源。这个策略在数据库场景有变形应用:尽量在一个事务里按固定顺序访问所有需要的表,让事务一次性拿到所有锁。缺点也很明显——资源利用率低,因为有些资源其实是等用到才需要的,提前全占着会加大阻塞面。
打破"不可剥夺"条件,允许线程在申请不到新资源时释放自己已有的资源。Java里Lock接口的tryLock(timeout)就是干这个的。抢不到锁就放弃并释放已有资源,这是行之有效的策略。
打破"循环等待"条件,最经典的方案是对资源进行全局编号,要求所有线程严格按照编号递增的顺序申请锁。两个线程都按A、B的顺序拿锁,就不可能形成交叉等待。
预防策略的优点是简单可靠,缺点是资源利用率偏低、并发度下降。适合锁数量少、业务逻辑稳定的场景。
5.2 避免策略:银行家算法的工程变形
避免策略的核心思想是:在每次资源分配之前,系统计算一下如果继续分配,系统是否还能处于"安全状态"。这个算法的理论基础是迪杰斯特拉的银行家算法。银行家算法的完整实现比较复杂,因为要维护每个进程的最大资源需求矩阵、已分配矩阵、剩余资源向量等,而且要求每个进程事先声明最大资源需求,这在现代应用里是不现实的。
工程上真正落地的是银行家算法的简化思想:在获取多个锁的时候,使用带超时的锁获取机制,获取失败就回滚重试。以Java的ReentrantLock为例:
java复制ReentrantLock lockA = new ReentrantLock();
ReentrantLock lockB = new ReentrantLock();
boolean gotA = lockA.tryLock(2, TimeUnit.SECONDS);
if (gotA) {
try {
boolean gotB = lockB.tryLock(2, TimeUnit.SECONDS);
if (gotB) {
try {
// 业务逻辑
} finally {
lockB.unlock();
}
} else {
// 获取B失败,释放A,回退重试
}
} finally {
lockA.unlock();
}
}
用tryLock最大的好处是它不会无限期阻塞,获取锁失败可以让线程放弃这次操作、释放已持有的锁,稍后重试。这实际上是在"持有并等待"和"不可剥夺"两个条件上做了文章。代价是代码复杂度上升,嵌套越深越难写,所以更适合锁数量少、获取逻辑不嵌套太深的场景。
5.3 检测与解除:现代系统的标配
前面说的预防和避免都是在设计和编码阶段做文章,检测与解除则是在运行时兜底。操作系统里的死锁检测需要维护资源分配图和进程等待图,周期性检测图中是否有环路。现代主流数据库和Java虚拟机都内置了类似的检测机制。
Java JVM在检测到死锁后能够打印"Found one Java-level deadlock"日志,告诉我们具体是哪个线程、哪把锁。数据库的检测更主动:InnoDB每检测到死锁就会选一个事务回滚,释放资源让其他事务继续跑。
工程上对死锁的"解除"一般不是真的去把某个锁强制释放(这也不安全),而是中断或回滚死锁中的某个参与者。数据库事务靠回滚来解决;Java线程可以通过中断,但前提是代码里对中断有响应。最稳妥的做法还是应用层做好失败重试。
工程上最推荐的组合拳是:设计阶段按照资源顺序规避循环等待,编码阶段使用带超时的锁获取,运行阶段依赖数据库和JVM的死锁检测来兜底,同时把死锁日志收集起来持续分析和优化。
5.4 从数据库层面优化SQL降低死锁概率
数据库场景的死锁,很多是可以通过SQL层面的改造来降低概率的。我总结几条实用经验:
第一,事务尽量短小。一个事务只做必要的事情,不要在一个事务里跑慢查询、调用远程接口、做复杂计算。事务持有锁的时间越短,与别的事务发生冲突的窗口就越小。
第二,多个事务访问多张表时保持相同的访问顺序。如果所有事务都先操作订单表再操作库存表,就不会出现A等库存、B等订单这种交叉。
第三,合理使用索引。更新和删除语句的WHERE条件要走索引,否则数据库会对目标表进行全表扫描,加锁范围可能从行锁膨胀到多行甚至全表。加了合适的索引,锁的粒度就能控制在最小。
第四,监控长事务。长事务是锁等待的温床,它们持有锁时间过长,容易让其他事务堆积。常用information_schema.innodb_trx来查看当前有哪些事务还没提交。
另外,对于高频更新的热点行,可以考虑拆分热点行、引入缓冲表等手段,减小行锁竞争的压力。这些手段的本质都是缩短锁持有时间、缩小锁粒度。
6. 写了这么多年代码,关于死锁我的一些实在话
文章的干货部分到上面基本结束了,最后说几句实在的经验。
我在实际项目里见过太多次死锁引发的事故,很多都可以在代码评审阶段就发现。每次评审涉及多线程或事务的代码,我必问两个问题:**这段代码可能同时获取哪些锁?多个线程获取这些锁的顺序是否一致?**这两个问题一过,大部分死锁隐患就暴露出来了。
如果系统已经有死锁发生了,不要急着加超时时间。超时只是把问题从"死锁"变成"超时异常",并没有消除根源。花点时间把死锁日志导出来,画一张锁等待图,找出那个环,然后把加锁顺序改掉,这才是治本。
还有一个小技巧,写多线程代码的时候,把加锁操作的顺序作为注释写清楚。比如"这里必须先锁A再锁B,顺序不能反"。代码风格规范如果能强制这一点,能从源头上降低后来人改代码时引入死锁的概率。
死锁这个主题,理论基础并不深奥,四个条件、四类策略,背上几遍考试就能过。但真正吃透它,靠的是在线上事故和排查过程里一次次打磨出来的敏感度。这篇文章如果能让你在下一次写嵌套锁或复杂事务的时候,多停一秒钟想一想加锁顺序,那就算起作用了。
