💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
对于我们开发者来说,出去面试的时候,经常会被问到一个问题,请谈谈你对死锁问题的理解?但是很多人都不能系统地回答出核心原理,有的人不知道如何排查死锁问题,有的人则不知道如何来解决死锁问题。那么到底该怎么系统地回答这个问题呢?今天我们就来聊聊这个话题。 在这节课,我会先给你介绍下死锁的概念,然后基于一个小场景,来模拟死锁问题的产生,并利用工具来排查死锁问题,最后我们再来看怎么在开发过程中避免死锁问题的产生。 # 死锁的概念 死锁一般发生在多线程执行的过程中,也就是两个或两个以上的线程在执行的时候,因为争夺资源会造成线程间互相等待,这种情况就是产生了死锁问题,在没有外力作用的情况下,这些线程会一直相互等待,没办法继续运行。 就比如这张图(图 1),你可以看到有两个资源,资源 1 和资源 2,和两个线程,分别为线程 A 和线程 B。线程 A 在已经获取了资源 2 的情况下,期望获取线程 B 持有的资源 1;而线程 B 在已经获取了资源 1 的情况下,期望获取线程 A 持有的资源 2,那么线程 A 和线程 B 就处于了相互等待的死锁状态。在没有外力干涉的情况下,线程 A 和线程 B 就会一直处于相互等待状态,不能处理其他任务,那这两个线程也就白白浪费掉了。 死锁产生的四个必要条件 对应死锁的概念,我们来看线程死锁问题产生的条件。相信你在学习操作系统时,就知道线程死锁需要四个必要条件: 第一,互斥条件。指的是多个线程不能同时使用同一个资源,比如线程 A 已经持有的资源,不能同时在被线程 B 持有。如果线程 B 请求获取被线程 A 已经占有的资源,那线程 B 只能等,等到这个资源被线程 A 释放。 第二,持有并等待条件。指的是当线程 A 已经持有了资源 1,又提出想申请资源 2,但是资源 2 已经被线程 C 占有了,所以线程 A 就会处于等待状态,但它在等待资源 2 的同时并不会释放自己已经获取的资源 1。 第三,不可剥夺条件。是指线程 A 获取到资源 1 后,在自己使用完之前不能被其它线程比如线程 B 抢占使用。如果线程 B 也想使用资源 1,只能在线程 A 使用完主动释放后获取。 第四,环路等待条件。在发生死锁的时候,必然存在一个线程,也就是资源的环形链,比如线程 A 已经获取了资源 2,但是请求获取资源 1;线程 B 已经获取了资源 1,但是请求获取资源 2,这就会形成一个线程和资源请求等待的环形图。 死锁只有同时满足互斥、持有并等待、不可剥夺、环路等待这四个条件的时候才会发生。那么,我们该如何尽早排查死锁问题呢? # 模拟死锁问题与排查 下面我们使用 Java 代码来模拟一个死锁场景: ~~~ public class DeadLockDemo { ... // 1. 创建资源 private static Object resourceA = new Object(); private static Object resourceB = new Object(); public static void main(String[] args) { //2. 创建线程 A Thread threadA = createThreadA(); //3. 创建线程 B Thread threadB = createThreadB(); //4. 启动线程 threadA.start(); threadB.start(); } } ~~~ ~~~ private static Thread createThreadA() { Thread threadA = new Thread(() -> { //2.1 尝试获取资源 A synchronized (resourceA) { System.out.println(Thread.currentThread() + " got ResourceA"); //2.2 休眠 1s try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + "waiting get ResourceB"); //2.3 尝试获取资源 B synchronized (resourceB) { System.out.println(Thread.currentThread() + "got ResourceB"); } } }, "ThreadA"); return threadA; } ~~~ ~~~ private static Thread createThreadB() { Thread threadB = new Thread(() -> { //3.1 尝试获取资源 B synchronized (resourceB) { System.out.println(Thread.currentThread() + " got ResourceB"); //3.2 休眠 1s try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + "waiting get ResourceA"); //3.3 尝试获取资源 A synchronized (resourceA) { System.out.println(Thread.currentThread() + "got ResourceA"); } } }, "ThreadB"); return threadB; } ~~~ 你可以看到,代码 1 创建了两个资源对象,分别为 resourceA 和 resourceB; 代码 2createThreadA 方法创建了一个名称为 ThreadA 的线程,这个线程启动后会先执行代码 2.1 的 synchronized 块试图获取 resourceA 上的对象锁,它获取成功后会休眠 1 秒,然后执行代码 2.3 尝试使用 synchronized 块获取 resourceB 上的对象锁。 代码 3createThreadB 是创建了一个名称为 ThreadB 的线程,这个线程启动后会先执行代码 3.1 的 synchronized 块试图获取 resourceB 上的对象锁,它获取成功后也会休眠 1 秒,然后执行代码 2.3 尝试使用 synchronized 块获取 resourceA 上的对象锁。 代码 4 则启动两个线程运行;运行上面代码后,可能会输出这样的结果: ~~~ Thread[ThreadA,5,main] got ResourceA Thread[ThreadB,5,main] got ResourceB Thread[ThreadA,5,main]waiting get ResourceB Thread[ThreadB,5,main]waiting get ResourceA ~~~ 从这段输出中,我们可以发现 ThreadA 一直卡到获取 ResourceB 的地方,ThreadB 则一直卡在获取 ResourceA 的地方,从而导致程序无法正常向下运行。那么为啥会卡到这里呢? 下面我们使用 JDK 自带的打印线程堆栈的 jstack pid(进程 ID) 命令,看下当前 JVM 中的线程堆栈,对应上面输出结果的一个线程堆栈是这样的: ~~~ Found one Java-level deadlock: ============================= "ThreadB": waiting to lock monitor 0x00007f886e832168 (object 0x000000076b839ff0, a java.lang.Object), which is held by "ThreadA" "ThreadA": waiting to lock monitor 0x00007f886e8349f8 (object 0x000000076b83a000, a java.lang.Object), which is held by "ThreadB" Java stack information for the threads listed above: =================================================== "ThreadB": at org.mysql.DeadLockDemo.lambda$main$1(DeadLockDemo.java:49) - waiting to lock <0x000000076b839ff0> (a java.lang.Object) - locked <0x000000076b83a000> (a java.lang.Object) at org.mysql.DeadLockDemo$$Lambda$2/577405636.run(Unknown Source) at java.lang.Thread.run(Thread.java:748) "ThreadA": at org.mysql.DeadLockDemo.lambda$main$0(DeadLockDemo.java:31) - waiting to lock <0x000000076b83a000> (a java.lang.Object) - locked <0x000000076b839ff0> (a java.lang.Object) at org.mysql.DeadLockDemo$$Lambda$1/2011482127.run(Unknown Source) at java.lang.Thread.run(Thread.java:748) Found 1 deadlock. ~~~ 从这个线程堆栈我们可以知道什么呢?有一处死锁。其中 ThreadB 获取到了地址为 0x000000076b83a000 的对象(resourceB)的锁,然后等待获取地址为 0x000000076b839ff0 的对象(resourceA)的锁;而 ThreadA 获取到了地址 0x000000076b839ff0 对象(resourceA)的锁,然后等待获取地址为 0x000000076b83a000 的对象(resourceB)的锁。这解释了 ThreadA 为啥一直卡到获取 ResourceB 的地方,而 ThreadB 一直卡在获取 ResourceA 的地方。 经过我们刚才的分析,就能知道代码是出现了死锁问题,导致线程被阻塞,从而导致被阻塞的线程不能继续向下运行了。那我们该怎么修改前面那段代码,从而避免死锁呢? # 如何避免死锁问题的产生 刚才我们也说了,死锁的产生需要同时满足四个必要条件,反过来说,预防死锁就只需要我们至少破坏其中一个条件。最常见的并且可行的就是使用资源有序分配法来破坏循环等待条件,从而避免死锁的产生。那什么是资源有序分配呢? 比如前面的代码例子,ThreadA 是先尝试获取 ResourceA,然后尝试获取资源 ResourceB;而 ThreadB 则是先尝试获取资源 ResourceB,然后尝试获取资源 ResourceA;这就不是资源有序分配的,因为 ThreadA 和 ThreadB 获取资源的顺序不一样。 资源有序分配是指当 ThreadA 是先尝试获取 ResourceA,然后尝试获取资源 ResourceB 时,ThreadB 也是先尝试获取 ResourceA,然后尝试获取资源 ResourceB;或者当 ThreadA 是先尝试获取 ResourceB,然后尝试获取资源 ResourceA,ThreadB 也是先尝试获取 ResourceB,然后尝试获取资源 ResourceA。也就是 ThreadA 和 ThreadB 总是以相同的顺序申请自己想要的资源。 我们可以使用资源有序分配法修改上面的例子,其中我们保持 createThreadA 方法,不变,createThreadB 代码修改为: ~~~ private static Thread createThreadB() { Thread threadB = new Thread(() -> { //3.1 尝试获取资源 A synchronized (resourceA) { System.out.println(Thread.currentThread() + " got ResourceA"); //3.2 休眠 1s try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + "waiting get ResourceB"); //3.3 尝试获取资源 B synchronized (resourceB) { System.out.println(Thread.currentThread() + "got ResourceB"); } } }, "ThreadB"); return threadB; } ~~~ 你可以看到,代码 ThreadA 是先尝试获取 ResourceA,然后尝试获取资源 ResourceB;ThreadB 则也是先尝试获取 ResourceA,然后尝试获取资源 ResourceB,符合资源有序分配的原则。 然后运行刚才那段代码,一个可能的输出结果是这样的: ~~~ Thread[ThreadA,5,main] got ResourceA Thread[ThreadA,5,main]waiting get ResourceB Thread[ThreadA,5,main]got ResourceB Thread[ThreadB,5,main] got ResourceA Thread[ThreadB,5,main]waiting get ResourceB Thread[ThreadB,5,main]got ResourceB ~~~ 我们可以看到 ThreadA 先后获取到资源 ResourceA 和 ResourceB,然后线程 B 也先后获取到资源 ResourceA 和 ResourceB,最后程序正常终止运行,就不会出现死锁现象。 好了,关于线程死锁问题的产生与避免,我们今天就讲到这里。简单来说,死锁问题的产生是由两个或两个以上线程并行执行的时候,争夺资源而互相等待造成的。死锁只有同时满足互斥、持有并等待、不可剥夺、环路等待这四个条件的时候才会发生,所以要避免死锁问题,最简单的办法就是用资源有序分配法来破坏循环等待条件。 在日常开发环境中,死锁问题的发生还是比较常见的,比如批量更新数据库时,如果不在插入前使用资源有序分配法对批量数据根据唯一键排序,也会发生死锁现象的。所以,建议你在开发过程中,要有多线程并发的思维,从并发的角度去思考,再结合资源有序分配原则,就可以大大避免死锁问题的发生。