Java - 线程安全问题

本文探讨了多线程环境下并发修改共享变量导致的不一致性,重点分析了内存可见性问题和指令重排序,以及如何通过volatile关键字确保线程安全。作者还讨论了单例模式在多线程中的优化策略,强调了volatile在解决这些问题中的重要性。
摘要由CSDN通过智能技术生成

一、引入

两个线程同时并发对一个变量自增X 次,最终预期能够一共自增 2X 次。

但是,经过多次运行,每次的运行的结果都不太一样,就像是一个 随机数 ,处在 X至 2X 之间。

站在硬件的角度来理解,执行一段代码,就是需要让 CPU 把对应的指令从内存读取出来,然后再执行。(CPU 自身包含了一些寄存器,也能存储少量的数据)

比如count++这一行代码,其实对应了3个机器指令:load,add,save。

在多线程并发的情况下,最好的情况是一个线程完全执行完这3个机器指令,另一个线程再接着执行。但是由于操作系统的随机调度,执行情况会有很多种。

两线程在计算 1+1 = 2 的时候,就会出现两种结果,要么为 2,要么为 1

二、线程不安全五大原因

  • 操作系统的随机调度/抢占式执行
  • 多个线程修改同一变量
  • 有些修改操作,不是原子性的(如上的count++)
  • 内存可见性
  • 指令重排序

三、内存可见性

一个线程修改,一个线程读的场景,就特别容易因为内存可见性引发问题!

CPU 自身包含了一些寄存器,也能存储少量的数据

线程1 进行反复的 从内存中读数据(LOAD) 和 检测CPU寄存器中的值是否符合预期(TEST)

线程2 在中途某个时间点突然修改寄存器中的值然后写回内存 (ADD, SAVE)

正常情况下,线程1 在读和判断的时候,线程 2 突然写了一下不会出现问题,因为 线程2 写完之后,线程1 就能立即读到内存中的变化,从而保证下一次检测 CPU 寄存器中的值是最新的值. 

但是,在程序运行过程中,可能涉及到编译器 "优化",也可能是 JVM  优化,还可能是操作系统的优化!!就可能导致出现以下问题:

但是 线程1 这样 "优化" 之后,线程2 突然写了一个数据,此时 线程2 的修改,线程1 就感知不到了!!就导致了 线程1 没有读取到内存中最新数据,这就是内存可见性问题!!

上述场景的优化,在单线程环境下,没问题;多线程情况下就可能出问题。由于多线程环境太复杂了,编译器/JVM/操作系统进行优化的时候就可能产生误判!!
 

四、指令重排序

指令重排序,也是 操作系统/编译器/JVM 的一个优化操作,通过调整代码的顺序,从而达到加快速度的效果!!这种优化操作,在单线程情况下同样没有问题,而多线程就可能引发问题:

五、volatile 关键字

让 线程2 输入一个非0的数,使 线程1 结束:

public class Test {
    static class Counter {
        // volatile public int flag = 0; //加上 volatile 就不会出问题
        public int flag = 0;
    }

    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            while(counter.flag == 0) {
            }
            System.out.println("t1 结束");
        });
        t1.start();
        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数: ");
            counter.flag = scanner.nextInt();
        });
        t2.start();
    }
}

这段代码如果不做任何处理,最终我们输入一个非0的数,循环也不会退出,线程1 始终不会结束,原因就是内存可见性问题,线程1 的循环就相当于一直在执行 LOAD,TEST 指令,由于编译器/JVM的优化,导致了 LOAD 的重复操作都被省略,只执行一次,导致线程2 的修改最新数据没有被 线程1 及时获取,所以出现这样的问题。解决这个问题,就需要使用 volatile 关键字!!

volatile 操作相当于显示的禁止了编译器进行上述优化,是给这个对应的变量加上了"内存屏障"(特殊的二进制指令),JVM 在读取这个变量的时候,因为内存屏障的存在,就知道每次都要重新读取内存的内容,而不是进行草率的优化。

---频繁读内存,速度是慢了,但是数据算对了!!

上述代码,不使用 volitile ,也可以解决,可以在循环里让 线程1 sleep 一下: 

编译器的优化,是根据代码的实际情况来进行的。上个版本里循环体是空,所以循环转速极快!!导致了读内存操作非常频繁,所以就触发了优化!!(读内存操作比操作寄存器的速度慢上几千上万倍)

这个版本里加了 sleep ,让循环转速一下就慢了!!读内存操作就不怎么频繁了,就不会触发优化了!!

最好是加上volatile!!

六、多线程下单例模式,双重检测锁

饿汉模式

public class Singleton {
    //程序启动,则立即创建实例
    private static Singleton instance = new Singleton();
 
    public static Singleton getInstance() {
        return instance;
    }

    private Singleton() { };
}

懒汉模式

public class SingletonLazy {
    private static SingletonLazy instance = null;

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

    private SingletonLazy() { };
}

1.对于饿汉模式来说,多线程调用 getInstance ,只涉及了多线程读,所以不会引发线程安全问题!!

2.对于懒汉模式来说,多线程调用 getInstance,有的地方在读,有的地方在写,所以容易引发线程安全问题!!但是一旦实例创建好了之后,后续的 if 条件就不去了,就不会引发线程安全问题了。

对于懒汉模式在多线程中的安全问题,我们的解决办法就是加锁 

class SingletonLazy {
    private static SingletonLazy instance = null;

    public static SingletonLazy getInstance() {
        synchronized (instance) {
            //把读和写打包成原子操作
            if(instance == null) {//读操作
                instance = new SingletonLazy();//写操作
            }
        }
        return instance;
    }

    private SingletonLazy() { };
}

加上锁之后,线程安全问题得到解决了。

那么问题又来了,在创建好实例之后,后续在调用 getInstance 的时候就不应该再尝试加锁了,因为再尝试加锁,那么你的程序就要和"高性能"说拜拜了,加锁操作是非常影响效率的!

class SingletonLazy {
    private static SingletonLazy instance = null;

    public static SingletonLazy getInstance() {
        //使用双重 if 判定, 降低锁竞争的频率!!
        if(instance == null) {
            synchronized (instance) {
                //把读和写打包成原子操作
                if(instance == null) {//读操作
                    instance = new SingletonLazy();//写操作
                }
            }
        }
        return instance;
    }

    private SingletonLazy() { };
}

以上代码已经解决了多个线程获取锁低效的问题,那么问题又来了。多个线程频繁的读和写,这势必会让我们联想到内存可见性问题

用 volatile 修饰 instance。

但是,每个线程有自己的上下文,每个线程有自己的寄存器内容,按理来说,编译器/JVM/操作系统的不应该对读操作进行优化的。但是话又说回来,编译器/JVM/操作系统的优化是站在什么样的角度,咱也不知道!!所以这里为了保险起见,还是加上 volatile 更稳健!!

private static volatile SingletonLazy instance = null;

为什么单例对象必须使用 volatile 修饰  

对于 " instance = new SingletonLazy() " 这一行代码来说,它是由三条机器指令组成的:

创建内存空间
在内存空间中初始化 SingletonLazy 对象
将内存地址赋值给 instance 对象(执行这一条指令,instance 就不为 null 了)


【正常情况】  如果这三条机器指令是从上往下的顺序执行,那么就不存在问题。

【异常情况】  在不加 volatile 的情况下,编译器/JVM 为了加快程序执行速度,就会做出优化操作,它会调换 2,3 的执行顺序,那么调换顺序后,在多线程情况下就会出现问题,这个问题也叫做指令重排序。

多线程情况下,当线程 1 执行了 1,3 指令时, 还没来得及执行指令 2,此时线程 2 执行到这个代码了,因为线程 1 执行了指令 3,所以此时 instance 不为 null,那么线程 2 在经过第一个 if 条件判断时,就会返回 false,那么后面的代码就都不执行了,直接就返回 instance 对象了。但是此时的 instance 对象并没有完全实例化,那么线程 2 得到的 instance 就是一个不完整的对象,从而导致程序执行出错。这就是为什么单例对象必须使用 volatile 修饰的原因。

volatile 还有一个作用就是防止内存可见性,它可以保证多个线程在操作同一个变量时,始终可以读到最新的数据。

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值