Java – 出现线程安全问题的原因有哪些?
一、线程安全问题是什么?
多线程被系统随机调度,使得进程的执行有多种可能性。其中某些可能性会造成进程的代码出现bug -> 线程不安全/安全问题。
举个例子:当两个线程尝试修改同一个变量的时候,每次的运行结果都不一样
创建两个线程, 让这俩线程同时并发的对一个变量自增 5w 次. 最终预期一共自增 10w 次.
//使用一个类来保存计数的变量
class Add{
public int count;
public void add(){
count++;
}
}
public class demo1 {
static int Max=5_0000;
static Add add=new Add();
public static void main(String[] args) throws InterruptedException {
//线程1 - 增加Max次
Thread thread1=new Thread(()->{
for(int i=0;i<Max;i++){
add.add();
}
});
//线程2 - 增加Max次
Thread thread2=new Thread(()->{
for(int i=0;i<Max;i++){
add.add();
}
});
thread1.start();
thread2.start();
//阻塞主线程,等待thread1,thread2运行结束再运行主线程的输出“和”语句
thread1.join();
thread2.join();
//主线程阻塞解除,打印两个线程对变量count的累加效果
System.out.println("和:"+add.count);
}
}
运行结果:
分析运行结果:
每次的运行结果都不一致,这是线程不安全造成的安全隐患。
分析线程不安全的原因:
从内存与cpu的角度来看:
一行count++代码其实对应三条指令:① 从内存中读取数据到cpu; ② 在cpu的寄存器中完成加法运算; ③ 将在寄存器得到的运行结果写回内存
由于线程之间并发执行,两个线程,三条指令的执行步骤就有多种组合的可能性...
下图仅列举其中的几种可能性:
我们来画一下在各种可能性下,两个线程在cpu与内存之间处理指令的简略图:
可能性1:线程2取的数据是线程1存储在内存中的结果,结果正确
可能性3:线程1,2执行任务,取得都是0,计算结果都为1,存回内存的都是1。结果错误
结论:
只有一个线程连续完成Load-Add-Save三个指令结束任务之后,另一个线程再出来开始执行任务Load-Add-Save才不会出错。否则不然。
二、出现线程安全问题的原因
1. 操作系统随机调度线程,导致线程之间抢占式执行任务
2. 多个线程修改同一个变量
多个线程修改不同的变量,不会有影响;
多个线程读同一个变量,不会有影响
3. 多线程对同一个变量的修改操作不是"原子"的
原子指的是不可再拆分的微小单位
通过++来修改,修改操作对应三条指令,不是原子的;
通过=来修改,修改操作对应一条指令,视为原子的
上述例子中:
两个线程同时对一个变量进行++修改操作。由于++的修改操作并不是原子的,并且两个线程并发运行,就造成了线程1、2没办法在对方修改过的基础上进行修改而造成输出结果错误
4. 内存可见性引发的线程安全问题
常见场景是:a线程在写,b线程在读
a线程进行反复的读取/判断数据
读取数据:内存->寄存器(从内存读取数据到寄存器后,也可以直接从寄存器读取数据。
并且从内存读取数据的速度<<从寄存器读取数据的速度)
正常情况下:线程a进行读写和判断,线程2突然往内存写了新数据。线程b能够立即读到内存的变化,更新读到的数据
但是:进程运行的过程中,操作系统/JVM/编辑器可能会对这个过程进行优化
从内存中读取数据比较慢,反复读,每次读到的结果都一样。JVM就会进行优化 - 不再重复读,直接复用第一次从内存读到寄存器的数据 - 每次从寄存器读就好了
优化后,b线程往内存中写入了新数据,数据更新了
麻烦的事儿就在这!a线程经历了优化,没法读到内存中更新的数据感知不到变化。
内存可见性问题 - 内存数据改了,但是在优化的背景下,线程读不到/看不见
5. 指令重排序
有时候操作系统/编辑器/JVM为了提高效率,会交换指令之间的执行顺序
来看一个场景:Test t = new Test()
① 创建内存空间 ② 在内存空间上构造一个对象 ③把这个内存的引用赋值给t
正常情况下:
按②③的顺序,当第二个线程读到 t 为非null的时候,此时 t 一定是个有内存空间的有效对象
优化的背景下:(①肯定要先执行,但是②③的顺序可能会交换)
按③②的顺序,当第二个线程读到 t 为非null的时候,此时 t 仍然可能是个无效对象。因为还没有将新new出来的空间引用赋值给 t 时,就去读 t 的值了
🔊 为什么有时候交换顺序,可以提高效率呢?
举个例子:我们需要在超市买油-盐-米,但是超市里的柜台按米-油-盐的顺序排列。我们买油-盐-米的路程就要多于米-油-盐,后者交换顺序的效率明显就要高一些
有时候的优化能够给程序带来翻倍的效率提升,但有时候也会造成不好的影响!