• 线程安全问题你了解多少?java中线程安全的基础防范


    线程安全

    线程不安全的原因

    1. 操作系统对线程的调度是抢占式的,具有随机性

    抢占式执行是线程是并发执行的主要问题,但是对此我们无法改变,只能保证程序以任意线程顺序运行都能产生正确的结果

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

    这种操作产生的问题通常伴随着操作不是原子的问题,详细见下

    1. 修改操作不是原子的

    操作不是原子指的是操作指令不具有完整性,即操作a后操作b前面不能插入其它操作才能视作操作a和b连在一起的操作是原子的。举个例子:

    在这里插入图片描述

    如果有两个用户同时买票,由于多线程共享一个数据,它们必然会一起看到票数都有一张,如果他们一起买票就会造成票多买一张。如何避免这种情况呢?很简单,只要让a在买票时b在一旁等着就好了,这样就使得买票这个操作具有原子性,这样的操作我们成为“加锁”。具体加锁方法将在下面叙述。

    同时即使是一条java语句也不具有原子性,因为一段java语句往往涉及多个CPU操作,如下面代码:

    class Counter{
         public int num = 0;
         public void increase(){
             num++;
         }
    
    }
    
    public class ThreadCounter {
     public static void main(String[] args) throws InterruptedException {
         Counter counter = new Counter();
         Thread t1 = new Thread(() -> {
             for (int i = 0; i < 5000000; i++) {
                 counter.increase();
             }
         });
         Thread t2 = new Thread(() -> {
             for (int i = 0; i < 5000000; i++) {
                 counter.increase();
             }
         });
    
         t1.start();
         t2.start();
         t1.join();
         t2.join();
         System.out.println(counter.num);
     }
    

    或许大家会认为结果是10000000,但事实上结果为1 ~ 10000000之间的一个数。

    这是因为对于num++这个操作在CPU中有三个操作:

    1. 从内存中读取数据
    2. 把数据更新
    3. 把数据写入内存

    如果有两个线程同时进行,这些操作就会进行重叠,如下图:

    在这里插入图片描述

    可见两个操作过后num只加了一次,想要解决这个问题也只要让该操作是原子即可,表现为a在进行increase时b就在一边等着,使得操作变为线性操作,这样就解决了该问题,但与此同时就浪费了大量的时间,在工程中我们需要对锁操作进行平衡,以具体需求为准。

    1. 内存可见性问题

    在这里插入图片描述

    • 线程之间的共享变量存储在主内存
    • 每个线程都有自己的工作内存
    • 当线程要读取一个数据时都是先从主内存拷贝到”工作内存“然后从”工作内存“读取数据
    • 当线程要修改一个数据时是现在”工作内存“中修改,之后同步到主内存

    之所以要搞那么多内存是因为这里的主内存才是硬件里的内存,而这里的工作内存指的是寄存器和缓存,所以在工作内存中管理数据要比在主内存快几个数量级。

    所以如果线程a和b共享一个数据并在两个工作内存中进行修改就很容易产生线程中值不同步的问题。

    1. 指令重排序

    在java中我们写出的代码被JVM编译时,部分代码顺序会被改变以优化程序执行效率,在单线程中这种逻辑很好控制,但是到了多线程中,JVM对指令的重排序很容易产生各种问题。

    解决线程不安全问题

    synchronized关键字——java的锁机制
    public synchronized void increase(){
             num++;
         }
    

    synchronized有两种使用方法,第一种就是修饰方法块,当修饰方法被synchronized修饰后,当该方法被调用一个线程时就会触发锁机制,在方法结束之前如果又有线程调用该方法,这个线程就会进入阻塞等待。

    在这里插入图片描述

    但是虽然说是"队列",但是当加锁的线程结束解锁离开后,其它线程并不会按照排队顺序进去,而是会依据操作系统调度进入。

    在这里插入图片描述

    可重入

    可重入即当一个线程不会自己锁住自己

    当一个线程触发一个方法的锁机制时,如果再此调用该方法理论上就会出现”死锁“,因为那个锁是它自己锁的,也就是说它需要自己等待自己解锁,有点像是一个人上完厕所后从窗户出去了,那么它再想进自然进不去:
    在这里插入图片描述

    但是synchronized修饰的代码块会检测尝试访问的对象是否是加锁的对象,如果不是就拒绝访问,如果是让它通过,同时计数器+1。该线程每结束一次方法计数器都会-1,直到计数器为0时锁才会解开。即使是在运行方法时遇到错误提前跳出也没事,因为JVM只看该方法是否结束,这一点要比C++等方便很多。

    锁对象

    synchronized关键字不仅能修饰方法,也可以修饰代码块:

    public void increase(){
        synchronized( 锁对象 ){
            num++;
        }
    }
    

    当一个有synchronized修饰的代码块被线程调用时,如果该方法块内的锁对象已经上锁,则该线程进入等待状态。一般来说锁对象我们都会用this,用this修饰的代码块效果和修饰方法的效果是一致的。

    volatile关键字

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-muYzv7au-1663659504993)(C:\Users\yang\AppData\Roaming\Typora\typora-user-images\image-20220920141956976.png)]

    volatile关键字修饰的变量可以保证内存的可见性

    public class VolatileTest {
        static class Counter{
            public int count = 0;
        }
        public static void main(String[] args) {
            Counter counter = new Counter();
            Thread t1 = new Thread(() -> {
                while(counter.count == 0){
    
                }
                System.out.println("循环结束");
            });
    
            t1.start();
            Scanner scanner = new Scanner(System.in);
            counter.count = scanner.nextInt();
        }
    }
    

    如上代码,理论上当我们从控制台输入一个非零数后程序就会立刻停止,但事实并非是这样。如上面所说,JVM为了优化速度,每个线程都有一个”工作内存“,t1线程开始时从主内存中获取数据存储在工作内存中,然后在工作内存上操作数据,这就导致了当我们在main线程修改数据后t1线程读取的数据仍然是工作内存中没有修改的数据,所以循环永远不会停止。

    而volatile关键字修饰后JVM在涉及到count后都会强制从主内存中获取数据,同时在更新数据时JVM会把所有其它工作内存中的数据全部进行更新。这样做减缓了程序的运行速度,但是提高了数据的准确性。

    public volatile int count = 0;
    

    volatile关键字防止指令重排序
    示例来自单例模式的实现,需要了解可看博主另一篇博客:Java单例模式的完整实现,从0到1带你一步步优化
    部分内容如下:

    public static SingletonLazy getInstance(){
        if(instance == null){
            synchronized (SingletonLazy.class){
                if(instance == null){
                    instance = new SingletonLazy();
                }
            }
        }
        
        return instance;
    }
    

    代码上虽然两个if内容相同,但是作用是天差地别,第一个if是让instance不为null时直接返回,第二个if是判断在多个线程同时进入第一个if后是否已经有过实例化,如果没实例化就创建,有就结束。

    在程序运行时new有三个指令:

    在这里插入图片描述

    在JVM中为了性能优化,可能会把2 和 3两个指令进行调换,这样的调换虽然在单线程中没有影响,但是在多线程中可能出现返回空引用的情况,即线程a先new一个对象,先执行3指令,此时instance已经不为空,但是2还没执行时另一个线程直接调用了getInstance,这就导致了空引用传递,图示如下:

    在这里插入图片描述

    为了避免指令重排序,我们可以使用volatile关键字进行修饰,完整代码如下:

    private static volatile SingletonLazy instance = null;//使用volatile修饰,禁止getInstance时指令重排序
    private SingletonLazy(){
    
    }
    
    public static SingletonLazy getInstance(){
        if(instance == null){
            synchronized (SingletonLazy.class){
                if(instance == null){
                    instance = new SingletonLazy();
                }
            }
        }
    
        return instance;
    }
    
    wait和notify方法

    在并发编程时我们往往是一个线程a依赖另一个线程b的出的结果才能正常运行,这就需要a先等待,然后让b让它走时它才能走。等待就是wait(),notify()就是唤醒操作。

    wait 做的事情:

    • 使当前执行代码的线程进行等待. (把线程放到等待队列中)

    • 释放当前的锁

      之所以释放当前锁是因为在等待时线程已经不用执行了,所以可以先解锁让别人使用,如果不解锁就是占着茅坑不拉屎了。

    • 满足一定条件时被唤醒, 重新尝试获取这个锁.

    注意: wait 要搭配 synchronized 来使用. 脱离 synchronized 使用 wait 会直接抛出异常.
    wait 结束等待的条件:

    wait等待结束的条件:

    1. 其他线程调用该对象的 notify 方法.
    2. wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间).
    3. 其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常

    如果多个线程都在wait,notify会随机唤醒一个线程,此外还有一个notifyAll方法唤醒所有线程,但是即使唤醒了所有线程,这些线程还是在锁状态,根据操作系统的调度一个一个运行。

  • 相关阅读:
    Flink1.14 SourceReader概念入门讲解与源码解析 (三)
    KIE - Graph Convolution Network
    论文笔记 《FAST-LIO2: Fast Direct LiDAR-inertial Odometry》及 激光SLAM综述
    一文快速学会linux shell 编程基础!!!
    想开发DAYU200,我教你
    成集云 | 金蝶K3集成聚水潭ERP(金蝶K3主管库存)| 解决方案
    如何让接口请求,页面不刷新加载,页面加载中 不显示
    计算机网络(一):概述
    idea maven 打包 内存溢出 报 GC overhead limit exceeded -> [Help 1]
    【JavaScript 算法】链表操作:从基础到进阶
  • 原文地址:https://blog.csdn.net/m0_61016568/article/details/126954560