在Java并发的话题中,volatile关键字一定是绕不开的话题。Java程序员都知道,volatile关键字的使用方式,以及它的特性:保证变量在内存中的可见性,但不保证原子性。Java的J.U.C包中volatile关键字可谓是基石般的存在。接下来我们便来好好的深究这个volatile关键字的核心。
被volatile修饰了的变量,是可以保证其在内存中的可见性的。其实在单核的操作系统下,不论是单线程还是多线程,都不存在变量的可见性问题。因此用不用volatile关键字都一样。但是在当今多核处理器和程序并发的场景下,必须采取一些特殊的机制来保证程序运行的结果正确。为了达到这个目的,程序语言本身和操作系统各自都做了不同的努力。接下来我们便来看看语言本身和操作系统到底做了什么样的事情?
我们以双重校验的单例模式开始:
public class Singleton {
private static Singleton INSTANCE;
private Singleton() {
}
public Singleton getInstance() {
if (INSTANCE == null) {
synchronized (this) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
双重校验锁乍看之下没有问题,但是如果了解Java new对象的过程的话,则一眼便能看出问题所在。
Java中new对象的具体过程:1)给对象分配内存;2)初始化对象;3)将引用指向对象所在的内存地址。以上三个步骤,1、2是具有前后依赖关系的,因此不存在指令重排序的问题,但是3和1/2步骤不存在前后依赖关系,因此操作系统有可能会进行指令重排序。如果在并发的场景下,A线程在初始化对象,此时B线程很有可能获取到一个还没有初始化完成的对象。
如果采用静态内部类的方式来实现单例模式,则不会出现这个问题,因为在类的加载过程中,JVM会通过锁机制保证只会有一个线程对类进行加载。
如果将INSTANCE使用volatile关键字修饰的话也不会存在这个问题。
接下来我们就来看看指令排序到底是个啥。
指令重排序的前提是遵循as-if-serial语义。如果从Java语言的角度来看指令重排序的话,它是这样的:
这意味着,指令重排序会发生在JVM编译阶段或者是操作系统执行阶段。
在编译阶段,编译器会适当优化指令来提升执行效率,优化手段包括语法糖的解析:例如JDK8下,字符串拼接将会重新被定为为StringBuilder方式,遵循as-if-serial语义也可能会进行指令重排序。
现代操作系统为了提升执行效率,引入了指令重叠并行执行技术。这种技术,也会存在指令重排序的情况。
指令重叠技术是计算机体系结构中的内容,详细介绍
随着摩尔定律的失效,现代计算机采用多核处理器的方式进一步提升执行效率。每个处理器都拥有一级缓存、二级缓存甚至三级缓存,缓存中的最小单位是缓存行。它们共用主内存,并且为了进一步提升执行效率,引入了Store Buffers和Invalid Queue。在并发的场景下,对于数据的读写指令可能会存在重排序。例如CPU1读取V变量到缓存中,并进行一系列的计算,在写回主内存时,CPU2读取了V变量进行一系列的计算。并先于CPU1的写指令,这将导致CPU1的运算结果不可见。这便是内存系统的重排序,更精准的来说应该是读写指令的重排序。
缓存行的介绍网络上有很多精辟的总结,这里不再重复造轮子。
指令重排序可以提升执行效率,但同时也带来了并发问题。而然操作系统并不知道何时应该进行指令重排序何时不应该进行指令重排序。因此开放了一些功能,让开发者自己决定到底该不该重排序。接下来,我们将站在操作系统层面和Java语言层面来看他到底是如何禁止指令重排序的。
操作系统为了保证缓存中数据的一致性,制定了缓存一致性(MESI)协议。当前大多数的缓存一致性协议的实现思想都是基于“嗅探”技术。它的基本思想如下:所有内存的传输都是在一条共享的总线上,而所有的处理器都能看到这条总线;缓存本身是独立的,但内存共享资源。因此所有对于内存的访问(即:读/写)都要经过仲裁(即同一个指令周期中,只有一个CPU缓存可以访问内存)。
MESI协议实际上是缓存行的4种状态的缩写,这四种状态如下:
状态 | 解释 |
---|---|
Modify | 当前缓存行有效,缓存行的数据已经被修改了,修改后的数据只存在于本Cache中,和内存中的数据不一致 |
Exclusive | 当前缓存行有效,数据只存在于本Cache中,和内存中的数据一致 |
Share | -当前缓存行有效,数据存在于很多Cache中,和内存中的数据一致 |
Invalid | 当前缓存行无效 |
对于写数据:CPU在写数据之前,必须先拥有独占权,即将缓存行状态变成E状态,此时其他处理器嗅探到这个变化后,会将自己的缓存行置于I状态。
对于读数据:CPU在读数据时之前,其他处理器会嗅探到这个请求,如果其他处理器的缓存行存在M或E状态,则必须回到共享状态,如果是M状态,则需要先写回主内存。
下图示意了,当一个cache line调整状态的时候,另外一个cache line 需要调整的状态。
M | E | S | I | |
---|---|---|---|---|
M | × | × | × | √ |
E | × | × | × | √ |
S | × | × | √ | √ |
I | √ | √ | √ | √ |
由于引入了MESI协议,嗅探并确认状态的过程是需要阻塞的。如果阻塞总线的话,将会导致总线不可用,将大大浪费CPU的性能,得不偿失,因此实际情况并不会阻塞数据总线,而是阻塞当前这个缓存行。然而阻塞缓存行依然会带来性能上的损耗(没有阻塞最好…),为了解决这个问题,引入了Store Buffers和Invalid Queue。
CPU运算完毕之后,不再是直接将数据写到缓存中,而是直接写到Store Buffers,然后继续做其他事情去,写回到缓存的任务交给了Store Buffers。数据最终会写回到缓存中,但至于具体什么时候真正写回,就得不到保证了。
当写回缓存时,其他缓存会嗅探到这个动作然后立马ack,但不会立马让缓存行失效,而是放入到Invalid Queue中,这个缓存行最终会失效,但至于具体什么时候真正时效,就得不到保证了。
在引入了Store Buffers和Invalid Queue的情况下,MESI协议的实际执行情况变得更加复杂并且带来了新的问题:
1.如果Store Buffers中有值,则CPU将直接从Store Buffers中取值,但此时的值会没有提交;
2.可以确保数据最终会处理,但什么时候处理得不到保证!
以上这两个问题,导致MESI协议“失效”了。
我们来看这一段伪代码:
value = 3;
isFinish = false;
void exeToCPUA(){
value = 10;
isFinish = true;
}
void exeToCPUB(){
if(isFinish){
//value一定等于10?!
assert value == 10;
}
}
一开始,CPUA完成了运算,isFinish的值已经从Store Buffers中写回缓存,然而CPUA中的valuse值坑还没有写回到缓存,或者写回到了缓存,但是CPUB中还没有真正的将其置为valid状态。此时的对于CPUB而言,value值不是10而是依然为3.
这个现象看起来好像是isFinish = true先执行,而value = 10后执行。这种在可识别的行为中发生的变化称为重排序。
然而操作系统并没有因为这个问题而放弃Store Buffers和Invalid Queue的优化方案。而是基于硬件层面实现的内存屏障,来保证MESI协议的有效性。接下来我们继续深究内存屏障。
在操作系统中,内存屏障有3种类型和一种伪类型:
类型 | 说明 |
---|---|
读屏障(Load Barrier) | 在执行任何一条加载数据操作到缓存之前,先将失效队列中的所有失效指令全部执行完毕! |
写屏障(Store Barrier) | 在执行写屏障指令之后的任何指令之前,先将Store Buffer中的所有存储指令全部执行完毕! |
全能屏障 | 同时拥有读屏障和写屏障的能力! |
LOCK指令 | (汇编语言中的指令)不是内存屏障,但是能够完成全能屏障的功能! |
以上便是内存屏障的定义,我们基于内存屏障在分析从新定义上面的伪代码:
value = 3;
isFinish = false;
void exeToCPUA(){
value = 10;
store_barrier();
isFinish = true;
}
void exeToCPUB(){
if(isFinish){
load_barrier();
assert value == 10;
}
}
我们基于内存屏障来重新分析这段代码:
一开始,CPUA完成了运算,那么在回写isFinish时,由于写屏障,必须先清空Store Buffer,此时value的值将会回写;于此同时,CPUB接收到value值的更新,会立即ack,并将本缓存中的value放入到Invalid Queue;
CPUB在获取到isFinish = true的情况下(有可能获取到的依然是false),在读取value值之前,由于读屏障,此时会将Invalid Queue中的失效指令全部执行完毕,再去读取value,而这个时候,value值是失效状态,将去主内存读取值。
通过内存屏障,可以保证MESI协议的有效性,达到“禁止指令重排序”的目的。
以上伪代码,在Java中的其实是长这样的:
private volatile int value = 3;
private boolean isFinish = false;
public void exeToCPUA() {
value = 10;
isFinish = true;
}
public void exeToCPUB() {
if(isFinish) {
assert value == 10;
}
}
我们可以看到通过volatile关键字便可以禁止指令重排序,从而保证了变量在内存当中的可见性。
那么volatile关键字的背后是内存屏障吗?答案:不是!
此时已然采用老套路,反编译看一下:
public com.leon.util.Test();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: aload_0
5: iconst_3
6: putfield #2 // Field value:I
9: aload_0
10: iconst_0
11: putfield #3 // Field isFinish:Z
14: return
......
通过反编译,看不出个啥。此时只能将class文件进一步反编译成汇编指令来观察。
由于汇编结果太多了,就不再贴出。我们只需知道,在操作value指令之前,都有一个lock指令前缀!
volatile关键字其实是通过汇编指令中的LOCK指令前缀来完成内存屏障的效果! 我们介绍到LOCK指令不是内存屏障,但是能够完成全能屏障的功能。
到这里,真想了然!
通过以上的介绍,我们知道了指令重排序的场景,MESI协议以及其有效性保证的实现机制,进而引出内存屏障的知识,最后我们终于知道了volatile关键字背后的核心是啥;为什么volatile关键字能够保证变量的可见性,可以禁止系统层面的指令重排序(当然也能禁止Java编译器层面的指令重排序)。
写到这里,越来越觉得像Java这样的高级语言,屏蔽了系统底层的知识和结构,让开发人员专注于功能实现,实在是了不起。但同时又觉得,由于现在人心浮躁,大多数人们只想看到自己想看到的;只能看到自己能看到的,不愿意知其所以然,未免有点悲哀~
我在写这篇文章的时候,查阅了大量的文献和网络上十几篇高赞的回答,最后综合自己的见解,将这些知识尽可能的关联起来,感谢这些巨人给我肩膀~
【参考文献】