1.抛砖引玉
首先,我们来看下这段代码,
value = 1;
isFinsh = false;
//线程A
void exeOnCPUA(){
value = 10;
isFinsh = true;
}
//线程B
void exeOnCPUB(){
if(isFinsh){
value == 10;
}
}
一般情况下,value==10是true,但是在某些情况下,value==10是false的,为什么呢,一方面是变量的可见性问题,一方面是编译重排序,我们再来看一段代码,
public class DoubleCheckExample {
public static Instance instance;
public static Instance getInstance() {
if (instance == null) {
synchronized (DoubleCheckExample.class) {
if (instance == null) {
instance = new Instance();
}
}
}
return instance;
}
}
这段代码是著名的DCL单例模式,也叫双重检查锁,那么通过这段代码真的可以获得单例吗,通常来讲是没问题的,但是某些情况下,仍然会出现问题,究其原因,就是发生了指令重排序。
那么,怎么解决以上2个问题呢,那就要说到我们今天的主角了-volatile,那他又是如何解决的呢,我们先来看一下操作系统内存结构的优化史。
2.操作系统内存结构
单核CPU
在早期,一台PC机通常都是单核CPU,CPU从主存中获取数据进行计算,但是由于I/O操作的速度比起CPU计算的速度差了几个数量级,后来又在他们中间加了一层高速缓存,如图1,
CPU直接从高速缓存中获取数据,这样,系统的性能得到了很大的提高。
多核CPU
但是随着计算量的增大,单核CPU已经远远不能满足计算的要求了,于是就发展到了现代计算机的多核CPU,计算速度获取了很大提高,但是也带来了一些问题。我们试想一下以下场景:
1.CPU1 从主存中读取了一个字节,以及它相邻的字节到CPU1的高速缓存中。
2.CPU2 做了上面同样的工作。这样CPU1,CPU2的高速缓存拥有了同样的数据。
3.CPU1 修改了那个字节,被修改后,那个字节被放回CPU1的高速缓存。但是该字节并没有被写入主存中。
4.CPU2 访问该字节,但由于CPU1并未将数据写入主存中,所以CPU2访问的还是他高速缓存中的老数据,这就带来了数据的不同步。
MESI
为了解决这个问题,芯片设计者制定了一个规则。当一个CPU修改高速缓存中的字节时,计算机中的其它CPU会被通知,它们的高速缓存将视为无效。于是,在上面的情况下,CPU2发现自己的高速缓存中数据已无效,CPU1将立即把自己的数据写回 主存中,然后CPU2重新读取该数据。这就是缓存一致性协议M(Modified) E(Exclusive) S(Shared) I(Invalid)。M表示修改,E表示独享,S表示共享,I表示无效。举个例子,
CPUA发出一条指令,从主存中读取x。
CPUA从主存通过bus读取到cache a中并将该cache a设置为E(独享)状态。
CPUB发出一条指令,从主存读取x。
CPUB试图从主存中读取x时,CPUA检测到了地址冲突,这时CPUA做出响应。此时x 存储于cache a和cache b中,x在chche a和cache b中都被设置为S状态(共享)。
CPUA对x进行计算,计算完成后发指令需要修改x.
CPUA将x设置为M状态(修改)并通知缓存了x的CPUB, CPUB将本地cache b中的x设置为I状态(无效)
CPUA对x进行赋值。
CPUB发出要读取x的指令。
CPUB通知CPUA,CPUA将修改后的数据同步到主存时cache a修改为E(独享)。
CPUB从主存中重新读取x,将cache a和同步后cache b中的x设置为S状态(共享)。
以上就是MESI作用的整个过程,他保证了变量x在各CPU中的数据始终都是最新的。
存储缓存(Store Bufferes)
缓存的一致性消息传递是要时间的,这就使得他们切换时会产生延迟。当一个缓存被切换状态时其他缓存收到消息完成各自的切换并且发出回应消息这么一长串的时间中CPU都会等待所有缓存响应完成。可能出现的阻塞会导致各种各样的性能问题和稳定性问题。
比如你需要修改本地缓存中的一条信息,那么你必须将I(无效)状态通知到其他拥有该缓存数据的CPU缓存中,并且等待确认。等待确认的过程会阻塞处理器,这会降低处理器的性能。因为这个等待远远比一个指令的执行时间长的多。
为了避免这种CPU运算能力的浪费,就引入了Store Bufferes。CPU把它想要写入到主存的值写到存储缓存,然后继续去处理其他事情。当所有失效确认都接收到时,数据才会最终被提交。
失效队列
但是这么做又带来了2个风险,
1.CPU会尝试从存储缓存(Store buffer)中读取值,但它还没有进行提交。
2.保存什么时候会完成,这个并没有任何保证。
另外,执行失效也需要CPU花费时间去处理。同时,存储缓存的容量也是有限的,所以CPU有时仍然需要等待失效确认的返回。这两个操作都会使得性能大幅降低。为了应付这种情况,引入了失效队列。它们的约定如下:
对于所有的收到的失效请求,失效消息必须立刻发送。
失效并不真正执行,而是被放在一个特殊的队列中,在方便的时候才会去执行。
CPU不会发送任何消息给所处理的缓存条目,直到它处理失效信息。
内存屏障
但是,即使是这样,CPU还是不知道什么时候该进行优化,什么时候不该进行优化,最终,他将这个任务丢给了程序员,由程序员决定什么时候进行优化,这就是内存屏障。内存屏障分为2种,
读屏障Load Memory Barrier:是一条告诉CPU在执行任何的加载前,先应用所有已经在失效队列中的失效操作的指令。
写屏障Store Memory Barrier:是一条告诉CPU在执行这之后的指令之前,应用所有已经在存储缓存中的保存的指令。
现在我们再来看一下上文中的第一段代码,
void exeOnCpuA() {
value = 10;
//在更新数据之前必须将所有存储缓存(store buffer)中的指令执行完毕。
storeMemoryBarrier();
isFinsh = true;
}
void exeOnCpuB() {
while(!isFinsh);
//在读取之前将所有失效队列中关于该数据的指令执行完毕。
loadMemoryBarrier();
value == 10;
}
可以看到,内存屏障完美的解决了可见性和重排序的问题。
这里简单解释下上文中的代码为什么会发生重排序,重排序主要分为3种,
编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从Java源代码到最终实际执行的指令序列,会经历这3种重排序,
源代码-》编译器优化重排序-》指令级并行重排序-》内存系统重排序-》最终执行的指令序列
3.JMM
上文中简单介绍了内存系统是如何来禁止重排序和实现可见性的,那么java中又做了什么呢,实际上java做了2件事。
第一,java根据操作系统的内存结构抽象出了自己的一个模型,即JMM-java内存模型,JMM中的主存,也就是java内存布局中的堆,对应着操作系统的主内存,JMM中的工作内存对应着CPU的寄存器和高速缓存。
第二,java根据操作系统的内存屏障抽象出了自己的内存屏障,即
LoadLoad屏障:对于这样的语句Load1;LoadLoad;Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:对于这样的语句Store1;StoreStore;Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
LoadStore屏障:对于这样的语句Load1;LoadStore;Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:对于这样的语句Store1;StoreLoad;Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。
当JVM遇到volatile修饰的关键字时会做如下处理,
在每个volatile写操作的前面插入一个StoreStore屏障。
在每个volatile写操作的后面插入一个StoreLoad屏障。
在每个volatile读操作的后面插入一个LoadLoad屏障。
在每个volatile读操作的后面插入一个LoadStore屏障。
这样,既保证了线程可见性,也禁止了重排序。顺便说明一下,这四个屏障是JMM的规范,而不是具体的字节码指令,因为你可以看到volatile变量在字节码中只是一个标志位,通过javap反编译出来的字节码中并没有任何的屏障,只是说JVM执行引擎会在执行时插入一个对应的屏障,或者说在JIT生成机器指令的时候插一条对应逻辑的屏障。
最后,我们来看一下,JVM将volatile转换成汇编指令的时候,是如何插入屏障的,如图2,
我们发现,他在指令的前端加了一个lock,这是一个原子指令,目的就是实现一个全屏障,即读写屏障。
至此,volatile底层原理就介绍到此。