【多线程初阶】内存可见性问题 volatile
文章目录
- 再谈线程安全问题
- 内存可见性问题
- 可见性问题案例
- 编译器优化
- volatile
- Java内存模型(JMM)
再谈线程安全问题
如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该有的结果,则说这个程序是线程安全的,反之,多线程环境中,并发执行后,产生bug就是线程不安全
上次谈到线程安全问题,我们介绍了前三种问题是如何解决的
-
1.[根本问题] 操作系统对于线程的调度是随机的,抢占式执行
这个原因我们无法解决,这是操作系统的底层设定,我们左右不了 -
2.修改共享数据:多个线程同时对同一变量进行修改
这个原因更多的是和代码结构相关,可以调整代码结构,规避一些线程不安全的代码,但是这样的方案通用性还是不够,有些需求就是需要多线程对共享数据进行修改
- 3.修改操作,不是原子的
Java中解决线程安全问题最主要方案就是加锁
我们通过synchronized 关键字加锁操作来实现,锁属于系统提供的一个专门的机制,它能够产生互斥效果,通过互斥效果,把本来无序的并发执行编程一个局部上的串行执行,从而进一步解决线程安全问题
本篇文章我们解决第四种原因内存可见性问题
内存可见性问题
可见性指,一个线程对共享变量值进行修改,能够及时被其他线程看到最新值
可见性问题案例
问题代码示例:
public class Demo21 {private static int flag = 0;public static void main(String[] args) {Thread t1 = new Thread(() ->{while(flag == 0){}System.out.println("t1 线程结束");});Thread t2 = new Thread(() ->{Scanner scanner =new Scanner(System.in);System.out.println("请输入 flag 的值:");flag = scanner.nextInt();});t1.start();t2.start();}
}
很明显,这也是个bug ,涉及到线程安全问题,一个线程读取,一个线程修改,修改线程修改的值,并没有被读线程读到,这就是"内存可见性问题"
编译器优化
背景:
为什么要有编译器优化这样的机制呢?–>由于程序员的水平参差不齐,研究JDK的大佬们,希望通过让编译器 & JVM对程序员写的代码,自动进行优化
优点:
对于程序员原有的代码,编译器/JVM会在原有逻辑不变的前提下,对代码进行调整,使程序效率更高
缺点:
- 编译器在编译代码时,并没有执行代码,只是根据编译的静态代码,来分析这个程序应该如何调整,才能更加高效,所以"保证原有逻辑不变",这个"保证"并非100%生效
- 尤其在多线程中,因为并发执行随机调度的特点,执行多线程程序过程中,编译器的判断可能会出现失误,可能导致编译器的优化前后的逻辑出现细节上的偏差,
我们上述案例最后的效果并非我们预期的效果就是因为编译器优化导致的逻辑上细节有所偏差,我们接下来详细介绍是如何产生偏差的
上述代码中,t1 线程度循环条件 flag == 0,对其操作进行细化,会细分出两个指令,分别是读取指令(load读取flag)和比较指令(cmp)
程序会先执行读取指令load,把flag这个变量在内存中的值读取到寄存器中,才会执行比较指令cmp
就是因为load和cmp两步指令都在循环中完成且while中没有休眠限制,会在极短时间内循环多次
其中load(读内存操作)是cmp(纯CPU寄存器操作)时间开销的几千倍,因为虽然读取内存数据比读取硬盘数据要快多了,但是如果拿CPU寄存器和内存比,还是寄存器快得多
CPU寄存器模块,也可以存储一些数据
存储空间: 内存 >> 寄存器
存储速度: 内存 << 寄存器
CPU使用寄存器,是为了辅助计算,保存一些空间结果
因此执行过程中,JVM感受到 load 反复执行的结果好像都一样
JVM嘀咕,我执行那么多次读取flag的操作,发现值始终都是 0 ,既然结果都一样,既然还要反复执行那么多次,何必呢,于是编译器优化,把读取内存的操作,优化成 读取寄存器 这样的操作(把内存的值读取到寄存器了,后序在load不再重新读内存,而是直接从寄存器里取出来)
于是等了很多秒之后,用户真正输入新的值,真正修改 flag,此时 t1 线程已经感知不到用户的修改了 (编译器优化,使得 t1线程的读操作,不是真正的读内存)
如果稍微调整一下上述代码
假设本身读取 flag 的时间是 1ns 的话,如果把读内存操作优化成读寄存器 ,1ns =>0.xx ns,能把效率优化个50%以上,但是引入了 sleep之后,sleep 直接占用 1ns,此时优不优化 读内存操作,就无足轻重了
volatile
针对内存可见性问题,也不能单单指望通过sleep来解决,毕竟使用sleep会大大影响到程序的效率,我们希望不使用sleep也可以解决上述内存可见性问题
JDK大佬们知道上述内存可见性问题,在编译器优化的角度难以进行调整,就在语法中引入了 volatile 关键字,通过这个关键字来修饰某个变量,此时编译器对这个变量的读取操作,就不会被优化成读寄存器了
volatile 修饰的变量,能够保证"内存可见性"
前面我们讨论内存可见性时,编译器优化将读内存操作优化成直接访问工作内存(实际就是CPU的寄存器 或者 CPU的缓存),速度非常快,但是可能出现数据不一致的情况,加上volatile,强制读写内存,速度是慢了,但是数据变得更准确了
代码写入volatile修饰的变量时
- 改变线程工作内存中volatile变量副本的值
- 将改变后的副本的值从工作内存中刷新到主内训
代码读取volatile修饰的变量时
- 从主内存中读取volatile变量的最新值到线程的工作内存中
- 从工作内存中读取volatile变量的副本
注意:volatile解决的是内存可见性问题,不是解决原子性问题
volatile 和 synchronized 是有着本质区别的,synchronized能够保证原子性,volatile保证的是内存可见性
Java内存模型(JMM)
提到 volatile 就一定会谈到 JMM(Java Memory Model) Java内存模型
Java内存模型(JMM):Java虚拟机规范中定义了Java内存模型
目的是屏蔽掉各种硬件和操作系统内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果
Java官方文档的术语
每个线程,有一个自己的"工作内存",同时这些线程共享同一个"主内存"
当一个线程循环进行上述读取变量操作的时候,就会把主内存中的数据,拷贝到该线程的工作内存中,后续另一个线程修改,也是先修改自己的工作内存,拷贝到主内存里
由于第一个线程仍然在读自己的工作内存,因此感知不到主内存的变化
注意:这里的术语,工作内存指的是"CPU的寄存器"
- 线程之间的共享变量存储在 主内存(Main Memory)
- 每一个线程都有自己的"工作内存"(Working Memory)
- 当线程要读取一个共享变量的时候,会先把变量从主内存拷贝到工作内存,再从工作内存读取数据
- 当线程要修改一个共享变量的时候,也会先修改工作内存中的副本,再同步回主内存
由于每个线程有自己的工作内存,这些工作内存中的内容相当于同一个共享变量的"副本",此时修改线程1 的工作内存中的值,线程2 的工作内存不一定会及时变化
1)初始情况下,两个线程的工作内存内容一致
2)一旦线程1 修改了 a 的值,此时主内存不一定能及时同步,对应的线程2 的工作内存的 a 的值也不一定能及时同步
此时就有了两个问题:
- 为什么要整那么多内存?
- 为什么要这么麻烦的拷贝来拷贝去?
1)为什么要整那么多内存?
实际上并没有这么多"内存",这只是Java规范的一个术语,属于"抽象"的 叫法,所谓的"主内存"才是真正的硬件角度的"内存",而所谓的"工作内存",则是CPU的寄存器和高速缓存
2)为什么要这么麻烦的拷贝来拷贝去?
因为CPU访问自身寄存器的速度以及高速缓存的速度,远远超过访问内存的速度(快了3-4个数量级也就是几千倍,上万倍)
比如某个代码中要连续10次读取变量的值,如果10次都从内存中读,速度是很慢的,但是如果只是第一次从内存读,读到的结果缓存到CPU的某个寄存器中,那么后9次读数据就不必直接访问内存了,效率就大大提高了
那么接下来的问题又来了,既然访问寄存器速度这么快,还要内存干嘛?
一个字:贵
值的一提的是,快和慢都是相对的,CPU访问寄存器速度远远快于内存,但是内存的访问速度又远远快于硬盘.对应的 CPU价格最贵,内存次之,硬盘最便宜