什么是死锁?
在操作系统和多线程编程中,死锁(Deadlock)是指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成的一种互相等待的现象。若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。
死锁不仅存在于操作系统底层,在现代高并发Java应用、数据库事务处理中也是极其常见且致命的错误。理解死锁发生的四个条件,是解决这一问题的前提。
死锁发生的四个必要条件
要发生死锁,必须同时满足以下四个条件。只要破坏其中任何一个条件,死锁就不会发生。这四个条件分别是:互斥条件、占有并等待条件、非抢占条件和循环等待条件。
1. 互斥条件 (Mutual Exclusion)
指资源是独占的且排他使用。例如,打印机只能被一个进程使用,当一个进程正在使用某资源时,其他进程必须等待。如果资源允许共享,则不会发生因争夺该资源而导致的死锁。
2. 占有并等待 (Hold and Wait)
指进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他进程占有,此时请求进程阻塞,但对自己已获得的资源保持不放。即“手里拿着羊,眼望着羹”。
3. 非抢占条件 (No Preemption)
指进程已获得的资源在未使用完之前,不能被其他进程强行夺走,只能由进程自己主动释放。操作系统不能强制剥夺进程手中已持有的资源。
4. 循环等待条件 (Circular Wait)
指在发生死锁时,必然存在一个进程——资源的环形链。即进程集合{P0,P1,P2,···,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源,……,Pn正在等待已被P0占用的资源。
这四个条件必须同时成立,死锁才会发生。这是解决死锁问题的理论基石。
Java多线程死锁实战示例
为了更直观地理解这四个条件,我们通过一个经典的Java代码示例来演示死锁是如何产生的。在这个例子中,两个线程Thread-A和Thread-B分别持有锁A和锁B,并试图获取对方的锁。
public class DeadlockDemo {
public static void main(String[] args) {
final String lockA = "LockA";
final String lockB = "LockB";
Thread threadA = new Thread(() -> {
synchronized (lockA) {
System.out.println("Thread A: Holding Lock A ...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("Thread A: Waiting for Lock B ...");
synchronized (lockB) {
System.out.println("Thread A: Holding Lock A & B");
}
}
});
Thread threadB = new Thread(() -> {
synchronized (lockB) {
System.out.println("Thread B: Holding Lock B ...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("Thread B: Waiting for Lock A ...");
synchronized (lockA) {
System.out.println("Thread B: Holding Lock A & B");
}
}
});
threadA.start();
threadB.start();
}
}
代码分析:
- 互斥条件:
synchronized关键字保证了锁的互斥性。 - 占有并等待: Thread-A 先获取了 lockA,然后在尝试获取 lockB 时,依然持有 lockA。
- 非抢占: 一旦获取了锁,JVM不会强制释放,必须等待线程执行完毕或异常。
- 循环等待: Thread-A 等待 lockB (被 Thread-B 持有),Thread-B 等待 lockA (被 Thread-A 持有),形成闭环。
死锁的预防与避免策略
既然知道了死锁发生的四个条件,我们就可以通过破坏其中至少一个条件来预防死锁,或者使用算法来避免死锁。
破坏四大条件以预防死锁
死锁预防是通过设置限制条件,破坏产生死锁的必要条件之一,从而防止死锁发生。这是一种静态的策略,可能在一定程度上降低系统资源利用率。
- 破坏互斥条件: 通常不可行,因为很多资源(如打印机)本质上是独占的。但可以使用虚拟设备技术(SPOOLing)将独占设备改造为共享设备。
- 破坏占有并等待条件:
- 静态分配(资源预分配): 进程在运行前一次性申请所有需要的资源,只有全部满足时才分配。这破坏了“逐步申请”的条件,但可能导致资源浪费和“饥饿”现象。
- 释放原有资源: 进程在请求新资源之前,必须释放已持有的所有资源。但这可能导致资源利用效率降低,且可能引起死循环。
- 破坏非抢占条件: 当进程申请资源不满足时,必须释放已占有的所有资源,待以后重新申请。这实现了资源的强制抢占,但可能导致已进行的工作丢失。
- 破坏循环等待条件:
- 资源有序分配法: 将资源编号,进程必须按编号递增顺序请求资源。若需申请编号较大的资源,必须先释放编号较小的资源。这能有效打破环路。
银行家算法 (Banker's Algorithm)
死锁避免是在资源动态分配过程中,防止系统进入不安全状态,从而避免死锁。最常用的算法是银行家算法。
核心思想:
每个进程在运行前必须声明其对资源的最大需求。当进程申请资源时,系统先试探性地分配,然后执行安全性算法检查系统是否仍处于安全状态。
安全状态:
如果系统能按某种进程推进顺序(如 P1, P2, ..., Pn),为每个进程分配其所需的资源,直至满足每个进程对资源的最大需求,使每个进程都可顺利完成,则称系统处于安全状态。若系统处于安全状态,就不会发生死锁。
| 策略 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 死锁预防 | 破坏必要条件 | 实现简单,保证不发生死锁 | 资源利用率低,可能限制并发 |
| 死锁避免 | 银行家算法,避免不安全状态 | 资源利用率较高 | 实现复杂,需预先知道最大需求,可能限制进程启动 |
死锁的检测与解除
在某些情况下,允许死锁发生,但系统需要能够检测出死锁,并在检测到后采取措施解除死锁。这通常用于资源利用率要求高、且死锁发生概率较低的场景。
1. 死锁检测
系统通过资源分配图(Resource Allocation Graph, RAG)或等待图(Wait-For Graph)来检测死锁。
- 资源分配图简化: 不断消除那些不阻塞的进程所对应的边(即该进程能获得所需资源并运行结束),若最终所有边都能被消除,则无死锁;若图中仍存在环,则存在死锁。
- 等待图: 只包含进程节点,若进程P等待进程Q的资源,则有一条从P到Q的边。若等待图中存在环,则系统处于死锁状态。
2. 死锁解除
一旦检测到死锁,必须采取措施将其解除。常见方法包括:
资源抢占
从一个或多个进程中抢占足够的资源分配给死锁进程,以打破死锁循环。这需要考虑优先级和回滚机制。
进程终止
强行终止参与死锁的进程。
- 终止所有死锁进程: 彻底解决,但代价巨大。
- 逐个终止进程: 每次终止一个进程,直到死锁解除。需选择代价最小的进程(如运行时间最短、优先级最低)。
网友们还关心:死锁的进阶话题
除了基础的四个条件和预防策略,开发者在实际工作中还经常遇到以下相关问题:
如何定位Java死锁?
在Java开发中,可以使用以下工具定位死锁:
- jstack 命令: 使用
jstack <pid>查看线程快照,JVM通常会直接报告发现的死锁信息。 - JConsole / VisualVM: 图形化监控工具,可以直观地看到线程状态和资源锁的持有情况。
- Arthas: 阿里巴巴开源的Java诊断工具,使用
thread -b命令可以自动检测并打印死锁线程。
数据库死锁怎么处理?
数据库(如MySQL)中的死锁通常由事务并发引起。解决方法包括:
- 统一访问顺序: 所有事务以相同的顺序访问资源(如表或行)。
- 缩短事务: 减少事务持有锁的时间,尽快提交或回滚。
- 设置超时: 数据库引擎通常有死锁检测机制,超过一定时间未获取锁的事务会被回滚。
关于死锁的常见问答 (FAQ)
tryLock(timeout) 方法,允许线程在等待锁时设置超时。如果超时未获取到锁,线程可以释放已持有的锁并重试,从而在一定程度上打破“非抢占”和“循环等待”条件,降低死锁风险。