【Java多线程从青铜到王者】死锁和内存可见性问题(六)

可重入

synchronized的加锁效果,也可以称为"互斥性“,其实synchronized还有一些特性,举一个例子

public class Test5 {
    public static void main(String[] args) {
        Object locker=new Object();
        Thread t=new Thread(()->{
            synchronized (locker){
                synchronized (locker){
                    System.out.println("hello thread");
                }
            }
        });
        t.start();
    }
}

这个代码会打印出结果吗?
直观来看的话,我们第一个synchronized已经对locker对象加上锁了,我们第二个synchornized想要对locker进行加锁的时候就会发生锁竞争,进而进入阻塞状态,但其实上这个代码可以打印出来结果的(如下图)
在这里插入图片描述
为啥最终没有出现阻塞呢?
最关键的问题就是这两次加锁,都是同一个线程,由于是同一个线程,所以在第二次加锁的时候,锁对象发现这次加锁的线程是已经持有了锁的线程,于是直接放行,就不会有阻塞,这个特性就称为”可重入
上述同样的代码在c++里面的话,这里的锁是不可以重入的,这就导致了上述代码出现了阻塞,就无法恢复了,线程就卡死了,这种情况我们就称为”死锁
使用可重入做就可以避免上述代码出现死锁的情况
可重入锁并没有什么应用场景,可重入锁就是为了防止咱们在写代码的时候写出这种双重加锁的情况导致线程卡死
我们可重入锁加锁和释放锁的逻辑是怎样的?是加了两个锁还是只有一个锁?
一个锁对象只有一把锁
我们的可重入锁里面内部其实会持有两个信息:
1.加锁次数的计算机
2.当前这个锁是被哪个线程持有的
在这里插入图片描述
即使上述的synchronized嵌套个嵌套多次的话也是不影响程序正确运行,这里什么时候释放锁的关键就是计时器,什么时候计时器的值变为0,什么时候释放锁

死锁

死锁是多线程问题中经典的面试题,加锁可以解决线程安全的问题,但是如果加锁的方式不正确的话,就有可能产生死锁的问题
一旦出现了死锁的话,程序就卡住了,这属于多线程里面比较严重的bug了

死锁的三种典型场景:

1.一个线程,一把锁

如果锁是不可重入锁的话,一个线程把一个对象加上两次锁的话,就会出现死锁(钥匙锁屋里了)

2.两个线程,两把锁

线程1获取了锁A,线程2获取了锁B,之后线程1又尝试获取锁B,但是此时锁B被线程2持有,线程1于是阻塞,线程2又尝试获取锁A,锁A又被线程1持有,线程2于是阻塞,这时两个线程都阻塞,两个线程都想获取对方的锁,于是就死锁了(家钥匙锁车里了,车钥匙锁屋里了)

比如两个同学出去吃饭,饭桌上有一瓶醋,还有一瓶辣椒油,两个人都要往饭里面加醋和辣椒油,同学A先拿了醋,同学B拿了辣椒油,同学A说你先把你手里的辣椒油给我,我两个都加了以后再给你,但是同学B是个犟种,他说凭什么你先用完再给我,你先把醋给我,我用完了以后再给你,同学A一听火气就上来了,两个人谁也不让谁,卡住了,就成了死锁了

public class Test6 {
    public static void main(String[] args) {
        Object A=new Object();
        Object B=new Object();
        Thread t1=new Thread(()->{
            synchronized (A){
                // 确保线程t2可以获取到锁B
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (B){
                    System.out.println("t1拿到了两把锁");
                }
            }
        });
        Thread t2=new Thread(()->{
            synchronized (B){
                //确保线程t1可以获取到锁A
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (A){
                    System.out.println("t2拿到了两把锁");
                }
            }
        });
        t1.start();
        t2.start();
    }
}

上述的代码就是两个线程两个锁出现死锁的案例
在这里插入图片描述
我们可以通过观察线程的状态看到,两个线程都处于由于锁竞争导致的阻塞状态

3.n个线程m把锁

哲学家就餐问题
在这里插入图片描述
现在由五个滑稽老铁一起吃面,但是桌子上现在只有五个筷子,每个滑稽老铁就是一个线程,每个筷子就是一个锁
每个滑稽老铁只能拿起自己跟前的两个筷子,比如当我们1号滑稽老铁要吃面了,他拿起了自己跟前的两根筷子,之后我们的2号滑稽老铁也想要吃饭,但是他发现他跟前的两根筷子被1号拿走了,此时他只能等待1号吃完把筷子放下来之后再吃面,这个过程中2号滑稽老铁是不能强1号的筷子的,每个滑稽老铁除了吃面,有时也会放下筷子,像哲学家一样思考人生(随机调度),虽然筷子的数量不多,但是好在每个人都会放下筷子思考人生,这就给了其他滑稽老铁吃面的机会,所以绝大部分的情况下,大家都还是可以吃到面的
但是有一些极端的情况,同一时刻,所有的滑稽老铁都想吃面了,他们同时拿起了左手的筷子,当他们想拿起右手的筷子的时候,发现拿不起来,右手的筷子已经被拿走了,但是他们都想吃面,都不想放下左手的筷子,于是就成了没有一个人吃到面的情况,也就成了死锁的情况了
在讲解到死锁的解决方法的时候,我们先说一下死锁产生的四个必要条件(缺一不可)
1.互斥使用:获取锁的过程是互斥的,一个线程拿到了锁之后,另一个锁要是想要获取这个锁,也要阻塞等待
2.不可抢占:一个线程拿到了锁之后,只能主动解锁,不能让别的线程将锁强行抢走
3.请求保持:一个线程拿到了锁A了之后,在持有A的前提下,尝试获取锁B
4.循环等待/环路等待
解决死锁问题,核心思路就是破坏上述的必要条件,只要能破坏一个就可以了
关于第一和第二点的话,这是锁最基本的特性,不太好破坏
第三点是代码结构的问题,这得根据具体的需求,不一定能破坏
这么来看我们的第四点是最好破坏的了,只要指定一定的规则就可以有效避免循环等待
指定加锁的顺序,针对每把锁进行编号,规定每个线程进行加锁的时候,优先获取编号小的锁,后获取编号大的锁(如下图)
在这里插入图片描述
我们2号滑稽拿筷子的时候,先拿编号小的筷子,也就是1号筷子,然后3号滑稽拿到是2号筷子,4号滑稽拿的是3号筷子,5号滑稽拿的是4号筷子,到了1号滑稽的时候,他要先拿编号小的筷子,于是1号滑稽就要拿1号筷子,但是此时1号筷子是被2号滑稽拿着,由于互斥使用和不可抢占,1号只能阻塞等待,此时5号滑稽就发现,哎呀,5号筷子没有人用,于是他就拿起了五号筷子开始吃面,等5号滑稽吃完饭后,放下筷子,4号筷子就空出来了,4号滑稽就拿起4号筷子开始吃面,以此类推,等到了2号吃完面的时候,放下1号筷子,此时1号滑稽看见1号筷子空出来了,于是他就拿起筷子来吃饭,这样的话,每个人都可以吃上面了
根据这个规律,我们可以改造一下下图中的代码
在这里插入图片描述
这个代码也是死锁的一种情况,我们预定一下加锁的顺序,先对A加锁,然后再对B加锁,也可以解决死锁的问题

public class Test6 {
    public static void main(String[] args) {
        Object A=new Object();
        Object B=new Object();
        Thread t1=new Thread(()->{
            synchronized (A){
                // 确保线程t2可以获取到锁B
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (B){
                    System.out.println("t1拿到了两把锁");
                }
            }
        });
        Thread t2=new Thread(()->{
            synchronized (A){
                //确保线程t1可以获取到锁A
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (B){
                    System.out.println("t2拿到了两把锁");
                }
            }
        });
        t1.start();
        t2.start();
    }
}

代码运行结果如下,可以发现,代码正常运行了
在这里插入图片描述

java标准库中的线程安全类

线程不安全的类
ArrayList
LinkedList
HashMap
TreeMap
HashSet
TreeSet
StringBulider

这个线程不安全,也不是百分百出问题的,有可能代码写出来之后,没有问题

线程安全的类
Vector
ConcurrentHashMap
StringBuffer

这几个类自带锁了,在多线程的情况下也并不不是不会出问题,但是会比线程不安全的类的问题少一些

内存可见性引起的线程安全问题

如果一个线程读,一个线程写的话,这个时候是否会有线程安全问题呢?
也是可能存在的
在这里插入图片描述
这个代码中,t1是在读数据,t2是在写数据
t2只要输入一个不为零的数,就可以结束t1线程
注意这里无论是t1还是t2先执行,等待用户输入的过程中,t1必然已经循环很多次了
在这里插入图片描述
当我们输入一个不为零的数的时候,发现t1线程并没有结束,我们的代码出现了bug
在这里插入图片描述
我们上图的核心指令有两条:
1.load:读取内存中的flag值到CPU寄存器里
2.拿着寄存器里的值跟0进行比较(条件跳转指令)
这个循环的过程是非常快的,不停的执行1 2 1 2 1 2 1 2
这个过程中有两个关键点:
1.load操作执行的过程是不变的(在等待用户输入的这几秒种,load操作不知道已经执行了多少亿次了)
2.load操作的开销是远大于条件跳转指令的操作的,可能一次循环有百分之九十多的时间用于load操作,不到百分之十的操作是条件跳转指令,因为访问寄存器的速度远远大于访问内存的速度
频繁的重复这两个指令,而且load指令开销很大,开销很大就算了吧,但是每次load的结果还一样,jvm于是就怀疑,你这里的load的操作是否还有存在的必要
这时jvm就对代码做出了优化,把load操作给优化掉,前几次load操作还是从内存读取,读取了几次发现,这个flag的值根本就没有改变,于是jvm激进的将load操作砍掉了,之后就不去内存里面读取数据了,而是从寄存器里提前缓存好的数据里读取,答覆地提高了运行的速度
后续我们t2线程改变了flag的值,但是我t1不从内存中读取了,也就是说t2修改了内存,但是t1没有看到内存的变化,这就是内存可见性问题
内存可见性,高度依赖于编译器优化的具体实现,这是写编译器的大佬们规定的,编译器啥时候优化,啥时候不优化,不好说,如果代码改动一点,可能就不会优化了(比如如下代码)

public class Test7 {
    public static int flag=0;
    public static void main(String[] args) {
        Thread t1=new Thread(()->{
           while(flag==0){
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   throw new RuntimeException(e);
               }
           }
            System.out.println("t1线程结束");
        });
        Thread t2=new Thread(()->{
            System.out.println("请输入falg的值:");
            Scanner scanner=new Scanner(System.in);
            flag=scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

当我们在循环里面加上sleep的时候,我们发现代码可以按照预期执行,也就是说jvm没有优化,可能是因为我们加了sleep之后,循环的速度变慢,load的整体的开销没有那么大了,优化也就没有那么迫切了
但是,我们是希望我们的代码不管怎么写,都不希望出现内存可见性问题
java就提供了volatile关键字,让上述的优化强制关闭,确保每次循环都是去内存中读取数据,强制都内存,开销是大了,效率低了,但是数据的准确性提高了

public class Test7 {
    public volatile static int flag=0;
    public static void main(String[] args) {
        Thread t1=new Thread(()->{
           while(flag==0){

           }
            System.out.println("t1线程结束");
        });
        Thread t2=new Thread(()->{
            System.out.println("请输入falg的值:");
            Scanner scanner=new Scanner(System.in);
            flag=scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

volatile关键字的功能:
1.保证内存可见性
2.禁止指令重排序

JMM

JMM模型:在我们上述的代码中,编译器发现每次读取数据都是从”主内存“读取,就把”主内存“的数据复制到”工作内存“,以后读取数据就从”工作内存“读取
对比我们自己的表述,编译器发现每次读取数据都是从内存中读取,于是将内存的数据复制到寄存器,之后每次都从寄存器里面读取数据
这里的”主内存“就是内存,”工作内存“就是寄存器
我们的优化不一定是放到寄存器里,也可能放到缓存里面,JMM其实就是java规范文档里面的一个抽象的概念,要严谨,于是JMM的表述跟我们自己的表述会不太一样,为了概括就引入了工作内存的概念,工作内存代指寄存器和缓存这一类东西

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值