• 线程安全问题


    目录

    🐇今日良言:一路惊喜 马声蹄蹄

    🐼一、线程安全问题

    🐳1.概念

    🐳2.代码

    🐳3.原因

    🐳4.解决方案


    🐇今日良言:一路惊喜 马声蹄蹄

    🐼一、线程安全问题

    🐳1.概念

    如果多线程环境下代码运行的结果是符合我们预期的,即该代码在单线程中运行得到的结果,那么就说说这个程序是线程安全的,否则就是线程不安全的.

    线程安全问题最根本的原因是:多线程的抢占式执行带来的随机性.

    如果没有多线程,此时代码的执行顺序是固定的,因此程序的结果也就是固定的.

    如果有了多线程,此时抢占式执行下,代码的执行顺序就会有很多种情况,所以为了执行结果正确,就需要保证在这多种执行顺序的情况下,代码运行得到的结果都是一样的.

    在多线程中,只要有一种情况下,代码结果不正确,就认为是有bug的,线程不安全的

    🐳2.代码

    通过下面代码,来理解线程安全问题

    1. class MyCount {
    2. public int count = 0;
    3. public void add() {
    4. count++;
    5. }
    6. }
    7. public class ThreadDemo22 {
    8. public static void main(String[] args) {
    9. // 创建一个实例
    10. MyCount myCount = new MyCount();
    11. // 创建两个线程,调用5万次 add 方法
    12. Thread t1 = new Thread(() -> {
    13. for (int i = 0; i < 50000; i++) {
    14. myCount.add();
    15. }
    16. });
    17. Thread t2 = new Thread(() -> {
    18. for (int i = 0; i < 50000; i++) {
    19. myCount.add();
    20. }
    21. });
    22. t1.start();
    23. t2.start();
    24. // 等待两个线程结束
    25. try {
    26. t1.join();
    27. t2.join();
    28. } catch (InterruptedException e) {
    29. throw new RuntimeException(e);
    30. }
    31. // 打印最终的结果
    32. System.out.println(myCount.count);
    33. }
    34. }

     运行三次上述代码,观察结果

    预期的效果是代码运行后,输出结果是100000  但是很遗憾,三次输出结果都不是

    如果在单线程中运行结果是100000,此时在多线程中发生了线程安全问题.

    为什么程序会出现这种情况呢?

    这是因为:在 count++ 操作中, ++ 操作本质上要分为3步:

    1.先把内存中的值,读取到CPU的寄存器上(该步骤称为load)

    2.CPU寄存器中的值进行 +1操作 (该步骤称为add)

    3.将得到的结果写回到内存中 (该步骤称为save)

    这三个操作,就是CPU上执行的指令,指令可以视为是机器语言.

    分析一下上述count++操作:

     可以看到,在多线程中,count++ 操作有无数种情况,针对自增结果正确和不正确情况再进行分析:

    结果正确

     结果不正确

     这里出现结果错误的情况,主要是因为t2读到了t1(还没提交)的数据.所以说,当运行代码后,最后的结果很大可能性是小于100000的.

    🐳3.原因

    多线程出现线程安全的主要原因有以下几点:

    1).抢占式执行,随机调度

        这是多线程中线程安全问题的根本原因

    2).多个线程同时修改同一个变量

        一个线程修改一个变量,没问题

       多个线程读取同一个变量,没问题

       多个线程修改多个不同的变量,也没事

    3).修改操作不是原子的

      如果修改操作是原子的,不会出现问题

      如果修改操作是非原子的,出现问题的概率非常高.

      原子:不可拆分的基本单位.

      上述的count++ 是非原子操作,可以拆分成load  add  save三个指令,而这三个指令无法再拆

       分了,是原子的

    4).内存可见性问题

        一个线程针对一个变量进行读取操作,同时另一个线程针对这个变量进行修改,

        此时读到的值,不一定是修改之后的值.这个读线程没有感知到变量的变化,

        归根结底是编译器/jvm在多线程环境下优化时产生了误判.

        为了解决内存可见性问题:需要为变量加上volatile关键字

        当为变量加上volatile关键字时,告诉编译器,这个变量是易变的,需要每次都重新读取这个变量的内存内容

    Volatile 不保证原子性   原子性是靠 synchronized 来保证的

    Volatile 和 synchronized 都能保证线程安全

    volatile关键字的作用主要有两个:

    一个是解决内容可见性问题,一个是禁止指令重排序

        从JMM(java Memory Model  java内存模型)的角度表述该问题:

        Java程序里,除了主内存,每个线程都有自己的工作内存(线程1和线程2的工作内存不是一个东西).

         线程1进行读取的时候,只是读取了工作内存的值.

         线程2修改的时候,先修改工作内存的值,然后再把工作内存的内容同步到主内存中.

         但是,由于编译器的优化,导致线程1没有重新从主内存同步数据到工作内存,读到的数据就是"修改之前"的结果.

    5).指令重排序

        本质上是编译器优化出bug了,可能是编译器觉得我们的代码有点差,在保持逻辑不变的情况

        下,进行调整(调整了代码的执行顺序),从而加快程序的执行效率.

    🐳4.解决方案

    主要是从原子性入手,来解决线程安全问题.

    通过 '加锁' 操作,将非原子操作转换成原子.

    加锁关键字:synchronized   这个关键字不仅要会写还要会读哦

     对上面的add方法加锁:

    此时再看执行结果,就是100000

     加了synchronized 后,进入方法就会加锁,出了方法就会解锁.

    如果两个线程同时尝试加锁,此时一个获取锁成功,另一个获取锁失败阻塞等待,只有当前面的线程释放锁之后,才可以获取到锁.

    针对上面 count++ 结果不正确的操作,加锁后进行分析:

     加锁的本质是把并发执行变成了串行执行

    加锁后,线程安全问题就得到了改善,但是代码的执行速度是大打折扣的.

    此时,就需要考虑我们的需求了,如果是要计算结果准确点,加锁无疑是正确的,虽然加锁会使多线程的速度慢了,但是还是比单线程要快.

    synchronized 的使用方法

    1.修饰方法

    1)修饰普通方法

    锁对象是this,谁调用这个普通方法,锁的对象就是谁

    2).修饰类方法

    锁对象是类对象

    2.修饰代码块

    显式/手动指定锁对象.

    3.可重入

    一个线程针对同一个对象加锁两次,是否会有问题,如果没有问题,就叫可重入,如果有问题,就叫不可重入.

    以上面的add方法为例:

    只要有线程调用add方法,进入add方法的时候,会先加锁(能够加锁成功),紧接着遇到代码块,再次尝试加锁.从this的角度看,它认为自己已经被另外的线程给占用了,这里的第二次是否要阻塞等待呢?  如果不阻塞等待就是可重入的,阻塞等待就是不可重入的.

    所以,加锁是要明确对哪个对象加锁的

    如果两个线程对同一个对象加锁,就会产生阻塞等待,锁竞争/锁冲突.

    如果两个线程对不同对象加锁,不会产生阻塞等待(不会锁冲突/锁竞争)

  • 相关阅读:
    人工智能对我们的生活影响
    【转存】异或运算的妙用
    css line-height属性是什么
    【Overload游戏引擎分析】UBO与SSBO的封装
    浮点数 C语言 IEEE754
    Linux源码安装RabbitMQ高可用集群
    13年老鸟整理,性能测试技术知识体系总结,从零开始打通...
    6月2(信息差)
    Java全栈开发第一阶段--01.Java基础编程(基本语法-变量的使用(重点))
    从手动测试到自动测试,企业该如何选择?
  • 原文地址:https://blog.csdn.net/qq_54469537/article/details/128182119