什么是死锁?构成死锁的条件如何解决
什么是死锁?构成死锁的条件&如何解决
1. 什么是死锁
在计算机科学中,死锁是一种非常常见且棘手的问题。从线程和锁的角度来看,死锁主要存在三种典型情况:一线程一锁、两线程两锁以及 M 线程 N 锁。接下来,我们将通过具体的实例对这三种情况进行详细剖析。
1.1 一线程一锁
从理论层面来讲,一个线程对应一个锁时,在第一个锁尚未解锁的情况下,是无法添加第二个锁的。然而,在 Java 中存在可重入锁的概念,这就会出现一种看似特殊的情况。以下是具体的情况展示:
通过以下 Java 代码示例,我们可以更直观地理解一线程一锁以及可重入锁的特性:
public class Demo1 {
//一线程一锁->可重入
public static void main(String[] args) {
Object lock = new Object();
Thread t = new Thread(() -> {
synchronized (lock) {
synchronized (lock) {
System.out.println("可重入锁");
}
}
});
t.start();
}
}
在上述代码中,我们创建了一个线程 t,并为其分配了一个锁对象 lock。在线程的执行逻辑中,我们对同一个锁对象 lock 进行了两次 synchronized 操作,这体现了可重入锁的特性,即同一个线程可以多次获取同一个锁,而不会导致死锁。
1.2 两线程两锁
为了更形象地理解两线程两锁导致死锁的情况,我们可以类比一个生活场景:一个人吃饭需要用一双筷子,当两个人只有一双筷子时,就可能出现死锁的情况。假设 A 拿到了 1 只筷子,B 拿到了 1 只筷子,此时两人互不相让,就会陷入僵局,造成死锁。以下是对应的图示:
接下来,我们通过 Java 代码来模拟这一过程:
public class Demo2 {
//两线程两锁
public static void main(String[] args) {
Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread(() -> {
//获取lock1
synchronized (lock1) {
try {
Thread.sleep(10); // 保证t2成功获取lock2
//获取lock2
synchronized (lock2) {
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("t1结束");
});
Thread t2 = new Thread(() -> {
//获取lock2
synchronized (lock2) {
try {
Thread.sleep(10); // 保证t1成功获取lock1
//获取lock2
synchronized (lock1) {
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("t2结束");
});
t1.start();
t2.start();
}
}
在上述代码中,我们创建了两个线程 t1 和 t2,以及两个锁对象 lock1 和 lock2。线程 t1 先获取 lock1,然后尝试获取 lock2;线程 t2 先获取 lock2,然后尝试获取 lock1。由于线程之间的竞争和等待,很容易导致死锁的发生。
1.3 M 线程 N 锁
在 M 线程 N 锁的情况中,最典型的例子就是哲学家问题。如下图所示:
现在有 5 个哲学家和 5 根筷子,每个哲学家都需要两根筷子才能吃饭。在下面这种情况下,就会出现死锁:
每个哲学家都只拿到了一只筷子,并且互不相让,这就构成了死锁。
2. 构成死锁的条件
死锁的发生并不是偶然的,它需要满足一定的条件。以下是构成死锁的四个必要条件:
- 互斥:当一个线程成功拿到锁之后,其他线程若想要拿到该锁,就必须进入阻塞等待状态。这意味着在同一时刻,一个锁只能被一个线程所拥有。
- 不可剥夺:如果线程 1 已经拿到了锁,线程 2 也想要获取该锁,那么线程 2 只能阻塞等待,而无法直接从线程 1 手中剥夺该锁。
- 请求和保持:当线程在获取到锁 1 之后,在不释放锁 1 的情况下,又尝试去获取锁 2。例如在上述两线程两锁的问题中,如果线程能够先放下一个筷子(释放一个锁),再去拿另一个筷子(获取另一个锁),就不会构成死锁。
- 循环等待:在多个线程的场景中,多把锁的等待过程形成了一个循环。比如在哲学家问题中,A 等待 B 放下筷子,B 等待 C 放下筷子,C 又等待 A 放下筷子,这样就构成了循环等待,从而导致死锁的发生。
3. 解决死锁
既然我们已经了解了死锁的常见情况以及构成死锁的条件,那么接下来我们就来探讨如何解决死锁问题。上述提到的构成死锁的常见情况有三种,其中一线程对一锁的情况,由于 Java 可重入锁的存在,我们在前面已经进行了详细说明,这里就不再赘述。
3.1 二线程对二锁
对于二线程对二锁导致死锁的问题,解决方法其实并不复杂。我们只需要将并行执行的方式改为顺序执行即可。具体的执行顺序为:t1 得到 lock1 ——> t1 释放 lock1 ——> t1 得到 lock2 ——> t1 释放 lock2 ——> t1 线程结束,t2 线程同理。以下是正确的 Java 代码示例:
public class Demo2 {
//两线程两锁
public static void main(String[] args) {
Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread(() -> {
//获取lock1
synchronized (lock1) {
try {
Thread.sleep(10); // 保证t2成功获取lock2
//获取lock2
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
synchronized (lock2) {
}
System.out.println("t1结束");
});
Thread t2 = new Thread(() -> {
//获取lock2
synchronized (lock2) {
try {
Thread.sleep(10); // 保证t1成功获取lock1
//获取lock2
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
synchronized (lock1) {
}
System.out.println("t2结束");
});
t1.start();
t2.start();
}
}
通过这种顺序执行的方式,我们可以有效地避免两个线程之间因为竞争锁而导致的死锁问题。
3.2 M 线程 N 锁
解决 M 线程 N 锁导致的死锁问题,关键在于打破构成死锁条件中的第四条——循环等待。这里我们仍然以哲学家问题为例,一种有效的解决方法是约定好用餐顺序,从而实现串行化。以下是对应的图示:
通过约定用餐顺序,我们可以确保每个哲学家都能够按照一定的规则获取和释放筷子,从而避免了循环等待的发生,进而有效地解决了死锁问题。
综上所述,死锁是一个在多线程编程中需要特别关注的问题。通过深入理解死锁的概念、构成死锁的条件以及相应的解决方法,我们可以在实际编程中有效地避免死锁的发生,提高程序的稳定性和可靠性。