Volatile全面解析,实现原理及作用分析

在java中,volatile有如下作用,一个是禁止指令重排序(编译时指令重排序 和 CPU乱序执行)。另一个是保证多线程共享变量的内存可见性。为了讲解volatile时如何禁止指令重排序,防止CPU乱序执行,以及保证变量内存可见性。首先我们需要了解这些概念,即什么是编译时指令重排序?为什么要指令重排序?为什么CPU会乱序执行?什么是内存可见性?之后,最后来看volatile到底是如何实现的,以及它是如何实现这些功能的。

1 编译时指令重排序

在编译阶段,编译器能够对很 大一个范围的代码进行分析,能够从更大的范围内分辨出可以并发的指令,并将其尽量靠近排列让处理器更容易预取和并发执行,充分利用处理器的乱序并发功能。 所以现代的高性能编译器在目标码优化上都具备对指令进行乱序优化的能力。并且可以对访存的指令进行进一步的乱序,减少逻辑上不必要的访存,以及尽量提高 Cache命中率和CPU的LSU(load/store unit)的工作效率。所以在打开编译器优化以后,看到生成的汇编码并不严格按照代码的逻辑顺序是正常的。和处理器一样,如果想要告诉编译器不要去对某些 指令乱序优化,也要通过一些方式来告诉编译器。通常可以通过volatile关键字来告诉编译器对哪些变量的访问操作不能进行重排序优化。

2 CPU乱序执行

即使在编译阶段,编译生成的指令是顺序的。在CPU执行指令的时候,也会存在乱序执行的问题,其中造成乱序执行的一个原因,得从CPU的指令流水技术说起。关于指令流水,详细的可以去看 CPU的结构和功能——指令流水及中断系统,下面简单介绍一下指令流水:
一条指令在执行的时候,会分为很多个周期,如:取指周期,间址周期,执行周期,中断周期等等。首先我们假设指令的执行分为取指周期和执行周期两个阶段,如果指令串行执行:
在这里插入图片描述
上图的执行过程是完全的串行操作,一条指令解释过程执行结果以后,再开始下一条指令的开始过程,那么实际上,如果我们再控制器实现过程中把取指部件和执行部件完全的独立开进行设计的话,那么在取指阶段只会用到取指令部件,那么执行指令阶段我们只会用到执行指令部件,这样的话,当上图中第二条指令的取指令部件运行的时候,其执行指令部件是空闲的。如果我们采用这种结果这种方式去解释一条指令的话,总有一个部件是空闲的,控制器的利用率非常低。

为了加快指令执行速度,充分利用CPU,就就会采用流水线的方式去执行指令:
在这里插入图片描述
上图为指令二级流水, 取指和执行在时间上是重叠的,使得取指部件和执行部件都都得到了充分的利用。整个指令周期就会减半,理想情况下速度会提升一倍,但只是理想情况,下面看看影响指令流水效率加倍的因素。这只是一个指令二级流水,如果是指令六级流水,效率提升的就更大:
在这里插入图片描述
上图是一个六级流水线,横轴表示时间,纵轴表示指令,这个流水线被分成了六级,这六级的功能分别是:取指令,指令译码,形成操作数地址,取操作数,执行,结果的写回,所谓结果的写回是指把运算结果写回到指令的寄存器中,或者是写回到给定的内存单元中。

上图中一共给出了9条指令,假设六级流水线每段时间都是相同的,这样的话,我们采用串行方式完成一条指令,一条指令就需要6个时间单位,9条指令就需要54个时间单位。如果采用流水线方式只是用了14个时间单位,当然是假设9条指令不冲突并且没有条件转移指令的情况下。

在流水线的基础上,对指令的解释速度进一步提高的方法。前面讲解的是用一条流水线如何提高指令的解释速度,利用这个思路,尽心进一步的扩展,如果我们使用多条流水线,有几条指令同时进入到不同的流水线中进行解释,这样的话速度会被进一步的提高。这种方法就是超标量技术。
超标量技术就是每个时钟周期内,多条独立指令进入到不同的流水线中执行。需要配置多条流水线,多个功能部件。
在这里插入图片描述
如上图中,有三条流水线,在每个时钟周期,可以有三条独立的指令分别进入每一个流水线执行,这样,指令的解释速度和用一条流水线相比,最高加速比可以达到三倍。利用这种方法,通常情况下可以在执行当中不去调整指令的执行顺序,指令的执行顺序在编译过程中采用优化技术,把多条可以并行执行的,独立的指令挑选出来,搭配起来,让他们同时进入到三条流水线执行,这种方法就是超标量的方法。

有了超标量技术,在一个指令周期内能并发执行多条指令,处理器从Cache预取了一批指令后,就会分析找出那些互相没有关联可以并发执行的指令,然后送到几个独立的执行单元进行并发执行。就有可能将多条无关联指令分别送到两个算术单元去同时执行。

通常来说访存指令(由LSU单元执行)所需要的指令周期可能很多(可能要几十甚至上百个周期),而一般的算术指令通常在一个指令周期就搞定。所以有的可能代码中的访存指令耗费了多个周期完成执行后,其他几个执行单元可能已经把后面有多条逻辑上无关的算术指令都执行完了,这就产生了乱序。

另外访存指令之间也存在乱序的问题。高级的CPU可以根据自己Cache的组织特性,将访存指令重新排序执行。访问一些连续地址的可能会先执行,因为这时候Cache命中率高。有的还允许访存的Non-blocking,即如果前面一条访存指令因为Cache不命中,造成长延时的存储访问时,后面的 访存指令可以先执行以便从Cache取数。这也就造成了指令乱序执行的问题。

当然,不是所有的指令,CPU都能对其进行乱序优化,对于前后存在依赖关系的指令,CPU是不能改变其执行次序的。

3 内存可见性

内存可见性,简单说就是,当有多个CPU(多线程)共享内存的时候,当一个线程对内存中的变了做了修改,而另一个线程中不能够立即知道这种修改。为什么会出现这种情况,就需要从硬件层面开始说起。

3.1 CPU高速缓存

我们都知道,CPU在执行指令的时候,需要访存操作,需要与内存交互,如读取运算数据、存储运算结果等,这个I/O操作是很难消除的(无法仅靠寄存器来完成所有运算任务)。由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。
在这里插入图片描述

3.2 缓存一致性问题

基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(Main Memory),如上图所示。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?这就是缓存不一致的问题。

3.3 总线锁

为了解决多CPU共享内存时,保证多个CPU的缓存数据的一致性,最初,操作系统采用总线锁的机制。CPU和内存之间的通过总线连接通信,所谓总线锁,就是当一个CPU通过总线访问内存的时候,其在总线上发出一个LOCK#信号,标志其独占总线,即独占内存,其他处理器就不能通过总线读写内存,也就是阻塞了其他CPU的访存操作,使该处理器可以独享此共享内存。

3.4 缓存一致性协议

总线锁确实能解决缓存不一致的问题,但是缺点也很明显,总线锁定把CPU和内存的通信给锁住了,使得在锁定期间,其他处理器不能操作其他内存地址的数据,严重的降低了CPU和内存的利用率,所以后来的CPU都提供了缓存一致性机制,如MESI协议。

MESI代表了缓存行的四种状态,CPU中每个缓存行(caceh line)使用额外的两位(bit)表示当前缓存行处于哪种状态。

M: 被修改(Modified)
该缓存行只被缓存在该CPU的缓存中,并且是被修改过的(dirty),即与主存中的数据不一致,该缓存行中的内容需要在未来的某个时间点(允许其它CPU读取主存中相应内存之前)写回(write back)主存。当被写回主存之后,该缓存行的状态会变成独享(exclusive)状态。

E: 独享的(Exclusive)
该缓存行只被缓存在该CPU的缓存中,它是未被修改过的(clean),与主存中数据一致。该状态可以在任何时刻当有其它CPU读取该内存时变成共享状态(shared)。同样地,当CPU修改该缓存行中内容时,该状态可以变成Modified状态。

S: 共享的(Shared)
该状态意味着该缓存行可能被多个CPU缓存,并且各个缓存中的数据与主存数据一致(clean),当有一个CPU修改该缓存行中,其它CPU中该缓存行可以被作废(变成无效状态(Invalid))。

I: 无效的(Invalid)
该缓存是无效的(可能有其它CPU修改了该缓存行)。

至于MESI状态转换:
在MESI协议中,每个Cache的Cache控制器不仅知道自己的读写操作,而且也监听(snoop)其它Cache的读写操作。每个Cache line所处的状态根据本核和其它核的读写操作在4个状态间进行迁移。
在这里插入图片描述
Local Read:表示本内核读本Cache中的值
Local Write:表示本内核写本Cache中的值
Remote Read:表示其它内核读其Cache中的值
Remote Write:表示其它内核写其Cache中的值
箭头表示本Cache line状态的迁移,环形箭头表示状态不变。

当内核需要访问的数据不在本Cache中,而其它Cache有这份数据的备份时,本Cache既可以从内存中导入数据,也可以从其它Cache中导入数据,不同的处理器会有不同的选择。MESI协议为了使自己更加通用,没有定义这些细节,只定义了状态之间的迁移,下面的描述假设本Cache从内存中导入数据。

当前状态 事件 行为 下一个状态
I(Invalid) Local Read 如果其它Cache没有这份数据,本Cache从内存中取数据,Cache line状态变成E;
如果其它Cache有这份数据,且状态为M,则将数据更新到内存,本Cache再从内存中取数据,2个Cache 的Cache line状态都变成S;
如果其它Cache有这份数据,且状态为S或者E,本Cache从内存中取数据,这些Cache 的Cache line状态都变成S
E/S
Local Write 从内存中取数据,在Cache中修改,状态变成M;
如果其它Cache有这份数据,且状态为M,则要先将数据更新到内存;
如果其它Cache有这份数据,则其它Cache的Cache line状态变成I
M
Remote Read 既然是Invalid,别的核的操作与它无关 I
Remote Write 既然是Invalid,别的核的操作与它无关 I
E(Exclusive) Local Read 从Cache中取数据,状态不变 E
Local Write 修改Cache中的数据,状态变成M M
Remote Read 数据和其它核共用,状态变成了S S
Remote Write 数据被修改,本Cache line不能再使用,状态变成I I
S(Shared) Local Read 从Cache中取数据,状态不变 S
Local Write 修改Cache中的数据,状态变成M;
其它核共享的Cache line状态变成I
M
Remote Read 状态不变 S
Remote Write 数据被修改,本Cache line不能再使用,状态变成I I
M(Modified) Local Read 从Cache中取数据,状态不变 M
Local Write 修改Cache中的数据,状态不变 M
Remote Read 这行数据被写到内存中,使其它核能使用到最新的数据,状态变成S S
Remote Write 这行数据被写到内存中,使其它核能使用到最新的数据,由于其它核会修改这行数据,状态变成I I

在这里插入图片描述

3.5 store buffer

说了缓存一致性协议,好像就能够解决问题了,但是,在这里你会发现,又有新的问题出现了。MESI协议中:当cpu0写数据到本地cache的时候,如果不是M或者E状态,需要发送一个invalidate消息给cpu1,只有收到cpu1的ack之后cpu0才能继续执行,在这个过程中cpu0需要等待,这大大影响了性能。于是CPU设计者引入了store buffer,这个buffer处于CPU与cache之间。
在这里插入图片描述
在cpu和cache之间引入store buffer之后,当我们要进行写cache操作的时候,不直接将数据写cache,先将数据写入store buffer,store buffer 在某个时刻(storebuffer存储满了,或者通过内存屏障强制刷新时等等)就会完成一系列的同步行为(发出invalid—并等待ack—接受ack—写cache)。因为此时数据还没有写入缓存,所以不需要等待ack返回,此时的缓存其实还是一致的。当收到ack之后可以把store buffer中的数据写入cache,因为其他CPU中的缓存数据已经失效,此时写cache,依旧保持是一致的。这样CPU就不会因为等待响应而影响性能。

加入storebuffer后,确实解决了CPU等待响应影响性能的问题,但是依旧存在问题:

问题一:storebuffer和cache数据不一致:

int a = b = 0;
public void fun(){
	a = a + 1;
	b = a + 1;
}

上述代码中,cpu执行a = a + 1;后,此时a = 1这个值被放到storebuffer里了,然后继续执行b=a+1,这时候cpu的cacheline中保存的a还是原来的0.这个时候就会导致计算出的b的值是不对的。因为我们在storebuffer里和cache中的a是不一致的,所以导致这种错误的结果。因此CPU设计者通过使用"store Forwarding"的方式解决这个问题,就是在执行取数的时候,先去storebuffer中查找对应的数据,如果查到就使用storebuffer中的最新值。
问题二:

int a = b = 0;
//CPU0
public void fun0(){
	a = a + 1;
	b = b + 1;
}
//CPU1
public void fun1(){
	while(b == 0){
		continue;
	}
	System.out.print(a);
}

假设有如上程序,现在CPU0执行fun0,CPU1执行fun1。并且此时,在CPU0中,由于之前程序执行的原因,现在a的缓存状态是S, b的缓存状态是E。两个CPU按照如下顺序执行(理解下面的执行,必须要先理解前面讲到的MESI协议):
CPU0:执行a = a + 1,在计算出a+1的结果后,需要将a+1的值写回Cache,但是因为现在有了StoreBuffer,a的值不会立即写入缓存,会先写StoreBuffer。接下来CPU0继续执行b = b + 1,一样会先将结果写StoreBuffer。
CPU0:接下来,CPU0需要将StoreBuffer的值同步回Cache中,由于a的状态是S,需要发送失效消息并且等待ack后才能同步回Cache。但是因为b的状态是E,不需要发送失效消息等待ack, 直接就同步回Cache了,并且将b的缓存行状态修改为M。所以此时在CPU0中,对于aCache中a的值是0,starebuffer中a的值是1;对于b,storebuffer中的值已经同步回Cache,cache中的b就是最新值1。
CPU1:CPU1开始执行,先判断b == 0,因为CPU1中没有b的值,CPU1会先看其他的CPU的Cache中是否有b的值,发现CPU0的Cache中有b的值,并且b的缓存行状态为M,所以首先CPU0要先Cache中将b的值写回主存。然后CPU1从主存中获取b的值,并且CPU0和CPU1缓存中b的缓存行状态都变成S状态。并且CPU0和CPU1的Cache中b的值都是1。
CPU1:CPU1执行打印a的值,因为CPU1的Cache中没有a,所以和b一样先判断其他的CPU的Cache中有没有a,发现CPU0的缓存中有a。因为CPU0的a的最新修改的值还在Storebuffer中没有同步回Cache,所以在CPU0的cache中a的缓存行状态还是S。所以CPU1直接从内存中读取a的值,最终打印出的a的值就是0。

好了,说到这,问题也就出现了,就是在多线程的情况下,可能线程1对于线程0的执行结果顺序是有依赖的。就如上面的程序CPU0中顺序执行a = a + 1; b = b + 1,但是,最终的结果是b的结果先同步回Cache,a的值还在StoreBuffer中,导致了在CPU1中虽然监听到了b的最新值,但是最终打印出的a的结果却不是最新的。
这种问题,对于CPU本身来说,是无法解决的,因为对于CPU来说,他执行的时候,不会去关心其他的CPU的指令执行的顺序问题。这种情况,这种顺序问题,只有编写程序的人最清楚,就需要编程人员去解决,所以CPU就提供了memory_barrier。

3.5 内存屏障

3.5.1 内存屏障与StoreBuffer

内存屏障的一个作用就是为了解决上面这种,storebuffer中的数据无法及时刷新到Cache导致的问题。加入内存屏障后,当CPU执行完a = a + 1后,接下来执行smp_mb(),这个smp_mb()的作用就是要将此刻CPU的StoreBuffer中的内容强制刷新到Cache后(当然还是需要发送失效消息并等待ACK),才能继续执行b = b + 1。修改代码如下:

int a = b = 0;
//CPU0
public void fun0(){
	a = a + 1;
	smp_mb(); // memory barrier
	b = b + 1;
}
//CPU1
public void fun1(){
	while(b == 0){
		continue;
	}
	System.out.print(a);
}

如上代码,在fun0中加入内存屏障,CPU0执行完内存屏障指令后,StoreBuffer中的a会被刷新回Cache中,接下来执行b = b + 1。在CPU1中,执行后,最终打印出的结果就是a的最新值1。

3.5.2 内存屏障与invalidate queue

但是这里依旧存在问题,还是如下代码:

int a = b = 0;
//CPU0
public void fun0(){
	a = a + 1;
	smp_mb(); // memory barrier
	b = b + 1;
}
//CPU1
public void fun1(){
	while(b == 0){
		continue;
	}
	System.out.print(a);
}

现在CPU0执行fun0,CPU1执行fun1。并且此时,在CPU0中,由于之前程序执行的原因,现在a的缓存状态是S, b的缓存状态是E。在CPU1中,a的缓存状态是S。
当a的值被修改,并且执行smp_mb将a的值从storebuffer写回到Cache中的时候,需要发送失效消息给CPU1。因为CPU1此时可能会接受很多的失效消息,并来不及处理,这些来不及处理的消息又不能丢弃,这个时候该怎么办呢?所以CPU内部就提供了失效队列invalidate queue。接受到的失效消息,CPU并不会及时去处理,会将消息直接放在失效队列中,之后再去处理。
好,有了失效队列,又会出现问题,当CPU0将a的失效消息发送给CPU1的时候,CPU1将失效消息放入缓存队列一直没有及时处理。此时CPU0中的a已经改变了,但是因为CPU1对失效队列的处理不及时导致CPU1不能及时拿到最新的a值。虽然CPU0中有内存屏障不会导致程序不会得出错误的结果,但是会导致CPU0一直阻塞,导致CPU1一直不能执行while循环。
对于这种问题,内存屏障依旧可以解决:

int a = b = 0;
//CPU0
public void fun0(){
	a = a + 1;
	smp_mb(); // memory barrier
	b = b + 1;
}
//CPU1
public void fun1(){
	while(b == 0){
		continue;
	}
	smp_mb(); // memory barrier
	System.out.print(a);
}

内存屏障的另一个作用就是,可以刷新invalidate queue及时处理,处理完之后才继续向下执行。所以内存屏障同时刷新storebuffer和invalidate queue。

3.5.3 全屏障,读屏障,写屏障

但是在上面的场景中看,fun0中不需要刷新失效队列,而fun1中不需要刷新storebuffer。所以cpu提供了全屏障,读屏障,写屏障。
全屏障:同时刷新storebuffer和invalidate queue。
读屏障:只刷新invalidate queue。
写屏障:只刷新storebuffe。
所以在上面的代码中,我们只需要在fun0中加入写屏障,在fun1中加入读屏障即可。

说到内存屏障,这里再说一点,还是对于上面的代码,对于fun0如果不加内存屏障,还有一种情况下会出现问题,前面也讲到过,就是CPU在具体执行指令的时候,对于前后没有依赖关系的指令,CPU会允许乱序执行。所以可能出现的一种情况就是先执行了b = b + 1;,再执行a = a + 1;这种情况下,CPU1及时得到了b的最新值,但是打印出的a的值却是不对的。而内存屏障还有一个作用就是说可以防止指令乱序执行,就是CPU在执行指令优化的时候,不能将内存屏障后面的指令优先于前面的指令指令。

3.5.4 总结

1、不能将内存屏障后面的指令优先于前面的指令执行(禁止CPU指令连续执行)。
2、刷新storebuffer和invalidate queue(写屏障和读屏障)。
总结也就是,必须等到内存屏障前面的指令都执行完,并且刷新完storebuffer或者invalidate queue(根据不同的屏障执行不同的刷新)才能继续往下执行。

4 Java内存模型

“内存模型”一词,可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。不同架构的物理机器可以拥有不一样的内存模型,而Java虚拟机也有自己的内存模型,并且这里介绍的内存访问操作与硬件的缓存访问操作具有很高的可比性。

上面这段话是《深入理解JVM》中的原话,对上面这段话,我个人的理解是对于不同架构的物理机器,对内存或高速缓存进行读写访问的过程是不一样的,内存模型是对这种访问的一种抽象,所以,不同架构的物理机器有不同的内存模型。所以对于不同架构的物理机器,操作内存/高速缓存的读写的规则协议是不一样的,即编写操作的指令也是不一样的。对java,要通过JVM做到一处编写到处运行,所以不能像C那样直接依赖于硬件架构,所以JVM定义了自己的内存模型,而JVM内存模型就是对不同的底层各种硬件架构的一层抽象。编写java程序的时候,我们只关心JVM内存模型,java指令在编译成最终的硬件执行指令的时候,JVM会将java代码根据不同的硬件结构转换成操作这种硬件内存的特定的执行指令。

正如《深入理解JVM》中所说:Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。在此之前,主流程序语言(如C/C++等)直接使用物理硬件和操作系统的内存模型,因此,会由于不同平台上内存模型的差异,有可能导致程序在一套平台上并发完全正常,而在另外一套平台上并发访问却经常出错,因此在某些场景就必须针对不同的平台来编写程序。

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理硬件时的主内存名字一样,两者也可以互相类比,但此处仅是虚拟机内存的一部分)。每条线程还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类比),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如图所示。
在这里插入图片描述
看到这儿,就能理解了,所谓的java内存模型,就是对不同硬件架构内存访问的一个抽象和操作定义,在不同的硬件平台上,可能主内存,工作内存对应到的具体的硬件结构也都是不同的。而最终具体如何操作主内存和工作内存之间的交互,依旧依赖于不同的硬件结构。只是java内存模型屏蔽掉了这些差异,或者说,我们使用java内存模型,最终编译转换成不同平台的执行指令,也由JVM根据不同的平台就行转换。

5 volatile实现

对问文章开头将的Volatile关键字的作用,这里重述一遍:
1、禁止java代码编译时重排序。
2、禁止CPU的乱序执行。
3、保证变量内存可见性。
那volatile是如何做到的呢?其实都是通过内存屏障实现的。具体我们看一下volatile在JVM层面的实现,如有如下java程序:

public class VolatileTest {
    public volatile static int a = 0;
    public static int b = 2;

    public static void main(String[] args)  {
        a = 9;
        b = 4;
    }
}

通过javap -v 命令查看相应的字节码:
在这里插入图片描述
通过对字节码的分析,会发现,对于加了volatile关键字的变量,在字节码中会打上一个标识ACC_VOLATILE,那这个标识有什么用呢?

在JVM源码中的accessFlags.hpp中定义了这些标识,并提供了相应的方法用于判断哪些字段被打标了:
在这里插入图片描述
所以,可以通过is_volatile方法,我们可以判断出哪些字段被volatile修饰了,那这个方法在什么时候会被用到呢?前面说了,volatile的这些作用是通过内存屏障实现的,所以判断应该是在解释执行java字节码的时候,使用变量的时候,加入了相应的内存屏障。好,我们验证一下,找到执行JVM中操作变量的指令,这些指令定义在bytecodeInterpreter.cpp中,他们是_getstatic(获取变量),以及_putstatic(修改变量),先看_putstatic的实现,在_putstatic实现中,会找到这样一段代码:
在这里插入图片描述
很明显能看到,确实在对变量赋值后,加入了内存屏障。这里的内存屏障是JVM层名定义的内存屏障,至于,这个内存屏障是如何实现的?我们下面再详细说明,这里我们再看看_getstatic,既然修改volatile变量值得时候加入了内存屏障,那获取volatile变量值得时候,有没有加入内存屏障呢?_getstatic有实现逻辑如下:
在这里插入图片描述
可以看到,_getstatic的时候,确实也会加内存屏障,但是是有条件的,这个条件大概是什么意思呢?源码中没有明确的备注,我也不太清楚是什么意思。但是我可以猜测一下,前面讲到,在多CPU共享变量的时候,发送失效消息后,只有等到确认ack后,才写缓存,这样只需要在修改数据的时候加入写屏障,就可保证了缓存的一致,保证最终的结果不会出错。设想如果有这样一种情况情况,对于有些CPU架构,发送失效消息后,就可以直接写Cache。对于接受失效消息的一方,如果不及时刷新失效队列就会导致出现错误结果。当然,这点只是个人的猜想。我在现在在我的windows的internx86的电脑上运行,最终生成的汇编代码中,使用volatile变量是没有加内存屏障的。

下面我们看一下,最终生成的汇编,是否是真的加入了内存屏障,如有如下代码:

public class VolatileTest {
    public volatile static int a = 0;
    public static int b = 2;
    public static void main(String[] args)  {
        if (a == 0){
            a = 9;
        }
        b = 4;
    }
}

最终生成的汇编代码如下:
在这里插入图片描述
会发现,编译成汇编代码之后,对于对volatile变量的修改操作后,多出了一行lock addl $0x0,(%rsp)指令,这行lock addl $0x0,(%rsp)指令,就相当于加了一个内存全屏障。并且,可以看出,在我本地java代码最终生成的汇编,值在修改volatile变量的时候增加了内存屏障,使用的时候并没有增加屏障。这也就是说,对volatile变量读操作其实和普通的变量没有区别(除了前面提到的那种在使用的时候也加屏障特殊情况),只有在写操作的时候,因为加了内存屏障,会对性能有一定的影响。

6 JVM内存屏障

前面讲,java内存模型是对不同底层架构内存交互的一种抽象,而内存屏障是一种解决内存内存之间交互带来的问题的手段,所以,显然,JVM内存屏障也是对底层不同的硬件CPU架构提供的内存屏障的一种抽象。先看看JVM内存屏障的定义:

在JVM源码中,能够找到这种定义,具体在orderAccess.hpp中:
在这里插入图片描述
loadload(Load1,Loadload,Load2): 确保Load1所要读入的数据能够在被Load2和后续的load指令访问前读入。如果两个Load有依赖关系,就不需要特定去加这种屏障,因为CPU本身会保证顺序性。若没有依赖关系又想保证其顺序性,可以通过这个屏障实现。
storestore(Store1,StoreStore,Store2): 确保Store1的数据在Store2以及后续Store指令操作相关数据之前对其它处理器可见。这个和前面讲解storebuffer时候举得例子一样,对CPU0对a和b赋值,为了让a的值能够保证在b之前对其他CPU的可见性,所以加入了写屏障。
loadstore( Load1; LoadStore; Store2): 确保Load1的数据在Store2和后续Store指令被刷新之前读取。在等待Store指令可以越过loads指令的乱序处理器上需要使用LoadStore屏障。
storeload(Store1; StoreLoad; Load2): 确保Store1的数据在被Load2和后续的Load指令读取之前对其他处理器可见。StoreLoad屏障可以防止一个后续的load指令使用的Store1的数据不是另一个处理器在相同内存位置写入一个新数据。它相当于全屏障,开销也是最昂贵的。因为正如前面所讲,既要刷新storeBuffer,又要刷新失效队列。

上面讲的,都是JVM层面定义的内存屏障,他们最终的实现,还是依赖于不同的硬件提供的内存屏障功能,随意对于不同的底层平台有不同的实现:
在这里插入图片描述
下面我们看一下在linux_x86中的实现:

// A compiler barrier, forcing the C++ compiler to invalidate all memory assumptions
static inline void compiler_barrier() {
  __asm__ volatile ("" : : : "memory");
}

inline void OrderAccess::loadload()   { compiler_barrier(); }
inline void OrderAccess::storestore() { compiler_barrier(); }
inline void OrderAccess::loadstore()  { compiler_barrier(); }
inline void OrderAccess::storeload()  { fence();            }

inline void OrderAccess::acquire()    { compiler_barrier(); }
inline void OrderAccess::release()    { compiler_barrier(); }

inline void OrderAccess::fence() {
  if (os::is_MP()) {
    // always use locked addl since mfence is sometimes expensive
#ifdef AMD64
    __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
    __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
  }
  compiler_barrier();
}

可以很清楚的看到,在linux_x86中,loadload,storestore,loadstore只能禁止编译时重排序,storeload加入了内存屏障(前面讲到的volatile编译后会生成一个 lock; addl $0,0(%%rsp)指令,这个指令就是linux_x86的内存屏障实现),并且禁止了编译时重排序。

我们再看看linux_aarch64对内存屏障的实现如下:

inline void OrderAccess::loadload()   { acquire(); }
inline void OrderAccess::storestore() { release(); }
inline void OrderAccess::loadstore()  { acquire(); }
inline void OrderAccess::storeload()  { fence(); }

inline void OrderAccess::acquire() {
  READ_MEM_BARRIER;
}

inline void OrderAccess::release() {
  WRITE_MEM_BARRIER;
}

inline void OrderAccess::fence() {
  FULL_MEM_BARRIER;
}

可以很清楚的看到,loadload,loadstore使用的是读内存屏障,storestore是写内存屏障,storeload则是全屏障,这个和我们前面讲的CPU内存屏障的实现就一一对应起来了。

7 volatile作用总结

1,保证编译时不被重排序: 如果cpu只有单核,并且CPU保证不乱序执行。那这个时候,我们需要java语言层面的volatile的支持吗?当然是需要的,因为在语言层面编译器和虚拟机为了做性能优化,可能会存在编译时指令重排的可能,而volatile给我们提供了一种能力,我们可以告诉编译器,什么可以重排,什么不可以。

2、保证store buffer和invalid queue中的数据及时被刷新和处理: 假设更进一步,假设java语言层面不会对指令做任何的优化重排,那在多核cpu的场景下,我们还需要volatile关键字吗?答案仍然是需要的,因为 MESI只是保证了多核cpu的独占cache之间的一致性,但是cpu的并不是直接把数据写入 cache的,正如前面所讲,中间还可能有store buffer,invalid queue的存在。因此,有MESI协议远远不够。我们需要volatile提供内存屏障的功能,保证被修改的数据强制从store buffer刷入Cache,强制CPU处理invalid queue中的失效消息,保证数据的立即可见。

3、保证CPU对指令不乱序执行: 我们再来做最后一个假设,假设编译时不重排序,也没有store buffer 和invalid queue,数据直接写cache,这个时候MESI协议就能够保证缓存一致性了,那现在可以抛弃volatile了吧?还是不可以,CPU执行指令的时候,它们只会保证指令之间有比如控制依赖,数据依赖,地址依赖等等依赖关系的指令间提交的先后顺序,而对于完全没有依赖关系的指令,比如x=1;y=2,它们是不会保证执行提交的顺序的,所以必须使用了volatile,保证CPU不能对指令乱序执行。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章