【JavaEE初阶 — 多线程】死锁的产生原因和解决方法
目录
死锁
1.构成死锁的场景
(1) 一个线程一把锁
问题描述
解决方案(可重入锁)
(2) 两个线程两把锁
问题描述
(3)N个线程 M把锁
哲学家就餐问题
2.死锁的四个必要条件
3.如何解决死锁问题
(1)避免出现请求和保持
(2)打破多个线程的循环等待关系
死锁
1.构成死锁的场景
(1) 一个线程一把锁
问题描述
我们先来看下面的代码:
看起来是两次对同一个引用的加锁,是没有必要的,但是在我们日常学习和工作中,很容易就会写出上述的对一个锁对象进行两次或者多次加锁的操作,此时会出现如下情况:
- 如果第一次加锁要解锁,就必须得先执行完{}中的代码块,就必须进行第二次加锁;
- 但是第二次加锁时,发现锁对象 locker 还未被解锁,第二次加锁因此进入阻塞等待的状态,所以第一次加锁的操作无法执行到解锁的位置;
- 上面的这种情况,就被称为“死锁”(dead lock);死锁是一个非常严重的 bug ,一旦出现,整个线程都会被卡住。
上面的代码虽然会造成死锁,但是我们不太容易写出上面的代码;
但是一旦方法调用的层次比较深,就容易出现对同一对象进行多次加锁的情况。 我们再来分析下面的代码:
- 第一次进行加锁操作,能够成功的(锁对象还没有被获取);
- 第二次进行加锁,此时意味着,锁对象是已经被占用的状态;第二次加锁,就会触发阻塞等待。
解决方案(可重入锁)
为了解决上述代码出现的死锁问题, Java 的 synchronized 就引入了可重入的概念;
当 t线程 对 locker对象 加锁成功之后,后续 t 再次针对 locker 进行加锁,不会触发阻塞,而是直接往下走,因为当前 locker 就是被 t 持有~~
但是,如果是其他线程尝试加锁,就会正常进入阻塞等待的状态;
- 如果发现是同一个锁持有者的线程,则跳过加锁环节;
- 如果是不同的锁持有者,才会进入阻塞等待。
我们运行刚刚所写的代码,发现程序是可以正常执行的:
- 理论上,程序会被上死锁,但是当我们正在运行程序时,会发现程序依旧可以正常执行,输出结果也正确;
- 这样的原因是因为 synchronized 的可重入性,解决了当前情况(一个线程针对同一个锁对象进行多次加锁)造成的死锁的问题,哪怕我们再锁三四层,synchronized 的可重入性都会解决该问题。
可重入锁只能针对 一个线程多次对锁对象进行加锁 的情况,如果是其他情况造成的死锁,则无法通过可重入锁解决。
面试官的问题:
如何自己实现一个可重入锁?
- 在锁内部记录当前是哪个线程持有的锁,后续每次加锁,都进行判定
- 通过计数器,记录当前加锁的次数,从而确定何时真正进行解锁.
(2) 两个线程两把锁
问题描述
- 现在有 t1,t2 两个线程,以及 locker1,locker2 两把锁;
- t1 获取 locker1,t2 获取 locker2 后,t1,t2再分别尝试获取 locker2,locker1,两个线程互不相让,因此进入阻塞等待,最终造成死锁的情况;(家钥匙放车里,车钥匙放家里);
因为上述这种情况,构成的死锁问题的原因,不但因为锁互斥与不可抢占的性质,也因为两个线程在加锁的过程中,造成了请求保持和循环等待;
而造成死锁的原因,是因为两个线程对两个锁对象的加锁,是嵌套的写法;并且两个线程在阻塞等待的过程,等待关系形成了循环。
我们来看下面这段代码:
package Thread;public class Demo22 {public static void main(String[] args) throws InterruptedException {Object locker1 = new Object();Object locker2 = new Object();Thread t1 = new Thread(() -> {System.out.println("t1 获取到 locker1");synchronized (locker1){try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}synchronized (locker2){System.out.println("t1 获取到 locker2");}}});Thread t2 = new Thread(() ->{synchronized (locker2){System.out.println("t2 获取到 locker2");try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}synchronized (locker1){System.out.println("t2 获取到 locker1");}}});t1.start();t2.start();t1.join();t2.join();}
}
代码逻辑:
- t1 线程 和 t2 线程 在分别获取到 locker1,locker2 之后,打印日志;
- 打印日志后,两个线程分别休眠 1s ,休眠的目的是为了防止系统随机调度线程(抢占式执行),使得其中一个线程一口气获取到 locker1,locker2两把锁;
- 在休眠结束后,两个线程分别尝试获取对方已经获取过且没有释放的锁。
我们运行程序,并提供 jconsole 查看 t1,t2 的状态:
造成死锁之后,t1,t2 都进入阻塞等待的状态,从执行结果的打印日志来看,整个进程被死锁卡住,两个线程各自加第二把锁的时候,jconsolo 的堆栈跟踪也一目了然地表明情况。
(3)N个线程 M把锁
哲学家就餐问题
大部分情况下,上述模型可以很好的运作,但是在一些极端的情况下会造成死锁:
上面的五个线程,都在获取一把锁后,尝试对另一把已经被别的线程获取过的锁,进行加锁,因此所有线程都陷入了阻塞等待的状态,并且五个线程,五把锁之间的等待过程,构成了循环。
这也使得线程与线程之间,出现了请求保持的情况;
多个线程,多把锁,出现死锁的情况,如上面五个哲学家谁到凑不齐一双筷子来吃面(造成死锁)的这种情况,是比较典型极端的,当然还有更多种出现死锁的情况;
我们先要处理典型的情况,如刚刚吃面的问题,虽然这种情况可能性很小,但是也不能忽略这种情况。
2.死锁的四个必要条件
对于我们在上面描述的,构成死锁的三个场景中,只要涉及 N 个线程,M把锁(N>1 && M>1),并且产生了死锁,原因都是满足了上述的四个必要条件。
3.如何解决死锁问题
- 刚刚构成死锁四个必要条件,锁的互斥与不可抢占,是因为锁的基本特性;
- 要通过解决锁的互斥和不可抢占,来解决死锁问题的做法非常难;
- 要想打破死锁,避免死锁,我们应该从请求与保持,或者循环等待这两个构成死锁的原因,来寻找突破点。
- 只要能够解决请求与保持,或者循环等待两个原因中的任意一个,就能够打破死锁~
(1)避免出现请求和保持
我们再来分析一下,产生死锁问题的原因,是因为请求保持的代码:
我们可以发现 ,上述代码中的两个线程,无论是 t1 还是 t2,在进行加锁的代码块中,加锁的方式都是嵌套加锁,这就使得两个线程无法获取对方的锁,又无法解锁,从而双双进入阻塞等待;
换句话说,这种构成死锁情况的原因,就叫做请求保持;
解决方法:
对于上图的代码,我们要对其进行修改:
- 对于t1线程,把 synchronized(locker2) 从 synchronized(locker1)的大括号代码块中取出
- synchronized(locker2) 和 synchronized(locker1) 在 t1 线程中,从嵌套关系变成并列关系;
- 对于 t2 线程,也作出同样的修改,使得加锁方式从嵌套加锁,修改为并列加锁:
执行结果:
所以,要想解决请求保持,就不要写出嵌套加锁的代码;但是,在日常开发中,确实会出现代码逻辑,必须要通过嵌套加锁,来完成一些操作;所以嵌套加锁很难避免;
因此,我们更通用的打破死锁的做法,就是打破多个线程之间的循环等待关系。
(2)打破多个线程的循环等待关系
我们把刚刚的并列加锁代码,还原成嵌套加锁:
只要涉及 N 个线程,M把锁(N>1 && M>1),都可以用“哲学家就餐”模型来进行描述:
只要我们对线程的加锁的顺序做出约定;所有的线程,都按照一定顺序进行加锁,就可以破除循环等待条件,进而打破死锁:
(1)
(2)
(3)
(4)
(5)
(6)
(7)
此时,看着桌子上所剩不多的 CPU 资源,t1瞬间黑化成邪恶栀子花
通过上述“哲学家就餐”模型,我们能直观的发现,只要规定好加锁顺序,就可以打破多个线程循环等待的关系,进而解决死锁问题。
我们回归代码,来感受一下约定加锁顺序(规定每个线程先获取编号小的锁,再去获取编号大的锁)后,带来的效果;
因为五个线程五把锁的情况,并不容易产生死锁,所以我们就用场景二来演示:
- 对于上述代码,我们约定每个线程都先获取编号小的锁对象;
- t1 先获取 lcoker1,再获取 locker2,满足约定的规则;
- t2 先获取 locker2,再获取 locker1,不满足约定的规则,所以需要对 t2 进行修改。
执行结果:
约定加锁顺序后,通过修改后代码的执行结果,我们可以看到, 死锁的问题就被完美的解决了~