【多线程奇妙屋】收藏多年的线程安全问题大全笔记(下篇) { 死锁问题 },笔记一生一起走,那些日子不再有
本篇会加入个人的所谓鱼式疯言
❤️❤️❤️鱼式疯言:❤️❤️❤️此疯言非彼疯言
而是理解过并总结出来通俗易懂的大白话,
小编会尽可能的在每个概念后插入鱼式疯言,帮助大家理解的.
🤭🤭🤭可能说的不是那么严谨.但小编初心是能让更多人能接受我们这个概念 !!!
引言
在当今的计算机世界中,线程安全是一个至关重要的话题。而当涉及到线程安全的死锁问题时,它就像是隐藏在系统深处的一颗 “定时炸弹”,随时可能引发意想不到的故障。死锁,这一复杂而又神秘的现象,不仅会影响程序的正常运行,还可能导致系统陷入瘫痪。让我们一同深入探究线程安全的死锁问题,揭开它那层神秘的面纱,了解其背后的原理与解决之道。
目录
-
synchronized 的回顾
-
死锁问题
-
内存可见性问题
一. 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
为 可重入锁: 把所以的 重复锁都当成一把锁 来执行。
鱼式疯言
补充细节
-
: 注意这里是在 一个线程中两把锁对同一个对象进行加锁 , 在这个过程中注意有三个条件:
同一个线程
,两把锁
,同一个对象
; 在这三者中缺一不可, 否则就 无法产生死锁 。
原理补充:
有图有真相, 关于
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();
}
}
入上图和代码, 我们就很清晰的可以看到, 这里两个线程:t1
和 t2
分析流程
-
首先 t1 去竞争锁对象: locker1 , t2 去竞争锁对象: locker2 ,
-
当双方都拿到了各自的锁之后, t1 又想去竞争 locker2 , 而同时 t2 又去想竞争 locker1 。
-
但是此时
locker1 由 t1
拿着,locker2 由 t2
拿着,谁也不让谁, 就会出现一直僵持下去, 让t1 一直拉着 locker1 而不释放锁, 同时让 t2 也一直拿着 locker2 而不释放锁。 -
最终使着 两个线程都处于阻塞等待 的状态, 于是就出现上述的死锁的情况。
可能小伙伴们还不是很理解吧~
下面小编举个生活栗子来:
比如有一个我和女神出去吃饭, 因为我和女神都有一个共同的美食就是: 喜欢吃饺子并且都喜欢加醋和酱油。
于是我们每人都点了一份饺子, 于是我们就开始吃起饺子了。
首先, 女神先拿到醋, 于此同时我拿到酱油,
然后加完酱油之后我想继续加点她手里的醋,但是 女神那边加完醋之后也想加完酱油。
于是女神说:“你先把酱油给我, 我再给你醋” , 然后我就说了: “你先把醋给我, 我再给你酱油” 。
最终我们由于互相拿到对方想要的, 就好像互相拿着对方的锁, 而不能及时释放, 就会一直僵持下去, 最终出现了死锁。
3. 循环加锁
循环加锁就说 n 个线程去竞争 m 个锁的所造成的死锁 的现象。
由于这个线程和锁的个数不明确,下面小编就通过故事的栗子来讲解
如上图, 这是一个经典的 “哲学家就餐问题”
。
哲学家每天都干两件事情, 干饭和思考 。
但是面前的情况是: 有 五个哲学家, 桌子上只有五根筷子 。
-
首先, 哲学家们就先拿起一根筷子
-
然后当哲学家想吃饭必须要拿起 第二根筷子 , 但是桌子上已经被每人一根拿到了。
-
于是就需要其中一个哲学家放下一根筷子,但是哲学家们都是很倔强的, 谁不愿意放下自己手中的筷子,于是谁也不让谁, 最终 五个哲学家都处在僵持的状态 下, 谁也吃不到饭 ,
就好像 五个线程都相互对方想要的五个锁 的之一, 都想拿锁. 但是 锁已经被各自拿到, 线程之间又要竞争锁, 就会 互相阻塞 , 最近出现
死锁
的情况 。
鱼式疯言
出现死锁问题的四个必备的条件:
- 锁的互斥性:
一个线程加上锁
, 另一个线程就无法加上锁 。
- 锁是不可抢占的: 当一个线程加上锁之后, 另一个锁想加锁就需要阻塞等待,
等待这个锁中的代码执行完毕
, 才轮到 另一个线程进行加锁 。
对于以上这两点是 锁synchronized
的基本特性 , 所以 一般我们无法改变 。
- 请求和保持: 一个线程在 不释放锁 A 的情况, 继续去拿锁B的, 就很有可能出现
死锁
, 但是 一个线程在释放锁 A 后, 再去拿锁B 就不太会出现 死锁的现象 。
- 循环等待/ 环路等待/ 循环依赖: 如果出现了 多个线程竞争锁出现了循环等待, 就需要参考下面的解决死锁问题的方案。 。
条件三和条件四 避免 死锁问题 的关键, 小伙伴们多多理解哦~
4. 死锁的解决方案
对于上述的死锁情况, 有问题就会有方案来解决。
-
首先, 针对重复加锁的情况: 我们 引用计数的机制 让 多个锁都等效 为一把锁即可。 这里就小编就不赘述了, 上面有提及到。
-
关于 双方互相加锁 和 循环加锁的情况, 通常采用顺序加锁:
也就是说我们规定,先给每个锁编号, 让所有的线程依次从编号小的锁开始加锁。
也就是如上图, 让线程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. 内存可见性的解决方案
对于, 内存可见性问题的解决方案,有以下两种:
- 从代码的角度:
- 使用 相对开销更大的操作 , 覆盖掉原先
编译器/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();
}
}
- 使用
volatile
标志这个 变量是易变 , 提醒 编译器/ JVM 这是一个容易改变的变量 , 这时不要直接比较 寄存器的值 , 而是不断的先 更新内存和寄存器的值 , 或者直接 比较内存的值 。
鱼式疯言
方案选择:
一般小编推荐用
方案二
, 直接使用volatile 标记,如果使用sleep
会 极大的影响程序的效率 , 小编这里不推荐哦~
总结
-
synchronized 的回顾: 回顾
synchronized
的对 同一对象进行加锁从而解决线程安全问题 , 但是也会产生 死锁现象。 -
死锁问题: 死锁问题三种典型情况:重复加锁, 双方互相加锁已经循环加锁。 最后通过: 引入计数解决问题解决一个线程加死锁, 顺序加锁解决多个线程加死锁。
-
内存可见性问题: 从一个线程改 , 另外一个线程读引入的读写内存可见性问题 , 实质上是 JVM 或编译器优化出的BUG , 从中介绍了两种方案用于解决, 并且小编推荐直接加上
volatile
即可。
如果觉得小编写的还不错的咱可支持 三连 下 (定有回访哦) , 不妥当的咱请评论区 指正
希望我的文章能给各位宝子们带来哪怕一点点的收获就是 小编创作 的最大 动力 💖 💖 💖