当前位置: 首页 > article >正文

【多线程奇妙屋】收藏多年的线程安全问题大全笔记(下篇) { 死锁问题 },笔记一生一起走,那些日子不再有

本篇会加入个人的所谓鱼式疯言

❤️❤️❤️鱼式疯言:❤️❤️❤️此疯言非彼疯言

而是理解过并总结出来通俗易懂的大白话,

小编会尽可能的在每个概念后插入鱼式疯言,帮助大家理解的.

🤭🤭🤭可能说的不是那么严谨.但小编初心是能让更多人能接受我们这个概念 !!!

在这里插入图片描述

引言

在当今的计算机世界中,线程安全是一个至关重要的话题。而当涉及到线程安全的死锁问题时,它就像是隐藏在系统深处的一颗 “定时炸弹”,随时可能引发意想不到的故障。死锁,这一复杂而又神秘的现象,不仅会影响程序的正常运行,还可能导致系统陷入瘫痪。让我们一同深入探究线程安全的死锁问题,揭开它那层神秘的面纱,了解其背后的原理与解决之道。

目录

  1. synchronized 的回顾

  2. 死锁问题

  3. 内存可见性问题

一. synchronized 的回顾

1. 线程安全问题的回顾

在这里插入图片描述

在上篇线程安全问题中, 就如上图中, 如果两个线程对同一个变量进行改操作, 由于修改操作是 非原子性 的, 就会导致最终的结果出现 BUG

在这里插入图片描述

那么针对上述问题 , Java的api 封装了系统原生的 : synchrnized, 通过 给对象加锁 的方式用来使 原先原子性的修改操作规定了原子性 , 从而解决 线程不安全问题

关于原子性的原理理解, 小编在上一篇文章中有详细的讲解, 由于这个内容比较多, 小编在这就不赘述了。

关于 synchronized 的使用, 在上一篇中, 小编特别提及了使用的方式:

  • 要对 同一锁对象进行加锁

  • 并且是对 同一变量修改

以上是上一篇的关于 synchronized内容 的大体使用细节, 除了注意以上这些细节, 还有可能会出现下面的 死锁问题

二. 死锁问题

在使用synchronized 的时候, 虽然解决了线程安全问题, 但是在使用锁的情况下, 也会产生 三种典型的死锁问题:

  • 重复加锁

  • 双方互相加锁

  • 循环加锁

1. 重复加锁

在这里插入图片描述

什么是重复加锁呢?就是给 一个线程内部的业务代码对同一个对象进行反复加锁

class Counter {
    public int count = 0;

    // 方法这边又加上锁了
    public void add() {
        synchronized (this) {
            count++;
        }
    }

}

 class Demo10 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
//                方法的外面加上了锁
                synchronized (counter) {
                    counter.add();
                }
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                synchronized (counter) {
                    counter.add();
                }
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("counter=" + counter.count);

    }
}

如上述代码, 当 线程内部方法的外部 已经加上了 , 已经 加上了锁了之后 , 接着又 在方法的内部加上了一层 ,这里就形成了 重复加锁 , 就会出现 死锁 的现象。

具体是怎么形成死锁的?

小编把上述代码替换成上面的形式,

synchronized (counter) {
		synchronized (this) {
            count++;
        }
}

在理解流程之前, 我们先得理解 synchronized 一个概念:执行到 { 加锁,执行到 } 代表 解锁

  • 首先进入第一个 synchronized 的 { , 先进入第一个 { , 就对 counter 这个对象进行加锁

  • 然后当进行第二个 synchronized { , 由于第一个 counter 已经被 外层的第一个 synchronized 拿到了, 第二个synchronized 就无法拿到锁对象 , 就会一直进入 阻塞等待 的状态。

  • 最后由于 第一个synchronized 正在拿到锁对象 并且被 第二个 synchronized 而 无法执行到 } , 就一直处于无法释放锁的状态。

  • 如此循环往复 , 就会导致第一个 锁一直无法释放锁 , 第二个线程一直阻塞等待, 这样 相互阻挡 , 就使 整个线程都处于阻塞等待 的状态,我们就称之为 : 死锁

那我们看看实际运行结果吧 ~

在这里插入图片描述

看到这里小伙伴们是不是很惊奇,问: 小编, 你是不是讲错了!

其实小编并没有讲错, 没有出现阻塞等待的情况,

是因为 JVM 内部识别 了重复锁的可能,就是说当 开发人员不小心加了重复锁 , 就会 出现死锁现象, 所以 JVM 就设置 synchronized可重入锁: 把所以的 重复锁都当成一把锁 来执行。

鱼式疯言

补充细节

  1. : 注意这里是在 一个线程中两把锁对同一个对象进行加锁 , 在这个过程中注意有三个条件: 同一个线程两把锁同一个对象; 在这三者中缺一不可, 否则就 无法产生死锁

原理补充

在这里插入图片描述

有图有真相, 关于 JVM 怎么实现 可重入锁 的, 一般我们用 引用计数 ,遇到 {count++, 遇到 }count- - , 只要当 count = 0 的那时, 就说明需要解锁了

2. 双方互相加锁

在这里插入图片描述

双方互相加锁: 指的是 两个线程中各自两把锁对两个对象同时进行加锁 产生了 死锁 的现象。

class Demo17 {
    private static Object locker1 = new Object();
    private static Object locker2 = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                System.out.println("t1 加锁 locker1 完成");

                // 这里的 sleep 是为了确保, t1 和 t2 都先分别拿到 locker1 和 locker2 然后在分别拿对方的锁.
                // 如果没有 sleep 执行顺序就不可控, 可能出现某个线程一口气拿到两把锁, 另一个线程还没执行呢, 无法构造出死锁.
                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 加锁 locker1 完成");

                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                synchronized (locker1) {
                    System.out.println("t2 加锁 locker2 完成");
                }
            }
        });

        t1.start();
        t2.start();
    }
}

在这里插入图片描述

入上图和代码, 我们就很清晰的可以看到, 这里两个线程:t1t2

分析流程

  • 首先 t1 去竞争锁对象: locker1 , t2 去竞争锁对象: locker2 ,

  • 当双方都拿到了各自的锁之后, t1 又想去竞争 locker2而同时 t2 又去想竞争 locker1

  • 但是此时 locker1 由 t1 拿着, locker2 由 t2 拿着,谁也不让谁, 就会出现一直僵持下去, 让t1 一直拉着 locker1 而不释放锁, 同时让 t2 也一直拿着 locker2 而不释放锁。

  • 最终使着 两个线程都处于阻塞等待 的状态, 于是就出现上述的死锁的情况。

可能小伙伴们还不是很理解吧~

下面小编举个生活栗子来:

比如有一个我和女神出去吃饭, 因为我和女神都有一个共同的美食就是: 喜欢吃饺子并且都喜欢加醋和酱油。

于是我们每人都点了一份饺子, 于是我们就开始吃起饺子了。

首先, 女神先拿到醋, 于此同时我拿到酱油,

然后加完酱油之后我想继续加点她手里的醋,但是 女神那边加完醋之后也想加完酱油

于是女神说:“你先把酱油给我, 我再给你醋” , 然后我就说了: “你先把醋给我, 我再给你酱油” 。

最终我们由于互相拿到对方想要的, 就好像互相拿着对方的锁, 而不能及时释放, 就会一直僵持下去, 最终出现了死锁。

3. 循环加锁

在这里插入图片描述

循环加锁就说 n 个线程去竞争 m 个锁的所造成的死锁 的现象。

由于这个线程和锁的个数不明确,下面小编就通过故事的栗子来讲解

在这里插入图片描述

如上图, 这是一个经典的 “哲学家就餐问题”

哲学家每天都干两件事情, 干饭和思考

但是面前的情况是: 有 五个哲学家, 桌子上只有五根筷子

  • 首先, 哲学家们就先拿起一根筷子

  • 然后当哲学家想吃饭必须要拿起 第二根筷子 , 但是桌子上已经被每人一根拿到了。

  • 于是就需要其中一个哲学家放下一根筷子,但是哲学家们都是很倔强的, 谁不愿意放下自己手中的筷子,于是谁也不让谁, 最终 五个哲学家都处在僵持的状态 下, 谁也吃不到饭 ,

就好像 五个线程都相互对方想要的五个锁 的之一, 都想拿锁. 但是 锁已经被各自拿到, 线程之间又要竞争锁, 就会 互相阻塞 , 最近出现 死锁 的情况 。

鱼式疯言

出现死锁问题的四个必备的条件:

  1. 锁的互斥性: 一个线程加上锁另一个线程就无法加上锁
  1. 锁是不可抢占的: 当一个线程加上锁之后, 另一个锁想加锁就需要阻塞等待等待这个锁中的代码执行完毕 , 才轮到 另一个线程进行加锁

对于以上这两点是 锁synchronized 的基本特性 , 所以 一般我们无法改变

  1. 请求和保持: 一个线程在 不释放锁 A 的情况, 继续去拿锁B的, 就很有可能出现死锁, 但是 一个线程在释放锁 A 后, 再去拿锁B 就不太会出现 死锁的现象
  1. 循环等待/ 环路等待/ 循环依赖: 如果出现了 多个线程竞争锁出现了循环等待, 就需要参考下面的解决死锁问题的方案。

条件三和条件四 避免 死锁问题 的关键, 小伙伴们多多理解哦~

4. 死锁的解决方案

对于上述的死锁情况, 有问题就会有方案来解决。

  1. 首先, 针对重复加锁的情况: 我们 引用计数的机制多个锁都等效 为一把锁即可。 这里就小编就不赘述了, 上面有提及到。

  2. 关于 双方互相加锁 和 循环加锁的情况, 通常采用顺序加锁:

也就是说我们规定,先给每个锁编号, 让所有的线程依次从编号小的锁开始加锁。

在这里插入图片描述

也就是如上图, 让线程2 竞争到小编号编号1 和编号2 , 然后其他哲学家进行等待, 等待线程2 用完, 于是 线程3 竞争 编号2 和编号3

于是依次下来, 就可以 顺利完成就餐

class Demo17 {
    private static Object locker1 = new Object();
    private static Object locker2 = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                System.out.println("t1 加锁 locker1 完成");

                // 这里的 sleep 是为了确保, t1 和 t2 都先分别拿到 locker1 和 locker2 然后在分别拿对方的锁.
                // 如果没有 sleep 执行顺序就不可控, 可能出现某个线程一口气拿到两把锁, 另一个线程还没执行呢, 无法构造出死锁.
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                synchronized (locker2) {
                    System.out.println("t1 加锁 locker2 完成");
                }
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (locker1) {
                System.out.println("t2 加锁 locker1 完成");

                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                synchronized (locker2) {
                    System.out.println("t2 加锁 locker2 完成");
                }
            }
        });

        t1.start();
        t2.start();
    }
}

在这里插入图片描述

如上图,

两个线程都同时竞争 locker1

要么 线程t1 竞争到锁 , 于此 同时t2 就要进行等待 , 要么 线程t1 竞争到锁, 线程t2 进行等待要么线程t2竞争到锁, 线程t1 进行等待 .

接着再进行locker2 的竞争。 这样的话就 不会产生冲突,出现死锁的情况 , 按照这样的次序有序执行就很难出现 死锁

鱼式疯言

综上所述

如果 一个线程重复死锁引用计数排查

如果是 多个线程缠绕死锁 ,就 顺序加锁

三. 内存可见性问题

1. 内存可见性问题的简介

线程安全的第四个问题: 内存可见性问题

在前面的线程安全问题中, 我们是针对 两个线程针对同一个变量进行修改

那么我们试想一下, 如果 一个线程修改, 另一个线程读取 , 那么是否会出现 线程安全问题 呢?

class Demo18 {
    private static  int n = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            while (n == 0) {

            }
            System.out.println("t1 线程结束循环");
        });

        Thread t2 = new Thread(() -> {

            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数: ");
            n = scanner.nextInt();
        });

        t1.start();
        t2.start();
        
        t1.join();

    }
}

在这里插入图片描述

如上代码和运行结果:

上面 t1 进行查看, 通过 循环的方式检验该变量n是否为 0

然后 t2 进行修改, 但是我们可以看到是, 当我们 输入一个非0数字并赋值给n 后

结果发现该线程一直处于 死循环 的状态, 而发生这样的问题, 我们就称之为 内存可见性问题

2. 内存可见性的本质

在这里插入图片描述

上面我们可以看到: 当 一个线程修改, 另一个线程读取的时候 , 读取出来的值是修改前的值, 而不是修改后的值, 我们称这样的问题: 内存可见性问题。

其实这背后的原理和编译器/ JVM 的优化有关系

   while (n == 0) {}

这个代码会执行很多遍:

当判断 n== 0 操作时, 就会进行两步操作:

1). 讲内存中的 n 更新到寄存器上。(这步操作执行速度比较慢)

2). 根据寄存器上的值 0 做比较 。 (这步指令执行速度很快)

  • 但是站在JVM的角度来看, 每次 每次循环执行操作1) 开销比较大 , 并且每次执行操作1) 结果都一样
  • 于是 JVM 做了一个大胆的决定, 直接优化掉 操作1) , 每次都只需要进行 操作2) , 让 程序更高效的执行
  • JVM 真正执行上面的过程时, 有个线程 修改了 n 变量的值 , 但是 JVM 读取到寄存器上的数据并没有把内存上的数据拷贝过来, 就无法感知到内存中数据的更新 , 于是就产生了 内存可见性问题
  • 如果是 单线程执行 的话, 编译器的优化是 很准确的

  • 但是如果是 多线程执行 的话, 编译器的优化就会 优化出BUG , 优化到不该优化的地方。

  • 编译器优化是一个 非常综合性 的过程, 既有对 javac 编译时的优化, 也有对 java 运行时的优化

  • 编译器会 自身代码进行优化时, 不会改变 代码的业务逻辑 , 而是对代码的 自身的结构内容执行顺序 ,从而得到 更高效率的执行结构

3. 内存可见性的解决方案

在这里插入图片描述

对于, 内存可见性问题的解决方案,有以下两种:

  • 从代码的角度:
  1. 使用 相对开销更大的操作 , 覆盖掉原先 编译器/JVM 的那点 小优化 , 编译器就会 自动减去这些优化
class Demo18 {
    private static  int n = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {

            while (n == 0) {
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("t1 线程结束循环");
        });

        Thread t2 = new Thread(() -> {

            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数: ");
            n = scanner.nextInt();
        });

        t1.start();
        t2.start();

        t1.join();

    }
}

在这里插入图片描述

如上图, 在循环中使用 sleep() 来休眠, 相对于编译器的优化, sleep开销是很大 的, 编译器的优化已经是杯水车薪, 起不来很大作用 , 那么编译器就会 自动取消这些优化 , 就不会再出现 内存可见性问题

  • 从系统内核的角度:
class Demo18 {
    private volatile static  int n = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {

            while (n == 0) {

            }
            System.out.println("t1 线程结束循环");
        });

        Thread t2 = new Thread(() -> {

            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数: ");
            n = scanner.nextInt();
        });

        t1.start();
        t2.start();

        t1.join();

    }
}

在这里插入图片描述

  1. 使用 volatile 标志这个 变量是易变 , 提醒 编译器/ JVM 这是一个容易改变的变量 , 这时不要直接比较 寄存器的值 , 而是不断的先 更新内存和寄存器的值 , 或者直接 比较内存的值

鱼式疯言

方案选择

一般小编推荐用 方案二直接使用volatile 标记,如果使用 sleep极大的影响程序的效率 , 小编这里不推荐哦~

总结

  • synchronized 的回顾: 回顾 synchronized 的对 同一对象进行加锁从而解决线程安全问题 , 但是也会产生 死锁现象

  • 死锁问题: 死锁问题三种典型情况:重复加锁, 双方互相加锁已经循环加锁。 最后通过: 引入计数解决问题解决一个线程加死锁, 顺序加锁解决多个线程加死锁

  • 内存可见性问题: 从一个线程改 , 另外一个线程读引入的读写内存可见性问题 , 实质上是 JVM 或编译器优化出的BUG , 从中介绍了两种方案用于解决, 并且小编推荐直接加上 volatile 即可。

如果觉得小编写的还不错的咱可支持 三连 下 (定有回访哦) , 不妥当的咱请评论区 指正

希望我的文章能给各位宝子们带来哪怕一点点的收获就是 小编创作 的最大 动力 💖 💖 💖

在这里插入图片描述


http://www.kler.cn/a/378587.html

相关文章:

  • Pycharm贪吃蛇小游戏后续2
  • Docker BUG排查
  • Linux——Ubuntu环境C编程
  • Zookeeper 简介 | 特点 | 数据存储
  • 0-ARM Linux驱动开发-字符设备
  • Hadoop——HDFS
  • STM32 第22章 常用存储器介绍
  • JavaScript 判断数据类型有哪些方法?
  • 1、DevEco Studio 鸿蒙仓颉应用创建
  • Gradient descent algorithm
  • express搭建ts(TypeScript)运行环境
  • ChatGPT、Python和OpenCV支持下的空天地遥感数据识别与计算
  • 关联容器笔记
  • 【天线&空中农业】草莓果实检测系统源码&数据集全套:改进yolo11-HSFPN
  • 【华为HCIP实战课程31(完整版)】中间到中间系统协议IS-IS路由汇总详解,网络工程师
  • 使用ssh-key免密登录服务器或免密连接git代码仓库网站
  • ASAN ThreadSanitizer定位多线程(资源管理)
  • LabVIEW过程控制实验平台
  • Flutter InkWell组件去掉灰色遮罩
  • C#医学检验信息系统LIS源码,医院检验科信息管理系统源码
  • 编程八种语言谁是最受市场青睐的?
  • 【已解决】cra 配置路径别名 @ 后,出现 ts 报错:找不到模块“@/App”或其相应的类型声明。ts(2307)
  • 【jvm】Major GC
  • 基于SpringBoot的视频点播系统设计与实现
  • 【计算机基础——操作系统——Linux】
  • Cuebric:用AI重新定义3D创作的未来