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) {
Syste
