首页 > 编程笔记 > Java笔记 阅读:88

什么是线程死锁,多线程中怎么解决死锁(非常详细)

我们先看生活中的一个实例:两个人面对面过独木桥,甲和乙都已经在桥上走了一段距离,即占用了桥的资源,甲如果想通过独木桥,乙必须退出桥面让出桥的资源,让甲通过,但是乙不同意甲先通过,于是二人僵持不下,导致谁也过不了桥,进而形成一个死局。

在计算机系统中存在类似的情况,多线程以及多进程可以改善系统资源的利用率并提高系统的处理能力。然而,并发执行带来了新的问题——死锁。

所谓线程死锁,是指两个或多个线程在执行过程中争夺资源而造成的一种僵局。当线程进入死锁状态时,每个线程都持有一个资源并等待另一个线程持有的另一个资源,它们将无法继续执行,因为每个线程都在等待被另一个线程持有的资源释放,如下图所示。

产生线程死锁的原因

产生死锁的主要原因通常归结为以下 4 个必要条件,当这些条件同时满足时,死锁就可能产生。

1) 互斥条件

资源不能被多个线程共享,只能同时由一个线程持有。如果另一个线程请求被线程持有的资源,则请求线程必须等待,直到资源被释放,类似于独木桥每次只能通过一个人。

2) 占有和等待

已经得到了某些资源的线程可以再请求新的资源,同时它不释放已占有的资源,类似于甲不退出桥面,乙也不退出桥面。

3) 非剥夺条件

一旦资源被线程占有,在该线程使用完并释放之前,其他线程无法强制剥夺该资源,类似于甲不能强行让乙退出桥面,乙也不能强行让甲退出桥面。

4) 循环等待

存在一种线程资源的循环等待关系,即线程 A 等待线程 B 持有的资源,线程 B 等待线程 C 持有的资源……线程 N 等待线程 A 持有的资源,形成一个闭合的循环等待资源链,类似于乙不退出桥面,甲不能通过;甲不退出桥面,乙不能通过。

在并发编程中,死锁的产生可能有很多具体的场景,以下是一些常见情况:
下面我们来看一个 Java 死锁示例,这个示例中,我们将创建两个线程,它们都试图获取相同的两个资源,但是以不同的顺序进行获取。这将导致循环等待,进而满足死锁的条件。
public class DeadlockDemo {
    public static void main(String[] args) {
        // 创建两个资源
        final String resource1 = "resource1";
        final String resource2 = "resource2";
        // 第一个线程试图先锁定resource1,然后锁定resource2
        Thread t1 = new Thread(() -> {
            synchronized (resource1) {
                System.out.println("Thread 1: Locked " + resource1);
                try {
                    Thread.sleep(100);
                } catch (Exception e) {
                }
                synchronized (resource2) {
                    System.out.println("Thread 1: Locked " + resource2);
                }
            }
        });
        // 第二个线程试图先锁定resource2,然后锁定resource1
        Thread t2 = new Thread(() -> {
            synchronized (resource2) {
                System.out.println("Thread 2: Locked " + resource2);
                try {
                    Thread.sleep(100);
                } catch (Exception e) {
                }
                synchronized (resource1) {
                    System.out.println("Thread 2: Locked " + resource1);
                }
            }
        });
        // 启动两个线程
        t1.start();
        t2.start();
    }
}
在上述代码中,如果 t1 在 t2 之前启动并锁定了 resource1,然后被操作系统挂起,t2 执行,这时 t2 将锁定 resource2。接下来,当 t1 被唤醒并试图锁定 resource2 时,它不能成功锁定,因为 resource2 已经被 t2 锁定。

与此同时,t2 也会尝试锁定 resource1,同样无法成功锁定,因为 resource1 已经被 t1 锁定。这样,两个线程就陷入了死锁,都在等待对方释放锁。

在实际的多线程程序设计中,死锁的情况可能会更加复杂,需要采用仔细的设计和规划来避免。避免死锁的方法有很多,例如预分配资源,使用锁顺序、锁超时、死锁检测算法,将资源分层等。有效的并发设计和良好的编程实践是避免死锁的关键。

如何避免和解决线程死锁?

线程死锁会导致程序中的线程无限等待资源,浪费系统资源,降低程序效率,但有些情况下死锁是可以避免的,下面介绍几种用于避免和解决线程死锁的方法。

1) 控制锁定顺序

控制锁定顺序,确保程序中所有线程按照一致的顺序获取多个锁,可以避免循环等待条件的发生。

当多个线程需要相同的一些锁,但是按照不同的顺序获取锁时,死锁就很容易发生。如果能确保所有的线程都按照相同的顺序获取锁,死锁就不会发生。

控制锁定顺序避免死锁的方法的原理是对所有线程需要锁定的资源进行全局排序,并强制所有线程按照这一预定义的顺序来获取锁。这样做可以有效地避免循环等待条件,而循环等待条件是死锁产生的 4 个必要条件之一。

下面我们通过一个 Java 示例对这种方法进行说明。假设有两个资源 resource1 和 resource2,以及两个线程(它们需要分别锁定这两个资源)。为了避免死锁,我们规定所有线程必须先锁定 resource1,再锁定 resource2。
public class AvoidDeadlockExample {
    private Object resource1 = new Object();
    private Object resource2 = new Object();
    public void method1() {
        synchronized (resource1) { // 先锁定resource1
            System.out.println(Thread.currentThread().getName() + " locked resource 1");
            // 模拟操作资源,如对数据进行处理
            sleep(50);
            synchronized (resource2) { // 再锁定resource2
                System.out.println(Thread.currentThread().getName() + " locked resource 2");
                // 执行任务
            }
        }
    }
    public void method2() {
        synchronized (resource1) { // 和method1()一样,先锁定resource1
            System.out.println(Thread.currentThread().getName() + " locked resource 1");
            // 模拟操作资源
            sleep(50);
            synchronized (resource2) { // 再锁定resource2
                System.out.println(Thread.currentThread().getName() + " locked resource 2");
                // 执行任务
            }
        }
    }
    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
    public static void main(String[] args) {
        AvoidDeadlockExample example = new AvoidDeadlockExample();
        Thread t1 = new Thread(example::method1, "Thread-1");
        Thread t2 = new Thread(example::method2, "Thread-2");
        t1.start();
        t2.start();
    }
}
在上述示例中,method1() 和 method2() 都需要锁定 resource1 和 resource2。通过强制这两个方法都按照先锁定 resource1、再锁定 resource2 的顺序执行,我们消除了循环等待条件,从而解决了死锁问题。即使这两个线程同时运行,它们也会按照相同的顺序尝试获取锁,因此不会相互阻塞。

需要注意的是,资源的排序和锁定顺序需要根据实际情况来确定,并且应做到全局一致。这意味着所有相关的代码都必须遵循这一顺序。如果获取锁的顺序在代码中部分不一致,就有可能发生死锁。

2) 设置加锁时限

加锁时限是一种在尝试获取锁时设置一个超时时间的策略,在尝试获取锁时,给请求设定一个时间。如果线程无法在指定时间内获得所有需要的锁,则释放它已经持有的任何锁,并等待一段时间后重试。这可以防止线程无限期地等待资源。

下面我们通过 Java 中 ReentrantLock 类的示例来进行说明:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;
public class TimeoutLockExample {
    private final Lock lock1 = new ReentrantLock();
    private final Lock lock2 = new ReentrantLock();
    public void process() {
        try { // 尝试获取第一个锁,最多等待50ms
            if (lock1.tryLock(50, TimeUnit.MILLISECONDS)) {
                System.out.println(Thread.currentThread().getName() + "acquired lock1");
                // 在这里添加额外处理,可能包括休眠以模拟长操作
                Thread.sleep(10); // 用于模拟处理内容
                try {
                    if (lock2.tryLock(50, TimeUnit.MILLISECONDS)) {
                             // 尝试获取第二个锁,最多等待50ms
                        try {
                            System.out.println(Thread.currentThread().getName() + " acquired lock2");
                            // 执行与锁关联的临界区代码
                        } finally {
                            lock2.unlock(); // 确保释放第二个锁
                        }
                    } else {
                        System.out.println(Thread.currentThread().getName()
                                + " could not acquire lock2");
                    }
                } finally {
                    lock1.unlock(); // 确保释放第一个锁
                    System.out.println(Thread.currentThread().getName() + " released lock1");
                }
            } else {
                System.out.println(Thread.currentThread().getName() + " could not acquire lock1");
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.out.println(Thread.currentThread().getName() + " was interrupted");
        }
    }
    public static void main(String[] args) {
        TimeoutLockExample example = new TimeoutLockExample();
        Thread t1 = new Thread(example::process, "Thread-1");
        Thread t2 = new Thread(example::process, "Thread-2");
        t1.start();
        t2.start();
    }
}
在上述示例中,每个线程尝试先获取 lock1,然后获取 lock2,每次尝试获取锁时最多等待 50ms。如果线程在等待期间无法获取锁,则 tryLock() 方法会返回 false,线程不会无限期地等待这个锁,这样就保证了即使资源被另一个线程持有,线程也不会陷入死锁,它可以尝试其他操作或者重试。

使用设置加锁时限的方法需要谨慎设置合理的超时时间,确保线程有足够的时间来完成它们的任务,同时又不会因为过长的等待而导致系统效率低下。实际中,可能需要通过测试和调整来确定最适合应用的超时时间。

3) 采用死锁检测

死锁检测是一个更好的死锁预防机制,它主要针对那些不可能实现控制锁定顺序并且设置加锁时限的方法也不可行的场景。

死锁检测是一种动态地解决死锁问题的方法,其基本思想是在运行时检测资源分配和线程等待的图形,寻找循环等待的情况。如果检测到循环等待,就意味着发生了死锁,系统就可以采用某种恢复措施,比如中断一个或多个线程,撤销并回滚其操作,以打破循环等待条件。

每当一个线程获得了锁时,系统会在线程和锁相关的数据结构中将其记下。除此之外,每当有线程请求锁时,也需要将其记录在这个数据结构中。当一个线程请求锁失败时,这个线程可以遍历锁的关系图看一看是否有死锁发生。

例如,线程 A 请求锁 1,但是锁 1 这个时候被线程 B 持有,这时线程 A 就可以检查线程 B 是否已经请求了线程 A 当前所持有的锁(锁 2);如果线程 B 确实有这样的请求,就意味着发生了死锁(线程 A 拥有锁 2,请求锁 1;线程 B 拥有锁 1,请求锁 2)。

当然,死锁一般要比两个线程互相持有对方的锁这种情况要复杂得多。例如,假设线程 A 等待线程 B,线程 B 等待线程 C,线程 C 等待线程 D,线程 D 又等待线程 A。线程 A 为了检测死锁,它需要递进地检测所有被 B 请求的锁。从线程 B 所请求的锁开始,线程 A 找到了线程 C,又找到了线程 D,发现线程 D 请求的锁被自己持有,这时它才知道发生了死锁。

最常见的死锁检测算法就是资源分配图算法。在资源分配图中,有两种类型的节点:
节点之间的边有两种类型:
下图是一幅关于 4 个线程(A、B、C 和 D)之间锁占有和请求的关系图,我们可以基于这样的数据关系来检查是否存在死锁。


当一个线程请求一个资源但不能立即得到满足时,资源分配图中就会添加一条请求边;当一个线程得到一个资源时,资源分配图中就会添加一条分配边。然后,死锁检测算法会定期或按需运行,查找资源分配图中是否存在循环等待路径。如果存在这样的路径,就说明发生了死锁。

当检测到死锁之后,系统可以使用下面一些策略:
在复杂的系统中,死锁检测和恢复可能会带来显著的性能开销,并且有时候恢复措施可能会导致较大的资源浪费或工作丢失。因此,在设计和实施这些系统时,必须仔细权衡死锁检测和恢复的成本与其带来的益处。

除了上述常用的几种避免死锁的方法外,还有减少锁数量、使用替代锁、优先级分配等其他方法,大家可以在实践中不断总结。

相关文章