死锁(Deadlock)实战指南:从原理到解决方案
1. 死锁到底是什么?从“卡脖子”到系统瘫痪
如果你写过并发程序,或者维护过数据库,大概率遇到过这种情况:程序突然卡住了,CPU占用率很低,但任务就是跑不完,重启一下服务又好了。这很可能就是遇到了“死锁”。这东西听起来挺吓人,但说白了,就是一场“资源争夺战”里,几个参与者互相卡住了脖子,谁也不肯松手,结果大家一起“饿死”在原地。
我刚开始接触多线程编程时,就亲手写出了一个死锁。当时写了一个简单的文件处理工具,两个线程需要同时读写两个不同的日志文件。我为了图省事,给每个文件都加了一把锁。线程A先锁了文件1,然后想去锁文件2;与此同时,线程B先锁了文件2,然后想去锁文件1。程序一跑起来,刚开始还挺正常,跑着跑着就“定”在那里了,两个线程大眼瞪小眼,谁都进行不下去。这就是最经典、也最直观的死锁场景。
所以,死锁的本质是一种由资源竞争引发的永久性阻塞状态。这里的“资源”是个广义概念,它可以是打印机、数据库里的一行记录、内存里的一块缓冲区,也可以是代码里任何一把锁(Lock)。当两个或更多的执行单元(线程、进程、事务)各自持有一部分资源,同时又都在等待对方释放自己所需的资源时,这条“等待链”就形成了一个闭环,系统就“死”在了这个环里。除非有外部力量介入(比如强制终止其中一个),否则这个僵局会一直持续下去。
理解死锁,对我们写出健壮、稳定的程序至关重要。无论是后端服务、数据库应用还是分布式系统,死锁都是一个必须直面的“幽灵”。接下来,我们就把它从原理到解决方案,一层层剥开来看。
2. 死锁诞生的四个“帮凶”:Coffman条件详解
死锁不会无缘无故发生,它的出现必须同时满足四个条件,这就是著名的 Coffman条件。你可以把这四个条件理解为死锁的“四大护法”,缺一不可。我们的所有预防和解决手段,核心思想就是去“破坏”其中至少一个条件。
2.1 互斥条件:好东西不能共享
互斥条件是说,资源本身是独占的,一次只能被一个执行单元使用。比如厕所的隔间,你进去了,门一锁,别人就只能在外面等着。在编程里,像修改一个全局变量、写入一个文件、或者给数据库某一行加上了“排他锁”(X Lock),这些操作在那一刻都是互斥的。
这个条件往往是系统特性决定的,很难被彻底破坏。我们总不能允许两个线程同时修改同一个内存地址吧?那数据就全乱套了。所以,针对互斥条件的策略通常是“疏导”而非“破坏”,比如把独占资源池化,或者使用无锁数据结构来避免竞争。
2.2 持有并等待:吃着碗里的,看着锅里的
持有并等待条件是说,一个执行单元已经占有了至少一个资源,但它还不满足,又伸出手去申请新的资源,而在申请新资源的时候,它并不会释放自己已经持有的资源。
这就好比你在厨房做饭,左手已经拿起了酱油瓶(持有资源A),同时右手又伸出去想拿醋瓶(申请资源B)。在你拿到醋瓶之前,你绝不会放下手里的酱油瓶。如果这时候另一个人正拿着醋瓶,同时想拿你的酱油瓶,你们俩就僵持住了。在代码里,最常见的体现就是嵌套的锁请求。
2.3 不可抢占:我的东西你不能抢
不可抢占条件意味着,资源一旦被某个执行单元获得,在它主动释放之前,系统不能强行把这个资源夺走分配给其他单元。
还是用厨房的例子,你手里的酱油瓶,别人不能直接从你手里抢过去,只能等你用完主动放回架子。在操作系统中,某些内核资源或硬件设备就是不可抢占的。在数据库里,一个事务持有的行锁,在事务提交或回滚前,其他事务也无法强行夺取。这个条件保证了资源操作的一致性,但也为死锁留下了可能。
2.4 循环等待:等成了一个“圈”
循环等待条件是死锁形成的直接表现。它指的是一组执行单元{P1, P2, ..., Pn},P1在等待P2占有的资源,P2在等待P3占有的资源,……,Pn又在等待P1占有的资源,形成了一个首尾相接的等待环。
这个“圈”不一定只有两个参与者,可能是三个、四个甚至更多。我遇到过的一个线上数据库死锁,就是由三个事务复杂交叉更新不同顺序的行记录导致的,排查起来比两个线程的死锁要费劲得多。但只要这个循环等待链形成了,死锁就发生了。
简单总结一下:互斥是资源本性,持有并等待是行为方式,不可抢占是保护规则,循环等待是最终结果。我们的实战目标,就是想方设法不让这四个家伙同时凑齐。
3. 代码里“活捉”一个死锁:从Java到数据库
理论说再多,不如看代码来得实在。我们就在不同场景下,亲手制造并观察一下死锁。
3.1 Java多线程中的经典双锁死锁
下面这个例子,是我认为每个Java程序员都应该运行一次、感受一下的“死锁教学程序”。它清晰地展示了两个线程如何优雅地走进死锁陷阱。
public class ClassicDeadlockDemo {
// 两把锁,代表两种不可共享的资源
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (lock1) { // 线程1先拿到lock1
System.out.println(Thread.currentThread().getName() + " 持有了 lock1,正在尝试获取 lock2...");
try {
Thread.sleep(50); // 睡眠一下,确保线程2能拿到lock2
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock2) { // 线程1试图获取lock2
System.out.println(Thread.currentThread().getName() + " 成功获取 lock1 和 lock2!");
}
}
}, "线程-1");
Thread t2 = new Thread(() -> {
synchronized (lock2) { // 线程2先拿到lock2
System.out.println(Thread.currentThread().getName() + " 持有了 lock2,正在尝试获取 lock1...");
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock1) { // 线程2试图获取lock1
System.out.println(Thread.currentThread().getName() + " 成功获取 lock2 和 lock1!");
}
}
}, "线程-2");
t1.start();
t2.start();
// 主线程等待10秒,看看两个线程能否结束
t1.join(10000);
t2.join(10000);
if (t1.isAlive() || t2.isAlive()) {
System.out.println("\n========== 死锁发生了!程序无法正常结束。 ==========");
// 在实际诊断时,可以在这里dump线程栈
// Thread.getAllStackTraces().forEach((thread, stack) -> {
// System.out.println("\n" + thread.getName() + ":");
// for (StackTraceElement element : stack) {
// System.out.println("\t" + element);
// }
// });
}
}
}
运行这段代码,你大概率会看到输出停在“线程-1 持有了 lock1,正在尝试获取 lock2...”和“线程-2 持有了 lock2,正在尝试获取 lock1...”,然后程序就挂起不动了,直到我们设置的join超时。这就是死锁的现场。两个线程各自握着一把钥匙,却都在等对方手里的那把,结果谁都开不了门。
3.2 数据库事务死锁:行锁的顺序陷阱
数据库里的死锁更为常见,尤其是高并发的更新场景。假设我们有一个用户账户表 accounts,现在有两个事务要同时更新两个用户的余额。
-- 事务1 (连接1中执行)
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1; -- 对 user_id=1 加行锁
-- 这里模拟一些业务处理时间
SELECT SLEEP(1);
UPDATE accounts SET balance = balance + 100 WHERE user_id = 2; -- 尝试对 user_id=2 加行锁
COMMIT;
-- 事务2 (连接2中执行,几乎同时开始)
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE user_id = 2; -- 对 user_id=2 加行锁
SELECT SLEEP(1);
UPDATE accounts SET balance = balance + 50 WHERE user_id = 1; -- 尝试对 user_id=1 加行锁
COMMIT;
这两个事务如果同时运行,就非常容易死锁。事务1锁住了id=1的行,想去锁id=2的行;事务2锁住了id=2的行,想去锁id=1的行。数据库引擎(如InnoDB)的死锁检测机制会在几秒内发现这个循环等待,并选择代价较小的事务(通常是被影响行数较少、undo log较小的事务)进行回滚,让另一个事务成功执行。被回滚的事务会收到一个“Deadlock found when trying to get lock”的错误。所以,数据库死锁并不总是导致系统挂起,很多时候表现为事务的异常失败和重试,这对我们设计重试机制提出了要求。
4. 防患于未然:死锁预防的实战兵法
知道了死锁怎么产生的,我们就要在写代码时主动避免。预防死锁,就是在系统设计阶段,通过破坏那四个必要条件中的一个或多个,把死锁扼杀在摇篮里。这是最积极、最根本的策略。
4.1 破坏循环等待:强制统一的锁顺序
这是最常用、也最有效的预防手段。核心思想是:给系统中所有可能被竞争的资源定义一个全局的、严格的获取顺序。任何线程或事务在需要获取多个资源时,都必须按照这个顺序来申请。
怎么定顺序呢?很简单,给资源排个号。比如,在Java中,我们可以根据对象的哈希码、或者自定义的ID来排序。
public class OrderedLockDemo {
// 假设我们有多个需要加锁的资源对象
private final Object resourceA = new Object();
private final Object resourceB = new Object();
private final Object resourceC = new Object();
public void safeOperation(Object param1, Object param2) {
// 1. 将需要加锁的资源对象收集到一个列表里
List<Object> resources = Arrays.asList(resourceA, resourceB, resourceC); // 实际中根据参数决定
// 2. 按照一个固定的规则排序,这里使用System.identityHashCode(对象内在哈希码)
resources.sort(Comparator.comparingInt(System::identityHashCode));
// 3. 严格按照排序后的顺序加锁
synchronized (resources.get(0)) {
synchronized (resources.get(1)) {
synchronized (resources.get(2)) {
// 这里是安全的临界区,可以操作所有资源
doBusinessLogic();
}
}
}
}
private void doBusinessLogic() {
// 业务逻辑
}
}
在数据库操作中,这个原则体现为按主键顺序更新。比如,无论业务逻辑如何,需要更新多行记录时,总是按照 user_id ASC 的顺序来执行 UPDATE 或 DELETE 语句。这样就能确保所有事务都以相同的顺序获取行锁,从根本上杜绝了循环等待。
4.2 破坏持有并等待:一次性申请所有资源
这个策略有点“霸道”:线程在开始执行前,必须一次性申请它所需要的全部资源。如果其中任何一个资源不可用,那么它连一个资源都拿不到,必须全部等待。
这有点像你去自助餐厅,必须先把所有想吃的菜都拿到盘子里,才能开始吃,不允许拿一点吃一点。在编程中,我们可以尝试实现一个“锁管理器”。
public class LockManager {
private final Set<Object> lockedResources = ConcurrentHashMap.newKeySet();
public boolean acquireLocks(List<Object> resources) {
// 首先,尝试原子性地获取所有锁
synchronized (this) {
for (Object resource : resources) {
if (lockedResources.contains(resource)) {
// 如果任何一个资源已被占用,立即释放所有已尝试的锁(本例中未真正加锁,仅为演示逻辑)
return false; // 申请失败
}
}
// 所有资源都可用,标记为已锁定
lockedResources.addAll(resources);
return true;
}
}
public void releaseLocks(List<Object> resources) {
synchronized (this) {
lockedResources.removeAll(resources);
}
}
}
这种方法的缺点是资源利用率可能很低。一个线程可能提前占用了它很久之后才用到的资源,导致其他线程长时间等待。在实际中,它更适用于资源需求明确且执行时间很短的场景。
4.3 使用尝试锁与超时机制
这是破坏“不可抢占”条件的一种妥协方案。我们不强抢资源,但我们不给线程无限等待的权利。如果拿不到锁,我就等一会儿,等不到我就放弃,并把已经拿到的锁也释放掉(破坏“持有并等待”),过段时间再重试。
Java中的 ReentrantLock 就提供了完美的支持:
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class TryLockWithTimeoutDemo {
private final Lock lock1 = new ReentrantLock();
private final Lock lock2 = new ReentrantLock();
public void tryLockOperation() {
boolean acquiredLock1 = false;
boolean acquiredLock2 = false;
try {
// 尝试获取第一把锁,最多等待100毫秒
acquiredLock1 = lock1.tryLock(100, TimeUnit.MILLISECONDS);
if (!acquiredLock1) {
System.out.println("获取 lock1 超时,操作失败或进行重试");
return;
}
// 尝试获取第二把锁,最多等待100毫秒
acquiredLock2 = lock2.tryLock(100, TimeUnit.MILLISECONDS);
if (!acquiredLock2) {
System.out.println("获取 lock2 超时,将释放已获得的 lock1 并重试");
return;
}
// 成功获取两把锁,执行核心逻辑
doCriticalWork();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("线程被中断");
} finally {
// 务必在finally块中按加锁的反序释放锁
if (acquiredLock2) {
lock2.unlock();
}
if (acquiredLock1) {
lock1.unlock();
}
}
}
private void doCriticalWork() {
// 临界区操作
}
}
这种“尝试-回退-重试”的模式,在分布式锁(如基于Redis的锁)中更是标配。它避免了线程无限期阻塞,给了系统自我恢复的机会。当然,它引入了新的问题:活锁(两个线程同时放弃、重试,又同时放弃……),通常通过引入随机退避时间来缓解。
5. 亡羊补牢:死锁的检测与解除
在复杂的系统,尤其是数据库和分布式系统中,完全预防死锁的成本太高,或者会严重损害性能。这时,更务实的策略是允许死锁发生,但能快速检测并自动恢复。这就是“检测+解除”策略。
5.1 数据库的死锁检测与自动处理
现代关系数据库(如MySQL InnoDB、PostgreSQL)都内置了强大的死锁检测引擎。它们会周期性地(例如每秒钟)检查事务等待图(Wait-for Graph)是否存在环路。
当检测到死锁时,数据库会扮演“裁判”角色,选择一个它认为“代价最小”的牺牲品事务(victim)进行回滚。选择依据通常包括:事务已修改的数据量(Undo Log大小)、事务的年龄、或者事务的优先级。被选中的事务会收到一个错误,而其他被阻塞的事务则得以继续执行。
作为开发者,我们的应对策略是:
- 必须处理死锁错误:你的代码在执行业务事务时,一定要捕获类似
Deadlock found when trying to get lock; try restarting transaction这样的异常。 - 实现优雅的重试:捕获到死锁异常后,不要直接抛给用户。应该实现一个有限次数的重试逻辑(例如最多3次),并在重试前进行短暂的随机等待(如
Thread.sleep(50 + random.nextInt(100))),以避免多个事务立即重试再次撞车。 - 优化事务设计:虽然数据库能解,但频繁死锁影响体验。尽量让事务短小精悍,避免在事务内进行远程调用或复杂计算。更新多行数据时,牢记**按固定顺序(如主键)**操作。
5.2 应用层的死锁诊断:线程转储分析
当你的Java应用服务卡死,怀疑是死锁时,最直接的诊断工具就是线程转储。你可以通过 jstack <pid> 命令,或者向JVM进程发送 SIGQUIT 信号(Ctrl+\)来获取。
一份揭示了死锁的线程转储,在最后会有明确的“Found one Java-level deadlock”段落,并清晰地画出线程间的等待关系。例如:
"Thread-1" #12 prio=5 os_prio=0 tid=0x00007f48740f1000 nid=0x6d1 waiting for monitor entry [0x00007f486f7f6000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.DeadlockDemo$2.run(DeadlockDemo.java:35)
- waiting to lock <0x00000000f5c0b2c0> (a java.lang.Object) // 它在等这个对象
- locked <0x00000000f5c0b2d0> (a java.lang.Object) // 但它已锁住那个对象
"Thread-2" #13 prio=5 os_prio=0 tid=0x00007f48740f2800 nid=0x6d2 waiting for monitor entry [0x00007f486f6f5000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.DeadlockDemo$1.run(DeadlockDemo.java:22)
- waiting to lock <0x00000000f5c0b2d0> (a java.lang.Object) // 它在等这个对象
- locked <0x00000000f5c0b2c0> (a java.lang.Object) // 但它已锁住这个对象
Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor 0x00007f486400c3e8 (object 0x00000000f5c0b2c0, a java.lang.Object),
which is held by "Thread-2"
"Thread-2":
waiting to lock monitor 0x00007f486400b6b8 (object 0x00000000f5c0b2d0, a java.lang.Object),
which is held by "Thread-1"
看,工具已经帮我们把“线程1等锁A(被线程2持有),线程2等锁B(被线程1持有)”的循环关系找出来了。拿到这个信息,再去对照代码,修复起来就有明确方向了。
5.3 分布式系统死锁的挑战
分布式死锁的检测是业界难题,因为没有一个节点拥有全局的、一致的资源视图。常见的思路有两种:
- 中心化检测:指定一个协调者节点(如ZooKeeper),定期收集所有节点的资源分配和等待信息,进行全局图分析。问题在于协调者成了单点瓶颈和故障点。
- 分布式检测:每个节点独立检测本地可能存在的死锁,并通过消息传递与相关节点通信,共同判断全局是否存在死锁。算法复杂,通信开销大。
因此,在分布式系统中,预防远比检测更受青睐。普遍采用的做法是:
- 使用带有租约(Lease)和超时机制的分布式锁,例如Redlock算法。锁会自动过期,避免了持有者崩溃导致的资源永久锁定。
- 设计无状态的、幂等的服务,让失败的重试变得安全。
- 使用Saga等分布式事务模式,将大事务拆解为一系列可补偿的本地小事务,通过反向的补偿操作来回滚,避免长事务持有资源。
6. 工程最佳实践:将死锁风险降到最低
结合我这些年踩过的坑,总结出几条最实用的工程建议,它们可能无法100%杜绝死锁,但能帮你避免90%以上的常见问题。
第一,锁的粒度要尽可能细。 不要动不动就锁整个方法、锁整个对象。思考一下,你真正需要保护的是哪一小块共享数据?能用 AtomicInteger 就别用 synchronized,能用 ConcurrentHashMap 就别自己包装 HashMap。Java并发包(java.util.concurrent)里的工具,都是大师们精心设计、久经考验的,优先使用它们。
第二,锁的范围要尽可能小。 拿到锁之后,只做必须做的、与共享数据相关的操作,做完立刻释放。不要在锁里面调用IO操作、网络请求、或者执行复杂计算。记住一个原则:锁内无慢操作。
// 反例:锁范围太大,还做了IO
public synchronized void processAndWrite(Data data) {
complexCalculation(data); // 耗时计算
callExternalService(data); // 网络调用,风险极高!
writeToFile(data); // IO操作
}
// 正例:只锁住共享数据的操作
public void processAndWriteBetter(Data data) {
Data processedData = complexCalculation(data); // 在锁外计算
synchronized (this) {
// 只保护这个核心的、对共享状态的更新操作
updateSharedState(processedData);
}
// 其他IO、网络操作放在锁外
callExternalService(processedData);
writeToFile(processedData);
}
第三,建立代码审查中的“锁序”检查点。 在团队Code Review时,对于涉及多个锁的代码,要特别警惕。 reviewer可以主动问:“这几个锁的获取顺序是固定的吗?会不会在其他地方以不同的顺序获取?” 这是一个非常好的习惯。
第四,对数据库操作进行“静态检查”或“规约约束”。 可以在团队规范中强制要求:“所有多行更新操作,必须在注释中说明排序依据(例如,按主键ID升序)”。甚至可以利用一些代码检查工具(如SonarQube自定义规则)或ORM框架的特性来进行约束。
第五,做好监控和告警。 监控数据库的死锁回滚次数(如MySQL的 innodb_deadlocks 状态变量)。监控应用服务器线程池中阻塞线程数量的异常增长。设置合理的阈值告警,这样当死锁开始频发时,你能第一时间被通知到,而不是等到用户投诉。
死锁就像程序世界里的“交通堵塞”,完全避免很难,但通过良好的设计、规范的编码和有效的监控,我们可以让道路更通畅,即使偶尔堵车,也能快速疏导。说到底,对抗死锁的关键,在于开发者心中时刻绷着那根“并发安全”的弦,养成谨慎操作共享资源的习惯。
更多推荐



所有评论(0)