目录
在上一篇文章,我们了解了什么是线程安全,分析了产生线程不安全的原因。今天我们就要深度刨析一下线程不安全的经典案例:死锁和内存可见性引起的线程不安全问题。
一、死锁
1.1 出现死锁的常见场景:
• 场景一:
锁是不可重入锁(synchronized 是可重入锁),并且一个线程针对一个锁对象,连续加锁两次。
• 场景二:
两个线程两把锁。先让两个线程分别拿到一把锁,然后再去尝试获取对方的锁,这时就出现了死锁的情况。
• 场景三:
多个线程,多把锁。随着线程和锁的数目的增加,情况就会变得更加复杂,死锁就更容易出现。下面就是一个经典的死锁场景:哲学家就餐(除非吃到面条,否则不会放下筷子)。
如果出现极端的情况,同一时刻所有的哲学家都拿起左边的筷子,这时就会出现死锁。
1.2 产生死锁的后果:
死锁是非常严重的问题。一个进程中线程的个数是有限的,死锁会使线程被卡住,没法继续工作。更加严重的是,死锁这种 bug 往往都是概率性出现(未知才是最可怕的)。测试的时候,怎么测试都没事,一旦发布,就出现了问题。更加要命的是发布也没有问题,等到夜深人静的时候,大家都睡着的时候,突然给你来点问题,直接带走年终奖😭。
1.3 如何避免死锁:
要想避免死锁,我们就要从产生死锁的原因入手。
教科书上经典的产生死锁的四个必要条件(下面给出的四个条件,友友们一定要背下来,面试的经典问题)。
1. 锁具有互斥性:
这时锁的基本特点,一个线程拿到锁之后,其他线程就得阻塞等待。
2. 锁具有不可抢占性(不可剥夺性):
一个线程拿到锁之后,除非他自己主动释放锁,否则谁也抢不走。
3. 请求和保持:
一个线程拿到一把锁之后,不释放这个锁的前提下,再尝试获取其他锁。
4. 循环等待。
多个线程获取多个锁的过程中,出现了循环等待,A 等待 B ,B 又等待 A。
在任何一个死锁的场景,都必须同时具备上述四点,只要缺少一个,都不会构成死锁。观察上面的四个条件不难发现条件 1 和条件 2 是锁的基本特性,这个我们无法改变,观察到条件 3 和条件 4 都是代码结构的问题,所以我们就从条件 3,4 入手。
• 针对条件 3:
不要让锁嵌套获取即可。如果有些场景必须要嵌套获取锁,那么就破除循环等待(条件 4 ),即使出现嵌套,也不会出现死锁。
• 针对条件 4:
当代码中,确实需要用到多个线程获取多把锁,一定要记得约定好加锁的顺序(每个线程都必须要先获取 A 锁,再获取 B 锁,再.......),就可以有效避免死锁了。
二、内存可见性
2.1 由内存可见性产生的经典案例:
请友友们观察一下下面这段代码,可以粘贴到自己的编译器上跑一下,看看是否符合你的预期。
public class demo1 {
static int count = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while(count == 0){
}
System.out.println("t1.end");
});
Thread t2 = new Thread(() -> {
Scanner in = new Scanner(System.in);
System.out.println("请输入一个数字:");
count = in.nextInt();
});
t1.start();
t2.start();
}
}
因为输入数据存在 IO 操作(很慢)所以一定能保证在我们输入数据的时候,t1 线程已经开始执行了。
正常来说,我们输入一个非 0 的数字后,t1 线程里面就会停止循环。但是产生的结果如下:
循环并没有退出,由于是前台线程,所以程序不能够结束。
上述问题产生的原因就是因为内存可见性。
• 案例解析:
上面的案例产生的问题是由于编译器优化 / JVM 优化产生的问题。不是说优化不好,而是 JVM 在这种情况下的优化太激进了。为什么会产生这么激进的优化呢?
我们站在指令的角度来理解有两个方面:
1. 在while 循环体中,每次条件判断的时候,分为两个步骤:1. load:从内存读取数据到 cpu 寄存器。2. cmp:比较