【多线程】多线程(5):死锁,内存可见性
【死锁】
死锁是一个使用锁的注意事项
【出现死锁的场景】(重点掌握)
【场景一:一个线程一把锁】
这个线程针对这把锁,连续加锁两次
synchronized (this)
{synchronized (this){}
}
第一次加锁,加锁成功,第二次加锁,由于锁对象被用了,因此陷入阻塞,需要等锁释放才能结束阻塞,但外面的锁能够释放的前提条件是代码可以正常往下走,但里面的锁产生阻塞导致代码无法往下走,逻辑上构成了死循环,导致线程卡死无法继续进行,于是形成了「死锁」
【可重入锁】
Java中,针对于“对同一把锁,连续加锁两次”的情况作了特殊处理,这种特殊处理让synchronized变成了「可重入锁」:
额外记录一下当前是哪个线程对这把锁加锁
在加锁时,判定该锁是否被占用,如果被其他线程占用了,就并不会进行加锁操作,也不会进行阻塞操作,而是一路放行,往下执行代码
此外,可重入锁还会通过一个引用计数判断当前加锁了几次,以及在什么时候才会真正的释放锁
【场景二:两个线程两把锁】
线程1,线程2,锁A,锁B
1.线程1对A加锁,线程2对B加锁
2.线程1在不释放锁A的情况下,对B加锁,同时线程2在不释放锁B的情况下,对A加锁
这种情况下也会出现死锁
举一个例子:
A和B下馆子吃饺子,他们都有吃饺子同时蘸酱油和醋的习惯,A抄起酱油,B抄起醋
A:你把醋给我,我用完后给你
B:你把酱油给我,我用完后给你
A和B互不相让,形成僵持
这就是死循环,构成「死锁」
如何避免?先放后拿
若先释放锁A再拿锁B,则不会死锁
【场景三:N个线程M把锁】
这里可以举一个典型的哲学家就餐问题的例子
任何一个哲学家想要吃到面条,都需要拿起左手和右手的筷子,此时,每根筷子都被哲学家左手拿起来了,他们的右手都拿不起筷子
由于哲学家非常固执,即便他们吃不到面条,也绝对不会放下手中的筷子
从而构成死锁
要想避免出现这种情况,可以给锁编号,按相同的顺序加锁,约定所有的线程在加锁时,都必须按照一定的顺序进行加锁(比如先针对编号小的锁加锁,后针对编号大的锁加锁)
此时,回到哲学家就餐问题上
给所有的筷子都标了序号,5号哲学家可以拿起5号和4号筷子就餐,就餐完毕后放下筷子,4号哲学家可以拿起4号和3号筷子就餐,就餐完毕后放下筷子……直到最后一个1号哲学家就餐完毕
Thread t1 = new Thread(() ->{synchronized (locker1){//t1先对locker1加锁System.out.println("t1加锁locker1完成");}try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}synchronized (locker2){//t1后对locker2加锁System.out.println("t1加锁locker2完成");}});Thread t2 = new Thread(() ->{synchronized (locker2){//t2先对locker2加锁System.out.println("t2加锁locker1完成");}try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}synchronized (locker1){//t2后对locker1加锁System.out.println("t2加锁locker2完成");}
我们可以看到,t1是先针对locker1加锁,后针对locker2加锁,而t2不是,此时只需要让两个线程加锁顺序相同就可以避免死锁了
【出现死锁的四个必要条件】
1.锁是互斥的「锁的基本特性,不可干预」
2.锁是不可能被抢占的(线程1拿到了锁A,此时线程2也想拿锁A,若线程1不主动释放锁A,那么线程2无法把锁A抢过来,形成僵持,构成死锁)「锁的基本特性,不可干预」
3.请求和保持(线程1拿到锁A后,在不释放锁A的前提下,去拿锁B,构成死锁)「代码结构,可干预」
4.循环等待(多个线程获取锁的过程中,存在循环等待,一旦出现,会构成死锁)「代码结构,可干预」
★避免请求和保持:先放后拿
★避免循环等待:按照相同顺序进行加锁
【内存可见性】
内存可见性,是引发线程安全问题的原因之一,本质上是编译器/JVM对多线程代码进行优化时,优化出bug
public class Demo3 {public 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和t2线程同时运行,t2线程中用户输入一个非0值时,t1线程就会结束,反之t1线程则会一直运行下去
但真正这么做却失败了,输入了一个非0值结果t1还是没有结束
这段代码的执行机理是:
1.从内存中读取数据到寄存器中
2.通过类似于cmp指令,比较寄存器和0的值
此时JVM执行代码时,发现每次执行1操作开销很大,而且执行结果一样,JVM没有意识到用户可能在未来修改n,于是JVM直接把1操作优化掉了——每次循环,不会重新读取内存中的数据,而是直接读取寄存器/cache中的数据(缓存的结果)/当JVM作出决定后,此时意味着循环开销大幅降低,但当用户修改n值时,内存中的n发生改变,但由于t1线程每次循环不会真的读内存,因此感知不到n的改变
内存中的n的改变,对于线程t1来说,是“不可见的”,即「内存可见性问题」
【解决方法:加sleep】
Thread t1 = new Thread(() ->{while(n == 0){try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}}System.out.println("t1线程结束循环");});
和读内存相比,sleep开销更大,远远超过读内存,就算把读内存优化掉也无意义,杯水车薪,因此不会优化读内存
【解决方法:加volatile】
多线程中编译器进行优化可能导致线程安全问题,而编译器进行优化的前提,是编译器认为:针对这个变量的频繁读取,结果都是固定的,因此可以引用“volatile”
public static volatile int n = 0;
用于修饰一个变量的关键字,提示编译器这个变量是“易变的”
引入volatile后,编译器在生成代码时,会给变量的读取操作附近生成一些特殊的指令,称为「内存屏障」,后续JVM执行到这些特殊指令时,就知道了不能进行上述优化
//volatile只是解决内存可见性问题,不能解决原子性问题,如果两个线程针对同一个变量进行修改,那么volatile就无能为力了