当前位置:首页>编程知识库>后端开发知识>面试官:说一下 volitile 的内存语义,底层如何实现
面试官:说一下 volitile 的内存语义,底层如何实现
阅读 2
2021-07-31

介绍

volatile主要两个特性,可见性和有序性。

可见性是使用lock前缀实现,lock前缀可实现嗅探机制,每个处理器都会有一个嗅探机制,去看自己的工作内存中的数值与主内存中那个的是否一致,不一致,会将自己的工作内存中的数值设置成无效,同时会从主内存中读取数值更新到自己的工作内存中。

有序性是通过内存屏障,禁止指令重排,内存屏障还可以强制刷出各种CPU的缓存数据保证可见性

volatile特性

把对volatile变量的单个读、写,看出是使用同一个锁对这些单个读、写做了同步,比如:
public class VolatileFeaturesExample {

 volatile long vl = 0L;

 public void set(long l) {
  vl = l;
 }

 public void getAndIncrement() {
  vl  ;// 复合(多个)volatile变量的读/写
 }

 public long get() {
  return vl;// 单个volatile变量的读
 }

}
等价于
class VolatileFeaturesExample1 {
 long vl = 0L;

 public synchronized void set(long l) {
  vl = l;
 }

 public synchronized long get() {
  return vl;
 }

 public void getAndIncrement() {
  long temp = get();
  temp  = 1L;
  set(temp);

 }
}
因为锁happens-before规则保证释放锁和获取锁两个线程之间的内存可见性,由可以推出,volatile变量的读总能看到对这个volatile变量最后的写入,而锁也决定了临界区代码的执行具有原子性,也就说,volatile变量同样对读写具有原子性
由上得出,volatile变量具有下列特性:

可见性,volatile变量的读总能看到对这个volatile变量最后的写入

原子性,对任意单个volatile变量的读写具有原子性,但volatile 复合操作是不具有原子性的

volatile写/读建立的happens before关系

从内存的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果
public class VolatileExample {
 int a=0;
 volatile boolean flag=false;
 
 public void writer(){
  a=1;   //1
  flag=true;//2
 }
 public void reader(){
  if(flag){//3
   int i=a;//4
   
  }
 }
}

根据程序次序规则,1 happens before 23 happens before 4

根据volatile规则,2 happens before 3

根据happens before的传递规则,1 happens before 4
1)当执行写入volatile时,也就是2JMM会将该线程A对应本地内存更新过的共享变量刷新到主内存,那到共享变量a对其他线程是可见的,也就读到a就是想要的1,而不是0
2)当读一个volatile变量时,JMM会把该线程B对应的本地内存置为无效,线程直接从主内存读取共享变量,同时该读操作会把本地内存的值更为与主内存的值统一

volatile内存语义的实现

下面看看JMM如何实现volatile写/读的内存语义
重排序分为编译器重排序和处理器重排序,JMM会限制这两种类型的重排序类型来保证volatile的内存语义

第二个操作是volatile写时,第一个操作不管是什么,都不能重排序

第一个操作是volatile读时,第二个操作不管是什么,都不能重排序

第一个操作是volatile是写,第二个操作是volatile是读,不能重排序
JMM内存屏障插入策略:(Load:加载(读)、Store:保存(写),屏障名称就可以看出读写的先后顺序)

在每个volatile写操作前插入StroreStore屏障

在每个volatile写操作前插入StroreLoad屏障

在每个volatile读操作前插入LoadLoad屏障

在每个volatile读操作前插入LoadStore屏障

volatile写操作

上面的StroreStore屏障保证了在volatile写之前,其前面的所有普通写操作对任意处理器都是可见的,因为StroreStore屏障保障所有的普通写在本地内存的数据在voltile写之前刷新到主内存

volatile写后面的StoreLoad屏障,作用是避免

volatile写与后面可能有的volatile读/写操作重排序

volatile读操作

下面为代码示例
public class VolatileBarrierExample {
 int a;
 volatile int v1 = 1;
 volatile int v2 = 2;

 void readAndWrite() {
  int i = v1;// 第一个volatile读
  int j = v2;// 第二个volatile读
  a = i   j;// 普通写
  v1 = i   1;// 第一个volatile写
  v2 = j * 2;// 第二个volatile写
 }
}
编译器生成字节码过程
最后的StoreLoad屏障不能省略,因为编译器无法确定第二个volatile写后是否会有volatile读或写,保守起见,都会在该处加一个StoreLoad屏障
JVM中定义的内存屏障如下,JDK1.7的实现

loadload屏障(load1loadloadload2

loadstore屏障(loadloadstorestore
这两个屏障都通过acquire()方法实现
volatileCAS底层实现都用CPUlock指令,他们有什么不同?
首先lock只是前缀,lock后面一定有跟命令,具体看后面的命令
  1. volatile没有保证原子性,volatile的实现需要内存屏障,由于lock前缀的指令具有内存屏障的效果,这里的lock addl $0x0,(%rsp)是用来作内存屏障使用的。
storeload屏障,完全由下面这些指令实现
__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
这里多了两个指令,一个lock,一个addl
lock指令的作用是:在执行lock后面指令时,会设置处理器的LOCK#信号(这个信号会锁定总线,阻止其它CPU通过总线访问内存,直到这些指令执行结束),这条指令的执行变成原子操作,之前的读写请求都不能越过lock指令进行重排,相当于一个内存屏障。

CAS保证原子性,CAS的实现用了lock cmpxchg指令。cmpxchg指令涉及一次内存读和一次内存写,需要lock前缀保证中间不会有其它cpu写这段内存。

lock只是前缀。cas 指定了lock后面的指令必须是交换,volatile lock后面的指令要看编译时的实际情况。

CAScmpxchg指令加lock前缀,是为了cmpxchg指令在多核处理器情况能保证原子性

lock前缀的具体作用

Lock指令区分两种实现方法

早期 - Pentium时代(锁总线),在Pentium及之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存,很显然,这个开销很大。

现在 - P6以后时代(锁缓存),在新的处理器中,Intel使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低lock前缀指令的执行开销。
这里锁缓存(Cache Locking)就是用了Ringbus MESI协议。
MESI大致的意思是:若干个CPU核心通过ringbus连到一起。每个核心都维护自己的Cache的状态。如果对于同一份内存数据在多个核里都有cache,则状态都为Sshared)。
一旦有一核心改了这个数据(状态变成了M),其他核心就能瞬间通过ringbus感知到这个修改,从而把自己的cache状态变成IInvalid),并且从标记为Mcache中读过来。同时,这个数据会被原子的写回到主存。最终,cache的状态又会变为S
这相当于给cache本身单独做了一套总线(要不怎么叫ring bus),避免了真的锁总线。
我们可以发现MESIF协议大大降低了读操作的时延,没有让写操作更慢,同时保持了一致性。
但是在多核情况下,就不是这么简单的了。每个cpu都有自己的缓存,每个cpu最终看到的数据,就是不在缓存中的主存 已在缓存中的数据。所以假设多cpu的情况下,某个cpu更新了某个cache line中的值又没有回写到内存中,那么其它cpu中的数据其实已经是旧的已作废的数据,这是不可接受的。
为了解决这种情况,引入了缓存一致性协议,其中用的比较多的称为MESI,分别是cache line可能存在的四种状态:

Modified。 数据已读入cache line,并且已经被修改过了。该cpu拥有最新的数据,可以直接修改数据。当其它核心需要读取相应数据的时候,此数据必须刷入主存。

Exclusive。 数据已读入cache line,并且只有该cpu拥有它。该cpu可以直接修改数据,但是该数据与主存中数据是一致的。

Shared。 多个cpu共享某内存的数据,可能由Exclusive状态改变而来,当某个cpu需要修改数据的时候,必须提交RFO请求来获取数据的独占权,然后才能进行修改。

Invalid。 无效的cache line,和没有载入一样。当某个cpucache line处于- - Shared状态,别的cpu申请写的时候,接收了RFO请求后会变为此种状态。
这四种状态可以不断的改变,有了这套协议,不同的cpu之间的缓存就可以保证数据的一致性了。但是依赖这套协议,会大大的降低性能,比如一个核心上某个Sharedcache line打算写,则必须先RFO来获取独占权,当其它核心确认了之后才能转为Exclusive状态来进行修改,假设其余的核心正在处理别的事情而导致一段时间后才回应,则会当申请RFO的核心处于无事可做的状态,这是不可接受的。
于是在每个cpu中,又加入了两个类似于缓存的东西,分别称为Store bufferInvalidate queue
Store buffer用于缓存写指令,当cpu需要写cache line的时候,并不会执行上述的流程,而是将写指令丢入Store buffer,当收到其它核心的RFO回应后,该指令才会真正执行。
Invalidate queue用于缓存Shared->Invalid状态的指令,当cpu收到其它核心的RFO指令后,会将自身对应的cache line无效化,但是当核心比较忙的时候,无法立刻处理,所以引入Invalidate queue,当收到RFO指令后,立刻回应,将无效化的指令投入Invalidate queue
这套机制大大提升了性能,但是很多操作其实也就异步化了,某个cpu写入了东西,则该写入可能只对当前CPU可见(读缓存机制会先读Store buffer,再读缓存),而其余的cpu可能无法感知到内存发生了改变,即使Invalidate queue中已有该无效化指令。
为了解决这个问题,引入了读写屏障。写屏障主要保证在写屏障之前的在Store buffer中的指令都真正的写入了缓存,读屏障主要保证了在读屏障之前所有Invalidate queue中所有的无效化指令都执行。有了读写屏障的配合,那么在不同的核心上,缓存可以得到强同步。往期:250期面试
所以在锁的实现上,一般lock都会加入读屏障,保证后续代码可以读到别的cpu核心上的未回写的缓存数据,而unlock都会加入写屏障,将所有的未回写的缓存进行回写。
参考《深入理解Java内存模型》



感谢阅读,希望对你有所帮助 :)
来源:blog.csdn.net/jyxmust/article/details/76946283


以上数据来源于网络,如有侵权,请联系删除。
评论 (0)