【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机制?
●【305期】面试官:Redis用过是吧?那你讲讲Redis都有哪些监控指标?
与其在网上拼命找题? 不如马上关注我们~
PS:因为公众号平台更改了推送规则,如果不想错过内容,记得读完点一下“在看”,加个“星标”,这样每次新文章推送才会第一时间出现在你的订阅列表里。点“在看”支持我们吧!