一、引入
两个线程同时并发对一个变量自增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 还有一个作用就是防止内存可见性,它可以保证多个线程在操作同一个变量时,始终可以读到最新的数据。