【多线程基础】 线程安全及解决方案(看这一篇就够了)

这篇具有很好参考价值的文章主要介绍了【多线程基础】 线程安全及解决方案(看这一篇就够了)。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

【多线程基础】 线程安全及解决方案(看这一篇就够了)

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

欢迎志同道合的朋友一起加油喔🦾🦾🦾【多线程基础】 线程安全及解决方案(看这一篇就够了)


目录

前言

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

1.1什么是原子性

1.2什么是内存可见性

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

 1.4 什么是指令重排序

2.解决线程安全问题

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

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

(2)synchronized的作用

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

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

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

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

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

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

(1) volatile保证内存可见性

(2) volatile禁止指令重排序



前言

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


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就是要确保实例化正确。文章来源地址https://www.toymoban.com/news/detail-434314.html

到了这里,关于【多线程基础】 线程安全及解决方案(看这一篇就够了)的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • Kafka重复消费以及消费线程安全关闭的解决方案

    Kafka消费程序每次重启都会出现重复消费的情况,考虑是在kill掉程序的时候,有部分消费完的数据没有提交offsect。 此处表明自动提交,即延迟提交(poll的时候会根据配置的自动提交时间间隔去进行检测并提交)。当kill掉程序的时候,可能消费完的数据还没有到达提交的时间

    2024年02月13日
    浏览(21)
  • 【JavaEE面试题(九)线程安全问题的原因和解决方案】

    大家观察下是否适用多线程的现象是否一致?同时尝试思考下为什么会有这样的现象发生呢? 原因是 1.load 2. add 3. save 注意:可能会导致 小于5w 想给出一个线程安全的确切定义是复杂的,但我们可以这样认为: 如果多线程环境下代码运行的结果是符合我们预期的,即在单线

    2024年02月16日
    浏览(17)
  • 精通线程池,看这一篇就够了

    当我们运用多线程技术处理任务时,需要不断通过new的方式创建线程,这样频繁创建和销毁线程,会造成cpu消耗过多。那么有没有什么办法 避免频繁创建线程 呢? 当然有,和我们以前学习过多连接池技术类似,线程池通过提前创建好线程保存在线程池中, 在任务要执行时取

    2023年04月17日
    浏览(33)
  • 【多线程】| 线程冲突解决方案

    同一进程内的线程是共享同一内存空间的,所以在多个线程的进程里,线程是可以同时操作这个进程空间的数据的,这样就容易造成线程冲突的情况。 举个小李子:一个房子里(代表一个进程),只有一个厕所(代表一个资源)。屋子里面有两个人A和B(代表两个线程),共

    2024年02月05日
    浏览(20)
  • 了解5G安全标准,看这一篇就够了

    随着移动通信系统在社会生活中的使用越来越广泛,特别是5G进一步以企业级应用作为核心应用场景,安全成为了包括5G在内的移动通信系统不可忽视的因素。本文梳理了全球主流移动通信标准化组织在安全方面的标准制定,从而可以快速了解5G协议层面对信息安全的考量。原

    2024年02月05日
    浏览(19)
  • CSS基础——看这一篇就够了

    目录 一、CSS简介 1.CSS是什么? 2.CSS的作用 3.CSS的构成 二、CSS选择器 1.基础选择器 (1).标签选择器 (2)类选择器 (3)标签选择器 (4) 通配符选择器 2.复合选择器 (1)后代选择器(包含选择器) (2)子选择器 (3)并集选择器 (4)伪类选择器  三、基本属性 1.字体属性

    2024年02月09日
    浏览(25)
  • @Async异步线程:Spring 自带的异步解决方案

            在项目应用中,使用MQ异步调用来实现系统性能优化,完成服务间数据同步是常用的技术手段。如果是在同一台服务器内部,不涉及到分布式系统,单纯的想实现部分业务的异步执行,这里介绍一个更简单的异步方法调用。         对于异步方法调用,从Spring3 开

    2023年04月24日
    浏览(34)
  • 前端HTML基础:表单标签看这一篇就行了。

    表单标签:     在网页中为了收集用户资料,此时你就会用到表单。 在HTML中,一个完整的表单由表单域、表单控件和提示信息3个部分组成 1.表单域 表单域就是一个包含表单元素的区域。 在HTML标签中,form标签用于定义表单域,以实现用户信息的收集和传递。 form会把它范

    2024年02月21日
    浏览(30)
  • 【Linux】shell编程基础(超详细,入门看这一篇就够了)

    🥇🥇【Liunx学习记录篇】🥇🥇 篇一:【Linux】VMware安装unbuntu18.04虚拟机-超详细步骤(附镜像文件) 篇二:【Linux】ubuntu18.04系统基础配置及操作 篇三:【Linux】用户与组的操作详细介绍 篇四:【Linux】管理Linux文件权限属性介绍 篇五:【Linux】使用数字表示法和文件表示法修

    2024年02月04日
    浏览(22)
  • 安全网格:数据安全的终极解决方案

    随着网络威胁的不断增长和安全法规的日益严苛,全球网络安全投资规模不断创下新高。据Gartner预测,2024年全球组织在IT安全和风险管理工具上的投入将达到2087亿美元。 然而,埃森哲(Accenture)的报告却显示,尽管投入巨资,超过七成(74%)的首席执行官对企业数据安全和网

    2024年04月27日
    浏览(18)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包