0


多线程 (五) 线程安全及解决方案(看这一篇就够了)

🎉🎉🎉点进来你就是我的人了
博主主页:🙈🙈🙈戳一戳,欢迎大佬指点!

人生格言:当你的才华撑不起你的野心的时候,你就应该静下心来学习!

欢迎志同道合的朋友一起加油喔🦾🦾🦾
目标梦想:进大厂,立志成为一个牛掰的Java程序猿,虽然现在还是一个🐒嘿嘿
谢谢你这么帅气美丽还给我点赞!比个心



前言

** 🦕**在多线程环境下如果说代码运行的结果是符合我们预期的,即该代码在单线程中运行得到的结果,那么就说这个程序是线程安全的,否则就是线程不安全的.下面带大家仔细给大家讲解一下线程不安全问题!


1. 造成线程不安全的原因有哪些呢?

1)抢占式执行,调度过程随机(也是万恶之源,无法解决)

2)多个线程同时修改同一个变量(可以适当调整代码结构,避免这种情况)

3)针对变量的操作,不是原子的(加锁,synchronized)

4)内存可见性,一个线程频繁读,一个线程写(使用volatile)

5)指令重排序(使用synchronized加锁或者volatile禁止指令重排序)

1.1什么是原子性

案例引入

我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的!

那我们应该如何解决这个问题呢?是不是只要给房间加一把锁,A 进去就把门锁上,其他人是不是就进不来了。这样就保证了这段代码的原子性了。
有时也把这个现象叫做同步互斥,表示操作是互相排斥的。

一条 java 语句不一定是原子的,也不一定只是一条指令(例如++操作,内部三条指令构成)

原子性是指一个操作或者多个操作,要么全部执行,并且执行的过程不会被任何因素打断,要么就都不执行

一组操作(一行或多行代码)是不可拆分的最小执行单位,就表示这组操作是具有原子性

多个线程多次的并发并行的对一个共享变量操作时,该操作就不具有原子性

在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。

上面一句话虽然看起来简单,但是理解起来并不是那么容易。看下面一个例子:

x = 10;     //语句1
y = x;         //语句2
x++;         //语句3
x = x + 1;     //语句4

注意其实只有语句1是原子性操作,其他三个语句都不是原子性操作。

  • 语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。
  • 语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及将x的值写入工作内存,这2个操作都是原子性操作,但是合起来就不是原子性操作了。
  • 同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值

也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。

从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。

由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

1.2什么是内存可见性

多个线程工作的时候都是在自己的工作内存中来执行操作的,线程之间是不可见

  1. 线程之间的共享变量存在主内存(实际内存)
  2. 每一个线程都有自己的工作内存(CPU寄存器+缓存)
  3. 线程读取共享变量时,先把变量从主存拷贝到工作内存,再从工作内存读取数据
  4. 线程修改共享变量时,先修改工作内存中的变量值,再同步到主内存

注意:

(1)线程对共享变量的所有操作都必须在自己的工作内存中进行,不能绕过工作内存直接从主内存中读写变量

(2)不同线程之间无法直接访问其他线程工作内存中的变量,线程之间变量值的传递需要通过主内存来完成

此时引入了两个问题
为啥要整这么多内存?
为啥要这么麻烦的拷来拷去?
1) 为啥整这么多内存?
实际并没有这么多 "内存". 这只是 Java 规范中的一个术语, 是属于 "抽象" 的叫法.
所谓的 "主内存" 才是真正硬件角度的 "内存". 而所谓的 "工作内存", 则是指 CPU 的寄存器和高速缓存
2) 为啥要这么麻烦的拷来拷去?
因为 CPU 访问自身寄存器的速度以及高速缓存的速度, 远远超过访问内存的速度(快了 3 - 4 个数量级, 也就是几千倍, 上万倍).
比如某个代码中要连续 10 次读取某个变量的值, 如果 10 次都从内存读, 速度是很慢的. 但是如果只是第一次从内存读, 读到的结果缓存到 CPU 的某个寄存器中, 那么后 9 次读数据就不必直接访问内存了. 效率就大大提高了.
那么接下来问题又来了, 既然访问寄存器速度这么快, 还要内存干啥??
答案就是一个字: 贵
值的一提的是, 快和慢都是相对的. CPU 访问寄存器速度远远快于内存, 但是内存的访问速度又远远快于硬盘.对应的, CPU 的价格最贵, 内存次之, 硬盘最便宜

1.3共享变量可见性实现的原理

线程1对共享变量的修改要想被线程2及时看到,必须要经过如下的2个步骤

(1)把工作内存1中更新过的共享变量刷新到主内存中

(2)将主内存中最新的共享变量的值更新到工作内存2中

变量传递顺序

1.4 什么是指令重排序

JVM翻译字节码指令,CPU执行机器码指令,都可能发生重排序来优化执行效率

比如有这样三步操作:(1) 去前台取U盘 (2) 去教室写作业 (3) 去前台取快递
JVM会对指令优化,也就是重排序,新的顺序为(1)(3)(2),这样就可以少跑一次前台,以此提高效率,这就叫做指令重排序.

编译器对于指令重排序的前提是 "保持逻辑不发生变化". 这一点在单线程环境下比较容易判断, 但
是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代
码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价.
重排序是一个比较复杂的话题, 涉及到 CPU 以及编译器的一些底层工作原理, 此处不做过多讨论

2.解决线程安全问题

引入count++问题

class Counter {
    private int count =0;
    public void add() {
            count++;
    }
    public int getCount() {
        return count;
    }
}
public class ThreadDemo1 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter =new Counter();
        Thread t1 =new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        Thread t2 =new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.getCount());
    }
}

运行上述代码我们会发现每次都结果是小于100000的,因为上面两个线程在实际对count进行++操作的时候并不满足原子性,导致最终的结果一直不是我们想要的,这就是由于不满足原子性所导致的线程不安全问题!!!

count++操作,本质上是有三个CPU指令构成

1.load,把内存中的数据读到CPU寄存器中

2.add,就是把寄存器中的值进行+1运算

3.save,把寄存器中的值写回到内存中

在这里插入图片描述

** 由于CPU的抢占式执行,导致两个线程同时进行count++操作的时候,内部的三个CPU指令不能完整一次性执行完,例如在第一个线程在执行的时候先读取共享变量count的值到自己的寄存器中,还没来得及修改,第二个线程获取到了CPU的执行权开始执行,此时线程2线读取共享变量到自己的工作内存(寄存器中)进行修改,最后再同步到主内存(就是更新共享变量count的值),当线程2执行完毕后,线程1再次获得CPU的执行权继续执行未完成的操作,将自己寄存器中的count进行修改再同步到主内存中,此时由于两次修改实际上只修改成功一次,这就是由于原子性引起的线程不安全问题!**

2.1 引入关键字synchronized解决线程不安全问题

(1) synchronized的使用方法(锁)

修饰方法:修饰普通方法时,关键字在public前后都可,锁对象是 this,也就是谁调用谁上锁。修饰静态方法时,锁对象是类对象。

修饰代码块:修饰代码块时,显式(手动)指定锁对象。

对于构造方法来说,如果加锁,不能直接加在方法上,但是内部可以使用代码块的方法,来加锁。

代码演示

    
    //修饰普通方法
    public synchronized void doSomething(){
        //...
    }

    //修饰代码块
    public void doSomething(){
        synchronized (this) {
            //...
        }
    }
    
    //修饰静态方法(与下面效果相同都是锁类对象)
    public static synchronized void doSomething(){
        //...
    }

    //修饰静态方法
    public static void doSomething(){
        synchronized (A.class) {
            //...
        }
    }

(2)synchronized的作用

sychronized是基于对象头加锁的,特别注意:不是对代码加锁,所说的加锁操作就是给这个对象的对象头里设置了一个标志位

一个对象在同一时间只能有一个线程获取到该对象的锁
sychronized保证了原子性,可见性,有序性(这里的有序不是指指令重排序,而是具有相同锁的代码块按照获取锁的顺序执行)

(2.1) 互斥性

synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待

进入 synchronized 修饰的代码块, 相当于 加锁
退出 synchronized 修饰的代码块, 相当于 解锁

下面图加深理解:

阻塞等待:

针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候,其他线程尝试进行加锁, 就加不上了,就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的线程, 再来获取到这个锁!

(2.2) 刷新主存

synchronized锁住共享变量时的工作流程:

🐳获得互斥锁
🐳从主存拷贝最新的变量到工作内存
🐳对变量执行操作
🐳将修改后的共享变量的值刷新到主存
🐳释放互斥锁

(2.3) 可重入性

synchronized是可重入锁
同一个线程可以多次申请成功一个对象锁

在可重入锁的内部, 包含了 "线程持有者" 和 "计数器" 两个信息.
如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取
到锁, 并让计数器自增.
解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)

    可重入锁的意义就是降低程序员负担(使用成本来提高开发效率),代价就是程序的开销增大(维护锁属于哪个线程,并且加减计数,降低了运行效率) 

如下图:

(3)优化后的代码(加锁后)

class Counter {
    private int count =0;
    synchronized public void add() {
            count++;
    }
    public int getCount() {
        return count;
    }
}
public class ThreadDemo1 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter =new Counter();
        Thread t1 =new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        Thread t2 =new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.getCount());
    }
}

2.2. 关于锁/同步监视器的总结(重点掌握):

总结1:认识同步监视器(锁) ----- synchronized(同步监视器){ }

**🐳必须是引用数据类型,不能是基本数据类型
🐳也可以创建一个专门的同步监视器,没有任何业务含义
🐳一般使用共享资源做同步监视器即可
🐳在同步代码块中不能改变同步监视器对象的引用 **

🐳尽量不要String和包装类Integer做同步监视器
🐳建议使用final修饰同步监视器

总结2:同步代码块的执行过程(重点理解)

1)第一个线程来到同步代码块,发现同步监视器open状态,需要close,然后执行其中的代码
2)第一个线程执行过程中,发生了线程切换(处于阻塞就绪状态),第一个线程失去了cpu,但是没有开锁(open)
3)第二个线程获取了cpu,来到了同步代码块,发现同步监视器close状态,无法执行其中的代码,第二个线程也进入阻塞状态
4)第一个线程再次获取CPU,接着执行后续的代码;同步代码块执行完毕,释放锁open
5)第二个线程也再次获取cpu,来到了同步代码块,发现同步监视器open状态,拿到锁并且上锁,由阻塞状态进入就绪状态,再进入运行状态,重复第一个线程的处理过程(加锁)
强调:同步代码块中能发生CPU的切换吗?能!!!但是后续的被执行的线程也无法执行同步代码块(因为锁仍旧close)

总结3:多个代码块使用同一个同步监视器(锁)

1)多个代码块使用了同一个同步监视器(锁),锁住一个代码块的同时,也锁住所有使用该锁的所有代码块,其他线程无法访问其中的任何一个代码块
2)多个代码块使用了同一个同步监视器(锁),锁住一个代码块的同时,也锁住所有使用该锁的所有代码块,但是没有锁住使用其他同步监视器的代码块,其他线程有机会访问其他同步监视器的代码块

2.3. 引入volatile解决线程安全问题

(1) volatile保证内存可见性

引入一个线程不安全的场景:

当一个线程对一个变量进行读取操作,同时另一个线程针对这个变量进行修改,此时读到的值不一定是修改后的值,这是编译器在多线程环境下优化时产生了误判,从而引起了bug

代码演示:

class Sign{
     public boolean flag = false;
}
 
public class ThreadDemo4{
 
 
    public static void main(String[] args) {
        Sign sign = new Sign();
 
        Thread t1 = new Thread(()->{
 
            while(!sign.flag){
 
            }
            System.out.println("执行完毕");
        });
        Thread t2 = new Thread(()->{
            sign.flag = true;
        });
        t1.start();
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        t2.start();
 
    }
}

运行上述代码我们会发现,程序会一直运行,while感知不到flag的变化。原因就是,执行到线程2的时候,while一直循环跑了好多遍,flag一直是false,所以编译器对代码进行优化,默认为程序不变,不再从内存中读取flag的值,而是读取寄存器中不变的flag的值,等到线程2执行到flag变量后,尽管修改掉了内存中flag的值,但是寄存器中的flag依旧为原来的值,所以while一直感知到的flag是没变的,一直循环跑。

那么如何解决该问题呢?

用volatile来修饰变量,通过保证内存可见性来解决上述问题,每次读取用volatile修饰的变量的值,都会从主内存中读取该变量。

通俗地讲:volatile变量在每次被线程访问时,都强迫从主内存中重读该变量的值,而当该变量发生变化时,又会强迫线程将最新的值刷新到主内存。这样在任何时刻,不同的线程总能看到该变量的最新值。

那么,线程修改volatile变量的过程:

(1)改变线程工作内存中volatile变量副本的值

(2)将改变后的副本的值从工作内存刷新到主内存

线程读volatile变量的值的过程:

(1)从主内存中读取volatile变量的最新值到线程的工作内存中

(2)从工作内存中读取volatile变量的副本

(2) volatile禁止指令重排序

我们这里拿实例化一个对象举例

SomeObject s=new SomeObject(); //保证对象实例化正确

1.堆里申请内存空间,初始化为0x0

2.对象初始化工作:构造代码块,属性的定义时初始化,构造方法(这才算是一个正确对象)

3.赋值给s

volatile禁止重排序,只能1->2->3,如果1->3->2在3到2之间有线程(线程调度随机)使用对象,其对象是错的即出现问题。

能准确的表明其作用是单列模式:(这个我们后面会再讲)

单列模式分为饿汉模式(在类加载期间就进行对象实例化),懒汉模式(第一次用到时进行对象的实例化)

其懒汉模式实现如下:假如多个线程走先判断对象没有实例化,对类加锁(一个线程持有锁,但这是不知道是否实例化),所以要再判断是否实例化,没有实例化进行实例化,实例化了就返回对象,这里volatile就是要确保实例化正确。

标签: java jvm 开发语言

本文转载自: https://blog.csdn.net/m0_53882348/article/details/129680007
版权归原作者 书生-w 所有, 如有侵权,请联系我们删除。

“多线程 (五) 线程安全及解决方案(看这一篇就够了)”的评论:

还没有评论