查看原文
其他

【314期】面试官:说一下 volitile 的内存语义,底层如何实现

介绍

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 2,3 happens before 4
  • 根据volatile规则,2 happens before 3
  • 根据happens before的传递规则,1 happens before 4

(1)当执行写入volatile时,也就是2,JMM会将该线程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屏障(load1,loadload, load2)
  • loadstore屏障(load,loadstore, store)

这两个屏障都通过acquire()方法实现

volatile和CAS底层实现都用CPU的lock指令,他们有什么不同?

首先lock只是前缀,lock后面一定有跟命令,具体看后面的命令

  • 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后面的指令要看编译时的实际情况。
  • CAS给cmpxchg指令加lock前缀,是为了cmpxchg指令在多核处理器情况能保证原子性

lock前缀的具体作用

Lock指令区分两种实现方法

  • 早期 - Pentium时代(锁总线),在Pentium及之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存,很显然,这个开销很大。
  • 现在 - P6以后时代(锁缓存),在新的处理器中,Intel使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低lock前缀指令的执行开销。

这里锁缓存(Cache Locking)就是用了Ringbus + MESI协议。

MESI大致的意思是:若干个CPU核心通过ringbus连到一起。每个核心都维护自己的Cache的状态。如果对于同一份内存数据在多个核里都有cache,则状态都为S(shared)。

一旦有一核心改了这个数据(状态变成了M),其他核心就能瞬间通过ringbus感知到这个修改,从而把自己的cache状态变成I(Invalid),并且从标记为M的cache中读过来。同时,这个数据会被原子的写回到主存。最终,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,和没有载入一样。当某个cpu的cache line处于- - Shared状态,别的cpu申请写的时候,接收了RFO请求后会变为此种状态。

这四种状态可以不断的改变,有了这套协议,不同的cpu之间的缓存就可以保证数据的一致性了。但是依赖这套协议,会大大的降低性能,比如一个核心上某个Shared的cache line打算写,则必须先RFO来获取独占权,当其它核心确认了之后才能转为Exclusive状态来进行修改,假设其余的核心正在处理别的事情而导致一段时间后才回应,则会当申请RFO的核心处于无事可做的状态,这是不可接受的。

于是在每个cpu中,又加入了两个类似于缓存的东西,分别称为Store buffer与Invalidate 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

END

题外推荐

推荐一个“摸鱼程序员”聚集地


【301期】面试官:dubbo为什么没有采用jdk的spi机制?

【302期】面试官:幂等性的接口该如何设计?

【303期】如何理解算法中的时间复杂度?

【304期】堆排序算法(图解详细流程)

【305期】面试官:Redis用过是吧?那你讲讲Redis都有哪些监控指标?

【306期】面试官:解决集群环境下定时任务多次执行的办法有哪些?

【307期】面试官:什么是NIO?NIO的原理是什么?

【308期】面试官:为什么kafka效率这么高?

【309期】阿里巴巴菜鸟网络——面试经历记录

【310期】面试官:MySQL主备、主从、读写分离你了解多少?


与其在网上拼命找题? 不如马上关注我们~

PS:因为公众号平台更改了推送规则,如果不想错过内容,记得读完点一下“在看”,加个“星标”,这样每次新文章推送才会第一时间出现在你的订阅列表里。点“在看”支持我们吧!

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存