对于我们开发者来说,出去面试的时候,经常会被问到一个问题,请谈谈你对死锁问题的理解?但是很多人都不能系统地回答出核心原理,有的人不知道如何排查死锁问题,有的人则不知道如何来解决死锁问题。那么到底该怎么系统地回答这个问题呢?今天我们就来聊聊这个话题。
在这节课,我会先给你介绍下死锁的概念,然后基于一个小场景,来模拟死锁问题的产生,并利用工具来排查死锁问题,最后我们再来看怎么在开发过程中避免死锁问题的产生。
死锁的概念
死锁一般发生在多线程执行的过程中,也就是两个或两个以上的线程在执行的时候,因为争夺资源会造成线程间互相等待,这种情况就是产生了死锁问题,在没有外力作用的情况下,这些线程会一直相互等待,没办法继续运行。
就比如这张图(图 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,最后程序正常终止运行,就不会出现死锁现象。
好了,关于线程死锁问题的产生与避免,我们今天就讲到这里。简单来说,死锁问题的产生是由两个或两个以上线程并行执行的时候,争夺资源而互相等待造成的。死锁只有同时满足互斥、持有并等待、不可剥夺、环路等待这四个条件的时候才会发生,所以要避免死锁问题,最简单的办法就是用资源有序分配法来破坏循环等待条件。
在日常开发环境中,死锁问题的发生还是比较常见的,比如批量更新数据库时,如果不在插入前使用资源有序分配法对批量数据根据唯一键排序,也会发生死锁现象的。所以,建议你在开发过程中,要有多线程并发的思维,从并发的角度去思考,再结合资源有序分配原则,就可以大大避免死锁问题的发生。