0


【多线程】线程安全问题,面试重点,synchronized锁,volatile,wait

前言:
大家好,我是良辰丫,这篇文章我将与大家一同去学习多线程中锁的知识点,认识线程安全问题,不多说,我们往下看.💞💞💞

🧑个人主页:良辰针不戳
📖所属专栏:javaEE初阶
🍎励志语句:生活也许会让我们遍体鳞伤,但最终这些伤口会成为我们一辈子的财富。
💦期待大家三连,关注,点赞,收藏。
💌作者能力有限,可能也会出错,欢迎大家指正。
💞愿与君为伴,共探Java汪洋大海。

在这里插入图片描述

目录

1. 了解线程不安全

所谓的线程不安全可以认为是我们口中经常说的bug,本质上是因为线程之间的调度顺序不确定导致.

publicstaticint count =0;publicstaticvoidmain(String[] args)throwsInterruptedException{Thread t1 =newThread(()->{for(int i =0; i <5000; i++){
                count++;}});Thread t2 =newThread(()->{for(int i =0; i <5000; i++){
                count++;}});
        t1.start();
        t2.start();Thread.sleep(1000);System.out.println(count);}

在这里插入图片描述

我们搞了两个线程,去执行count++,我们理想的结果是让count最终的值等于10000,然而呢,我们惊讶的发现,count最终的值并不是我们想要的,结果不符合我们预料的,此时我们就认为这种情况下线程不安全.
举个简单的例子 : 就拿我们生活中的软件抢票,如果出现了两个人抢到了同一张票,并且在后台显示自己购票成功,这就是所谓的bug,线程不安全.
接下来我们就来解析一下count++这个操作.

2. 分析count++

我们肉眼看到的count++,其实分为三部分.

  • load:把内存中的数据读到CPU寄存器中.
  • add:寄存器中的值进行+1操作
  • save:把寄存器的值写入内存中.

由于多个线程是并发执行的,那么可能t1线程的count++操作与t2的count++操作中的三部分混合执行,并且由于线程调度具有随机性,那么谁也无法判断会出现怎样的结果,往往来说,每次执行代码会有不同的结果.

3. 总结线程不安全的原因

3.1 抢占式执行

线程调度具有随机性,每个线程都会抢着去执行某次调度,这就会导致各种各样的不确定情况,线程与线程之间穿插进行

3.2 多个线程修改同一个变量

  • 一个线程修改一个变量是安全的.
  • 多个线程修改不同的变量是安全的.
  • 多个线程读取同一个变量是安全的.
  • 多个线程修改同一个变量是不安全的.

3.3 修改操作不是原子性

上述操作的count++可以分成三部分,那么它就不具有原子性.

其它情况

  • 内存可见性引起线程不安全.
  • 指令重排序引起线程不安全.

4. 如何解决线程不安全

4.1 加锁

synchronized为java加锁的关键字,那么加锁到底是什么呢?加锁是为了保证原子性.比如上述的count++,我们稍作一下修改,就会有意外的惊喜,废话不多说,我们上代码.

publicclassTest26{publicstaticint count =0;publicvoidadd(){synchronized(this){
            count++;}}publicstaticvoidmain(String[] args)throwsInterruptedException{Test26 test =newTest26();Thread t1 =newThread(()->{for(int i =0; i <5000; i++){
                test.add();}});Thread t2 =newThread(()->{for(int i =0; i <5000; i++){
                test.add();}});
        t1.start();
        t2.start();Thread.sleep(1000);System.out.println(count);}}

在这里插入图片描述

我们惊奇的发现,结果与我们预期结果相同了,是不是很神奇呢?然后我们简单分析一下.

  • 我们把count++的功能封装成一个方法.
  • 对方法内的count++进行加锁,这时count++的三部分功能就相当于装到一个上锁的匣子里.
  • 我们有两个线程,如果t1获取了锁对象,t1目前就有权利去执行完整的count++操作,此时t2线程没有权利获取锁对象,只能处于阻塞状态,只有当t1线程用完count++操作,释放了锁的时候,t2线程才有机会去获取锁对象.

接下来我们来认识一下synchronized的三种使用场所

  1. 修饰实例方法
publicclassTest27{publicstaticint count =0;publicsynchronizedvoidadd(){
               count++;}publicstaticvoidmain(String[] args)throwsInterruptedException{Test27 test =newTest27();Thread t1 =newThread(()->{for(int i =0; i <5000; i++){
               test.add();}});Thread t2 =newThread(()->{for(int i =0; i <5000; i++){
               test.add();}});
       t1.start();
       t2.start();Thread.sleep(1000);System.out.println(count);}}

修饰实例方法,用于当前实例加锁,进入同步代码要获得当前实例的锁.修饰对象中的实例方法.

  1. 修饰静态方法
publicstaticint count =0;publicstaticsynchronizedvoidadd(){
      count++;}publicstaticvoidmain(String[] args)throwsInterruptedException{Test27 test =newTest27();Thread t1 =newThread(()->{for(int i =0; i <5000; i++){
              test.add();}});Thread t2 =newThread(()->{for(int i =0; i <5000; i++){
              test.add();}});
      t1.start();
      t2.start();Thread.sleep(1000);System.out.println(count);}

修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前实例的锁.作用于静态方法,锁是当前class对象.

  1. 修饰代码块
publicclassTest32{Test32 test =newTest32();publicstaticint count =0;publicstaticsynchronizedvoidadd(){
        count++;}publicstaticvoidmain(String[] args)throwsInterruptedException{Test27 test =newTest27();Thread t1 =newThread(()->{synchronized(test){for(int i =0; i <5000; i++){
                    count++;}}});Thread t2 =newThread(()->{for(int i =0; i <5000; i++){
                test.add();}});
        t1.start();
        t2.start();Thread.sleep(1000);System.out.println(count);}}

修饰代码块,指定加锁对象,进入同步代码块要获得指定对象的锁.

注意:
加锁需要有锁对象,下面代码中locker就是锁对象,写啥都行,但是不能写内置类型(基本类型)

publicObject locker =newObject();publicvoidadd(){synchronized(locker){
        count++;}}

4.2 volatile

publicclassTest30{//测试内存可见性publicstaticint flag =0;publicstaticvoidmain(String[] args)throwsInterruptedException{Thread t =newThread(()->{while(flag ==0){}System.out.println("t线程结束");});Thread t2 =newThread(()->{Scanner sc =newScanner(System.in);System.out.println("请输入一个整数:");
                flag = sc.nextInt();});
        t.start();Thread.sleep(1000);
        t2.start();}}

预期效果,t通过flag等于0作为条件进行循环,t2通过控制台输入一个整数,一旦输入了非0的值,此时t线程就会结束.然而呢?实际效果

可能

会出现输入非0值后t线程没有结束.(以前编译器会有这样的内存可见性问题,后面编译器可能做了修正,不一定会出现这样的问题),我们拿出来知识做简单讨论,在这里,我们不得不引入一个新的概念,

内存可见性

.

在上述操作中涉及两部分

  • load:从内存读数据到CPU寄存器
  • cmp 比较寄存器里的值是否为0 (load的时间开销远远大于cmp)

编译器发现,load开销很大,而每次load的结果都一样,这个时候编译器就做了一个大胆的操作,把load操作优化掉,只有在第一次执行load才真正执行了,后序循环都只cmp,不load(相当于是复用之前寄存器中load过的值)

简单介绍一下编译器优化

编译器优化是一个非常普遍的事情,开发JVM和编译器的程序员都是大佬中的大佬,你可以想一下,你会开车,你和会造车的人想比,你相差很多哈哈.

编译器优化

就是能智能的调整你的代码的执行逻辑,保证程序结果不变的前提下,通过加减语句,通过语句变换,用过一系列的操作,让整个程序的执行效率大大提高.

  • 我们可以选择开启编译器优化,也可以关闭编译器优化.
  • 编译器优化对于单线程来说往往是非常准确的,但是对于多线程会出现误判,因为多线程调度具有随机性.

此时关键字volatile,可以很好的禁止优化,能够保证每次都是从内存上读取数据.
还有一种方式是sleep,因为sleep让执行速度变慢了,当循环次数速度变慢了,此时的load操作,就不再是负担了,编译器也没必要优化了.

publicclassTest30{//测试内存可见性,下面加了volatile关键字volatilepublicstaticint flag =0;publicstaticvoidmain(String[] args)throwsInterruptedException{Thread t =newThread(()->{while(flag ==0){}System.out.println("t线程结束");});Thread t2 =newThread(()->{Scanner sc =newScanner(System.in);System.out.println("请输入一个整数:");
                flag = sc.nextInt();});
        t.start();
        t2.start();}}

注意:
volatile适合的场所是单线程,不保证原子性,它适合于一个线程读一个线程写,而synchronized是适合于多个线程写.volatile也能禁止指令重排序.

5. volatile和内存可见性补充

所谓内存可见性,t1频繁读取主内存,效率比较低,就被优化成自己的工作内存.t2修改了主内存的结果,由于t1没有读主内存,导致修改不能被识别到.

  • 工作内存:CPU寄存器
  • 主内存:内存

简述一下为什么Java官方使用工作内存这样的术语呢?

  • java是跨平台的,兼容多种操作系统,兼容多种硬件设备,尤其是cpu,不同的硬件设备差别会很大,CPU与CPU之间也会有很大的差别.
  • 以前的CPU只有寄存器,现在的CPU还有缓存,而且CPU缓存还有好几个,L1,L2等,目前常见的CPU都是3级缓存.
  • 工作内存准确来说,代表CPU寄存器+缓存(CPU内部存储数据的空间)

6. wait与notify

线程的调度是随机的(无序的),但是也有一定的需求场景希望线程有序执行.wait就是让某个线程暂停下来等一等,notify就是把该线程唤醒,能够继续执行剩余的任务.
A,B,C去银行卡取存钱,但是只有一个取钱窗口,A优先抢到窗口,取钱的时候发现窗口没钱,没钱难道一直等下去嘛,或者出来明知道没钱还进去取嘛;如果B进去存钱了,此时窗口就有钱了,此时A就可以去取钱了.

像咱们以前学过的join局限性太大了,一个线程结束后别的线程才有机会,而结束的线程将不再苏醒.在这里wait与notify就很好的帮助解决上述问题,A发现没钱后进入线程阻塞,直到B存进去钱,A线程将被唤醒,此时A又可以进去取钱.
wait与notify是Object方法,只要你是一个类对象(不是内置类型/不是基本类型),都可以使用.

6.1 wait

主要做三件事情(wait必须写到synchronized代码块里面,加锁的对象必须和wait的对象是同一个)

  • 解锁.
  • 阻塞等待
  • 收到通知的时候,就唤醒,同时尝试获取锁.
publicstaticvoidmain(String[] args)throwsInterruptedException{Object locker =newObject();Thread t1 =newThread(()->{try{System.out.println("wait 开始");synchronized(locker){
                    locker.wait();}System.out.println("wait 结束");}catch(InterruptedException e){
                e.printStackTrace();}});
        t1.start();Thread.sleep(1000);Thread t2 =newThread(()->{synchronized(locker){System.out.println("notify 开始");
                locker.notify();System.out.println("notify 结束");}});
        t2.start();}

在这里插入图片描述

  • t1先执行,执行到wait,进入阻塞状态.
  • t2执行后,执行到notify,就会通知t1唤醒(notify是在synchronized内部就需要t2释放锁,t1才能继续往下走.)

注意:
notify也要放到synchronized中使用

  • 必须先执行wait,然后再notify,才有效果.
  • 如果没有wait就notify,此时notify不发挥任何作用,没有额外的副作用,但是代码的概念不能正确执行了.

6.2 notifyAll

可以有多个线程等待同一个对象,比如好几个线程都执行wait进入阻塞状态,如果调用了object.notifyAll就会把所有阻塞的线程唤醒,此时那些线程重新竞争锁对象.

7. wait和sleep的比较

①wait解决的是线程之间的顺序控制,sleep是让线程休眠一会
②sleep是Thread类的方法,wait是Object类的一个方法
③wait需要配合锁synchronized使用,调用wait后,线程进入阻塞状态,需要等待notify进行唤醒,而sleep与锁无关.

后序:
关于多线程线程安全的文章到这里就结束了,面试重点,synchronized锁,volatile,wait,希望小小的文章可以帮到大家.💌💌💌

标签: 面试 java jvm

本文转载自: https://blog.csdn.net/m0_58097299/article/details/129660404
版权归原作者 良辰针不戳 所有, 如有侵权,请联系我们删除。

“【多线程】线程安全问题,面试重点,synchronized锁,volatile,wait”的评论:

还没有评论