在多线程编程中,死锁(Deadlock) 是最令人头疼的问题之一——多个线程互相等待对方释放资源,导致程序永久停滞,既不报错也不退出。要有效预防和解决死锁,首先必须理解:死锁的发生并非偶然,而是必须同时满足四个特定条件。
这四个条件缺一不可。只要破坏其中任意一个,死锁就无法形成。本文将深入解析这四大必要条件,并结合经典案例说明其作用机制。在并发编程中,掌握这些原理至关重要。
一、死锁的四大必要条件
1.互斥条件(Mutual Exclusion)
资源一次只能被一个线程占用。
- 如果某个资源可以被多个线程同时访问(如只读数据),就不会产生竞争,自然不会死锁。
- 但像锁(Lock)、文件写权限、数据库行锁等独占性资源,必须满足互斥。
- ✅这是死锁的前提:没有互斥,就没有“等待”。
2.请求与保持条件(Hold and Wait)
线程在持有至少一个资源的同时,又去请求其他被占用的资源。
- 如果线程在请求新资源前,必须先释放所有已持有的资源,那么它就不会“边占边等”,从而避免死锁。
- ❌ 正是这种“贪心”行为——既不放手已有资源,又去申请新资源——为死锁埋下伏笔。
3.不剥夺条件(No Preemption)
线程已获得的资源,在使用完之前不能被其他线程强行夺走。
- 如果系统支持“剥夺式调度”(如某些实时操作系统),当高优先级线程需要某资源时,可强制低优先级线程释放,死锁即可避免。
- 但在 Java 等通用并发模型中,锁一旦获得,只能由持有者主动释放,无法被抢占。
- ✅ 因此,该条件通常成立,加剧了死锁风险。
4.循环等待条件(Circular Wait)
存在一组线程 {T₁, T₂, ..., Tₙ},其中 T₁ 等待 T₂ 持有的资源,T₂ 等待 T₃ 的……Tₙ 等待 T₁ 的,形成环路。
- 这是死锁的拓扑特征:资源依赖关系构成一个有向环。
- 最简单的形式是两个线程互相持有对方所需的锁(A 持有锁1等锁2,B 持有锁2等锁1)。
🔑关键结论:只有当以上四个条件同时成立时,死锁才可能发生。
二、经典死锁案例分析
下面是一个典型的必然死锁代码(基于Java实现):
public class MustDeadLock implements Runnable {
public int flag;
static final Object lock1 = new Object();
static final Object lock2 = new Object();
@Override
public void run() {
if (flag == 1) {
synchronized (lock1) {
try { Thread.sleep(500); } catch (Exception e) {}
synchronized (lock2) {
System.out.println("Thread 1 got both locks");
}
}
}
if (flag == 2) {
synchronized (lock2) {
try { Thread.sleep(500); } catch (Exception e) {}
synchronized (lock1) {
System.out.println("Thread 2 got both locks");
}
}
}
}
public static void main(String[] args) {
MustDeadLock r1 = new MustDeadLock();
MustDeadLock r2 = new MustDeadLock();
r1.flag = 1;
r2.flag = 2;
new Thread(r1, "T1").start();
new Thread(r2, "T2").start();
}
}
验证四大条件:
| 条件 |
是否满足 |
说明 |
| 互斥 |
✅ |
synchronized锁是互斥的 |
| 请求与保持 |
✅ |
T1 持有lock1后请求lock2,不释放lock1 |
| 不剥夺 |
✅ |
JVM 不会强制剥夺线程持有的锁 |
| 循环等待 |
✅ |
T1 → 等 lock2(被 T2 持有),T2 → 等 lock1(被 T1 持有)→ 形成环 |
✅四个条件全部满足 →必然死锁。
三、如何破坏死锁?——四大策略
既然死锁需四条件共存,我们只需破坏其一即可预防:
| 破坏条件 |
实现方式 |
示例 |
| 互斥 |
难以破坏(多数资源天然互斥) |
— |
| 请求与保持 |
要求线程一次性申请所有所需资源 |
“要么全拿,要么不拿” |
| 不剥夺 |
允许资源被抢占(复杂,少用) |
超时放弃 + 重试 |
| 循环等待 |
最常用!对资源编号,按序申请 |
所有线程先申请lock1,再申请lock2 |
✅ 推荐实践:统一加锁顺序
// 所有线程都按 lock1 → lock2 的顺序获取
synchronized (lock1) {
synchronized (lock2) {
// 安全
}
}
这样就打破了循环等待,死锁不再可能。
四、死锁的检测与工具
即使做了预防,生产环境仍可能出现死锁。Java 提供了诊断手段:
- jstack:
jstack <pid>查看线程堆栈,自动检测死锁并输出。
- VisualVM / JConsole:图形化监控线程状态。
- 代码检测:通过
ThreadMXBean.findDeadlockedThreads()编程检测。
五、总结
| 死锁条件 |
含义 |
是否可破坏 |
建议 |
| 互斥 |
资源独占 |
❌(通常不可) |
— |
| 请求与保持 |
边占边等 |
✅ |
一次性申请所有资源 |
| 不剥夺 |
不能抢资源 |
⚠️(复杂) |
超时重试机制 |
| 循环等待 |
资源依赖成环 |
✅✅✅ |
统一加锁顺序(首选) |
💡记住:死锁不是“bug”,而是一种系统性的并发状态。理解其四大必要条件,是编写健壮多线程程序的第一步。
在实际开发中,避免嵌套锁、减少锁粒度、统一加锁顺序,是预防死锁最有效的工程实践。
|