一次面试经历:
面试官:请讲一下 volatile。
我:volatile 是 java 虚拟机提供的最轻量级的同步机制,当变量定义为 volatile 后,可以保证此变量对多线程的可见性。多个线程可以读到内存中最新的值。
面试管:volatile 底层具体怎么实现的? 怎么保证的可见性?
我:。。。。
面试官:volatile 怎么保证多线程可以读到最新的值?
我:。。。。
面试结果可想而知了,面试官随便问了问就送我出门了。
所以打算好好研究一下。
先来介绍几个知识点:
Java 内存模型
主内存与工作内存
Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量储存到内存和从内存中取出变量这样的底层细节。注意,这里的变量是指实例字段,静态字段和构成数组对象的元素,不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,就不存在竞争问题。
Java 内存模型有主内存和工作内存,工作内存保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量,不同线程之间无法直接访问对方工作内存的变量,线程间变量值的传递均需要通过主内存来完成。
注意:
当一个变量被volatile修饰后,JMM 会把该线程对应的工作内存中的共享变量值刷新到主内存。表示着线程工作内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,当其他线程读取共享变量时,它会直接从主内存中读取。
指令重排序
在执行程序时为了提高性能,编译器和处理器通常会对指令做重排序:
1.编译器重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
2.处理器重排序。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
指令重排序对单线程没有什么影响,他不会影响程序的运行结果,但是会影响多线程的正确性,既然指令重排序会影响到多线程执行的正确性,那么我们就需要禁止重排序。那么JVM是如何禁止重排序的呢?这个问题稍后回答,我们先看另一个原则happens-before,happen-before原则保证了程序的“有序性”,它规定如果两个操作的执行顺序无法从happens-before原则中推到出来,那么他们就不能保证有序性,可以随意进行重排序。其定义如下:
1.同一个线程中的,前面的操作 happen-before 后续的操作。(即单线程内按代码顺序执行。但是,在不影响在单线程环境执行结果的前提下,编译器和处理器可以进行重排序,这是合法的。换句话说,这一是规则无法保证编译重排和指令重排)。
2.监视器上的解锁操作 happen-before 其后续的加锁操作。(Synchronized 规则)。
3.对volatile变量的写操作 happen-before 后续的读操作。(volatile 规则)。
4.线程的start() 方法 happen-before 该线程所有的后续操作。(线程启动规则)。
5.线程所有的操作 happen-before 其他线程在该线程上调用 join 返回成功后的操作。
6.如果 a happen-before b,b happen-before c,则a happen-before c(传递性)。
我们着重看第三点volatile规则:对volatile变量的写操作 happen-before 后续的读操作。为了实现volatile内存语义,JMM会重排序,
注意
观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令。lock前缀指令其实就相当于一个内存屏障。内存屏障是一组处理指令,用来实现对内存操作的顺序限制。volatile的底层就是通过内存屏障来实现的。
内存屏障
为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为4类,如下表:
屏幕快照 2019-01-07 21.51.37.png
volatile内存语义实现
为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。下面是基于保守策略的 JMM 内存屏障插入策略:
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障(禁止前面的写与volatile写重排序)。
- 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障(禁止volatile写与后面可能有的读和写重排序)。
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障(禁止volatile读与后面的读操作重排序)。
- 在每个 volatile 读操作的后面插入一个 LoadStore 屏障(禁止volatile读与后面的写操作重排序)。
其中重点说下StoreLaod屏障,它是确保可见性的关键,因为它会将屏障之前的写缓冲区中的数据全部刷新到主内存中。上述内存屏障插入策略非常保守,但它可以保证在任意处理平台,任意的程序中都能得到正确的volatile语义。下面是保守策略(为什么说保守呢,因为有些在实际的场景是可省略的)下,volatile 写操作 插入内存屏障后生成的指令序列示意图:
其中StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作对任意处理器可见(把它刷新到主内存)。另外volatile写后面有StoreLoad屏障,此屏障的作用是避免volatile写与后面可能有的读或写操作进行重排序。因为编译器常常无法准确判断在一个volatile写的后面是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)为了保证能正确实现volatile的内存语义,JMM采取了保守策略:在每个volatile写的后面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见模式是:一个写线程写volatile变量,多个度线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里也可看出JMM在实现上的一个特点:首先确保正确性,然后再去追求效率(其实我们工作中编码也是一样)。
下面是在保守策略下,volatile读插入内存屏障后生产的指令序列示意图:
屏幕快照 2019-01-07 21.46.33.png
上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况忽略不必要的屏障。在JMM基础中就有提到过各个处理器对各个屏障的支持度,其中x86处理器仅会对写-读操作做重排序。
原子性
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
一个很经典的例子就是银行账户转账问题:
比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。
试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。
所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。
同样地反映到并发编程中会出现什么结果呢?
举个最简单的例子,大家想一下假如为一个32位的变量赋值过程不具备原子性的话,会发生什么后果?
i = 9;
假若一个线程执行到这个语句时,我暂且假设为一个32位的变量赋值包括两个过程:为低16位赋值,为高16位赋值。
那么就可能发生一种情况:当将低16位数值写入之后,突然被中断,而此时又有一个线程去读取i的值,那么读取到的就是错误的数据。
对于被 volatile 修饰的变量,对任意(包括64位long类型和double类型)单个volatile变量的读/写具有原子性,记着是对单个volatile变量的读或写才具有原子性,另外任何复合操作都不能保证原子性,如a++,a = a+1, a = b。特别注意a = b这类,它实际上包含2个操作,它先要去读取b的值,再将b的值写入工作内存,虽然读取b的值以及将b的值写入工作内存这2个操作都是原子性操作,但是合起来就不是原子性操作了。
想要理解透volatile特性有一个很好的方法,就是把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。
总结
至此应该对 volatile 有比较好的了解了,至少面试应该问题不大了,其实就是上面几个关键点,可见性,重排序,内存屏障,原子性。把这些底层都研究透了,面试官根本难不倒你。
参考资料:
深入理解 Java 虚拟机
Java 并发编程的艺术
Java 多线程编程核心技术
https://www.cnblogs.com/yuanfy008/p/9335168.html
https://www.cnblogs.com/dolphin0520/p/3920373.html
https://www.cnblogs.com/chenssy/p/6379280.html
网友评论