【JavaEE初阶 — 多线程】死锁的产生原因和解决方法

时间:2024-11-09 16:03:45

 c96f743646e841f8bb30b2d242197f2f.gif

ddb5ae16fc92401ea95b48766cb03d96.jpeg692a78aa0ec843629a817408c97a8b84.gif


目录

  死锁  

  1.构成死锁的场景  

  (1) 一个线程一把锁   

  问题描述  

  解决方案(可重入锁)  

  (2) 两个线程两把锁  

  问题描述   

  (3)N个线程 M把锁   

  哲学家就餐问题  

  2.死锁的四个必要条件 

  3.如何解决死锁问题  

  (1)避免出现请求和保持  

  (2)打破多个线程的循环等待关系 


  死锁  


  1.构成死锁的场景  


  (1) 一个线程一把锁   


  问题描述  


 我们先来看下面的代码: 

c18ffdbb5cc04c9f8a06d4d2200e2a06.png看起来是两次对同一个引用的加锁,是没有必要的,但是在我们日常学习和工作中,很容易就会写出上述的对一个锁对象进行两次或者多次加锁的操作,此时会出现如下情况:

8a91473519cc4d308594382f01cac541.png

  • 如果第一次加锁要解锁,就必须得先执行完{}中的代码块,就必须进行第二次加锁;
  • 但是第二次加锁时,发现锁对象 locker 还未被解锁,第二次加锁因此进入阻塞等待的状态,所以第一次加锁的操作无法执行到解锁的位置;
  • 上面的这种情况,就被称为“死锁”(dead lock);死锁是一个非常严重的 bug ,一旦出现,整个线程都会被卡住。

上面的代码虽然会造成死锁,但是我们不太容易写出上面的代码;

但是一旦方法调用的层次比较深,就容易出现对同一对象进行多次加锁的情况。 我们再来分析下面的代码:

bdacc8d6936c476bbd6522be3c0685b0.png

  1. 第一次进行加锁操作,能够成功的(锁对象还没有被获取);
  2. 第二次进行加锁,此时意味着,锁对象是已经被占用的状态;第二次加锁,就会触发阻塞等待。

26fb071c000849ac95f85d33308b7eeb.png


  解决方案(可重入锁)  


为了解决上述代码出现的死锁问题, Java 的 synchronized 就引入了可重入的概念;

当 t线程 对 locker对象 加锁成功之后,后续 t 再次针对 locker 进行加锁,不会触发阻塞,而是直接往下走,因为当前 locker 就是被 t 持有~~

ebea33130b7c4b4cb874e110c8e73e04.png


但是,如果是其他线程尝试加锁,就会正常进入阻塞等待的状态;


805cc82e2ba54726a92301bee54b74b4.png


  • 如果发现是同一个锁持有者的线程,则跳过加锁环节;
  • 如果是不同的锁持有者,才会进入阻塞等待。

我们运行刚刚所写的代码,发现程序是可以正常执行的:

118d085356bd478bb7344f8a8f6c9974.png

  • 理论上,程序会被上死锁,但是当我们正在运行程序时,会发现程序依旧可以正常执行,输出结果也正确;
  • 这样的原因是因为 synchronized 的可重入性,解决了当前情况(一个线程针对同一个锁对象进行多次加锁)造成的死锁的问题,哪怕我们再锁三四层,synchronized 的可重入性都会解决该问题。

可重入锁只能针对 一个线程多次对锁对象进行加锁 的情况,如果是其他情况造成的死锁,则无法通过可重入锁解决。

面试官的问题:
如何自己实现一个可重入锁?

  1. 在锁内部记录当前是哪个线程持有的锁,后续每次加锁,都进行判定
  2. 通过计数器,记录当前加锁的次数,从而确定何时真正进行解锁.

  (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();
    }
}

代码逻辑:

  1. t1 线程 和 t2 线程 在分别获取到 locker1locker2 之后,打印日志;
  2. 打印日志后,两个线程分别休眠 1s ,休眠的目的是为了防止系统随机调度线程(抢占式执行),使得其中一个线程一口气获取到 locker1,locker2两把锁
  3. 在休眠结束后,两个线程分别尝试获取对方已经获取过且没有释放的锁。

143ba47421a64d73a905c8d91f04ad2e.png

我们运行程序,并提供 jconsole 查看 t1,t2 的状态:

5de9724c94e7441c94b52079acd9671c.png

造成死锁之后,t1,t2 都进入阻塞等待的状态,从执行结果的打印日志来看,整个进程被死锁卡住,两个线程各自加第二把锁的时候,jconsolo 的堆栈跟踪也一目了然地表明情况。


  (3)N个线程 M把锁   


  哲学家就餐问题  


0c09f8dc5464488889bb015a40bc037a.png

大部分情况下,上述模型可以很好的运作,但是在一些极端的情况下会造成死锁:
12ed9bc11aeb49babfeff9f3a713658a.png上面的五个线程,都在获取一把锁后,尝试对另一把已经被别的线程获取过的锁,进行加锁,因此所有线程都陷入了阻塞等待的状态,并且五个线程,五把锁之间的等待过程,构成了循环。 

这也使得线程与线程之间,出现了请求保持的情况; 

多个线程,多把锁,出现死锁的情况,如上面五个哲学家谁到凑不齐一双筷子来吃面(造成死锁)的这种情况,是比较典型极端的,当然还有更多种出现死锁的情况;

我们先要处理典型的情况,如刚刚吃面的问题,虽然这种情况可能性很小,但是也不能忽略这种情况。


  2.死锁的四个必要条件  


658141553e2e4ff88a945a6982cff111.png

对于我们在上面描述的,构成死锁的三个场景中,只要涉及 N 个线程,M把锁(N>1 && M>1),并且产生了死锁,原因都是满足了上述的四个必要条件。


  3.如何解决死锁问题  


  • 刚刚构成死锁四个必要条件,锁的互斥与不可抢占,是因为锁的基本特性
  • 要通过解决锁的互斥和不可抢占,来解决死锁问题的做法非常难;
  • 要想打破死锁,避免死锁,我们应该从请求与保持,或者循环等待这两个构成死锁的原因,来寻找突破点。
  • 只要能够解决请求与保持,或者循环等待两个原因中的任意一个,就能够打破死锁~

  (1)避免出现请求和保持  


我们再来分析一下,产生死锁问题的原因,是因为请求保持的代码: 

b7da42e2695346c9b655224fa30e1aaf.png

我们可以发现 ,上述代码中的两个线程,无论是 t1 还是 t2,在进行加锁的代码块中,加锁的方式都是嵌套加锁,这就使得两个线程无法获取对方的锁,又无法解锁,从而双双进入阻塞等待;

换句话说,这种构成死锁情况的原因,就叫做请求保持


   解决方法:  


对于上图的代码,我们要对其进行修改:

  1. 对于t1线程,把 synchronized(locker2) synchronized(locker1)的大括号代码块中取出 
  2. synchronized(locker2) synchronized(locker1) 在 t1 线程中,从嵌套关系变成并列关系
  3. 对于 t2 线程,也作出同样的修改,使得加锁方式从嵌套加锁,修改为并列加锁

6444a3f4b9884a9eacf3527791b8df32.png

 执行结果:

96dc79c9832d40ff84775fcf87af6d1a.png

所以,要想解决请求保持,就不要写出嵌套加锁的代码;但是,在日常开发中,确实会出现代码逻辑,必须要通过嵌套加锁,来完成一些操作;所以嵌套加锁很难避免

因此,我们更通用的打破死锁的做法,就是打破多个线程之间的循环等待关系


  (2)打破多个线程的循环等待关系    


我们把刚刚的并列加锁代码,还原成嵌套加锁:

3a14ee82ceaf497d980d298c073b88a8.png


只要涉及 N 个线程,M把锁(N>1 && M>1),都可以用“哲学家就餐”模型来进行描述

ad1cff50084f4e4d87041b7d57cf52d1.png

只要我们对线程的加锁的顺序做出约定;所有的线程,都按照一定顺序进行加锁,就可以破除循环等待条件,进而打破死锁:

(1)

4202253d7678464fad6eef1164bb2af3.png

(2)

9f9d34c664464b839369a6ddd0fb659f.png

(3)

00e76b618f39440ea61fe92860ae8389.png

(4)

b124ca2ecabf4c51900ca4522facb7c1.png

(5)

cbde44c408c44291901cee12e8b7e25a.png

(6)

11420fe10bec453983db912434556664.png

(7)

此时,看着桌子上所剩不多的 CPU 资源,t1瞬间黑化成邪恶栀子花

86d8c595927f43489cd22ecf27ab89ae.png


通过上述“哲学家就餐”模型,我们能直观的发现,只要规定好加锁顺序,就可以打破多个线程循环等待的关系,进而解决死锁问题。


我们回归代码,来感受一下约定加锁顺序(规定每个线程先获取编号小的锁,再去获取编号大的锁)后,带来的效果;

因为五个线程五把锁的情况,并不容易产生死锁,所以我们就用场景二来演示:

d0185cadf99641788399f15753795b2d.png

  • 对于上述代码,我们约定每个线程都先获取编号小的锁对象;
  • t1 先获取 lcoker1,再获取 locker2,满足约定的规则;
  • t2 先获取 locker2,再获取 locker1,不满足约定的规则,所以需要对 t2 进行修改。

d8837ef2b44244db9a56b72f4c4995bc.png执行结果:

192e1da1b13b4928bea2c4c722518617.png约定加锁顺序后,通过修改后代码的执行结果,我们可以看到, 死锁的问题就被完美的解决了~

 c96f743646e841f8bb30b2d242197f2f.gif

692a78aa0ec843629a817408c97a8b84.gif