文章目录
- 内存可见性
- 内存可见性问题代码演示
- JMM(Java Memory Model)
- 指令重排序
- 指令重排序问题代码演示
- 指令重排序分析
- volatile关键字
- volatile 保证内存可见性 & 禁止指令重排序
- volatile 不保证原子性
在上一节介绍线程安全问题的过程中,提到了产生线程安全的原因主要有
- 操作系统的线程随机调度策略
- 对共享数据的写操作
- 操作不具有原子性
- 内存可见性问题
- 指令重排序问题
这五点原因中线程的随机调度是由操作系统调度模块具体实现,无法干预,而多个线程对共享数据的写操作,在某些情况下可以通过调整代码结构进行避免。操作的原子性,可以通过加锁来解决,这小节我们主要来看内存可见性和指令重排序是怎样影响到线程安全的.
由于读取内存,相比较读取寄存器是一个非常慢的操作,编编译器为了进一步提高代码执行的效率,会在保持逻辑不变的前提下,调整生产的代码内容,这样的操作在单线程环境中不会有什么问题,但是,在多线程环境下,编译器就可能会误判,内存可见性和指令重排序都是有编译器优化产生的问题
内存可见性
内存可见性问题代码演示
我们先来观察这段代码:
import java.util.Scanner;public class Test6 {public static int isQuit = 0;// 内存可见性问题public static void main(String[] args) {Thread t1 = new Thread(() -> {while (isQuit == 0) {// 什么也不执行}System.out.println("t1 线程执行完毕");});Thread t2 = new Thread(() -> {System.out.println("请输入isQuit:");Scanner scanner = new Scanner(System.in);isQuit = scanner.nextInt();System.out.println("t2线程执行完毕");});// 启动线程t1.start();t2.start();}
}
执行结果~~
请输入isQuit:
1
t2线程执行完毕
可以看到这里,输入1之后线程并没有执行完毕,那么不应该啊,isQuit的值不为0,t1线程应该会退出循环,可是并没有。我们看一张图。
在这个过程中,我么看看两个线程都做了什么。t1 线程在一直在读取主内存中isQuit的值,由于循环体没有执行任何逻辑,所以这个速度非常之快。t2线程先将isQuit读入工作内存,然后修改值为1后写回主内存。
如果就这样看,那么在isQuit的值被修改后t1线程也应该随之终止。但事实上Java在运行时,编译器发现在大量读取isQuit的值后,发现isQuit的值并没有改变。于是就做出来一种激进的优化(读取内存要比读取寄存器慢得多),不再读取内存,直接从寄存器中取值,这就导致了后续t2线程在我们输入值后,isQuit的值的确是改变了,但是t1线程并没有取读取内存中的isQuit,这就导致了t1线程对isQuit的内存不可见
在单线程中,编译器这样的优化一般是没有问题的,但是在并发场景下,就不得不考虑这样优化后对代码的影响。于是Java提供了volatile关键字,被这个关键字修饰后,编译器将不会进行优化。
JMM(Java Memory Model)
我们先了解一下JMM, Java虚拟机(JVM)规范文档中定义了Java内存模型.。目的是屏蔽掉各种硬件和操作系统的内存访问差异(跨平台),以实现让Java程序在各种平台下都能达到一致的并发效果。
- 线程之间的共享变量存在 主内存 (Main Memory) - 相当于内存
- 每一个线程都有自己的 “工作内存” (Work Memory) - 相当于寄存器
- 当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.
- 当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存.
指令重排序
和内存可见性一样,指令重排序也是在一定条件下触发的编译器的”优化“,目的是提高代码效率,编译器在“保持逻辑不发生变化的情况下”,针对指令执行的顺序进行调整,这就是指令重排序。
指令重排序问题代码演示
class SingletonLazy {private static volatile SingletonLazy instance = null;public static SingletonLazy getInstance() {if (instance == null) {synchronized (SingletonLazy.class) {if (instance == null) {instance = new SingletonLazy();}}}return instance;}private SingletonLazy() { }
}public class Demo22 {public static void main(String[] args) {}
}
在这个单例模式(懒汉模式)中,如果是第一次创建实例,那么会涉及到一个new操作。我们简单的将new操作理解为三步:
- 申请内存空间
- 在内存空间上构造对象
- 把内存地址,复制给instance引用
指令重排序分析
在单线程下,先执行指令2,还是先执行指令3都可以,不影响最终的结果,但是在多线程下,就可能会出现问题。假设编译器将new操作的执行顺序优化为了 1 -> 3 -> 2,t1线程进入,创建单例,但是还没构造对象,就已经将空引用返回(锁已经释放),这是如果t2线程进入,instance还是为空此时就可能会创建出多个实例。
解决方案和内存可见性一样,使用volatile关键字,让编译器不要进行优化。
volatile关键字
volatile 保证内存可见性 & 禁止指令重排序
代码在写入 volatile 修饰的变量时
- 改变线程工作内存中volatile变量副本的值
- 将改变后的副本的值从工作内存刷新到主内存
代码在读取 volatile 修饰的变量时
- 从主内存中读取volatile变量的最新值到线程的工作内存中
- 从工作内存中读取volatile变量的副本
读取内存相较于读取寄存器来说,非常慢,使用volatile修饰虽然强制读写内存,但是保证了代码的正确性,一般来说,不会牺牲正确新来换取效率。
volatile 不保证原子性
volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性,volatile保证的是内存可见性,禁止指令重排序。volatile只是强制cpu读取内存,但是不会保证操作的原子性(不可分割)。
不管是原子性、内存可见性还是指令重排序,都可能产生线程安全问题,我们在进行并发编程时一定要谨慎!!