jvm

关于volatile、MESI、内存屏障、#Lock

2018-08-20  本文已影响2415人  陈涛_滴滴

最近又看了下Disruptor,里面提到了内存屏障,突然想到了指令重排、还有可见性,感觉里面关系有点乱,就翻了下,因此就写了这篇文章

带着几个问题:

一、可见性和MESI

1.1 可见性

在JVM的内存模型中,每个线程有自己的工作内存,实际上JAVA线程借助了底层操作系统线程实现,一个JVM线程对应一个操作系统线程,线程的工作内存其实是cpu寄存器和高速缓存的抽象

现代处理器的缓存一般分为三级,由每一个核心独享的L1、L2 Cache,以及所有的核心共享L3 Cache组成,具体每个cache,实际上是有很多缓存行组成:


1.2 缓存一致性和MESI

缓存一致性协议给缓存行(通常为64字节)定义了个状态:独占(exclusive)、共享(share)、修改(modified)、失效(invalid),用来描述该缓存行是否被多处理器共享、是否修改。所以缓存一致性协议也称MESI协议。

协议协作如下:

这个图的含义就是当一个core持有一个cacheline的状态为Y时,其它core对应的cacheline应该处于状态X, 比如地址 0x00010000 对应的cacheline在core0上为状态M, 则其它所有的core对应于0x00010000的cacheline都必须为I , 0x00010000 对应的cacheline在core0上为状态S, 则其它所有的core对应于0x00010000的cacheline 可以是S或者I ,

另外MESI协议为了提高性能,引入了Store Buffe和Invalidate Queues,还是有可能会引起缓存不一致,还会再引入内存屏障来确保一致性,可以参考[7]和[12]

存储缓存(Store Buffe)

也就是常说的写缓存,当处理器修改缓存时,把新值放到存储缓存中,处理器就可以去干别的事了,把剩下的事交给存储缓存。

失效队列(Invalidate Queues)

处理失效的缓存也不是简单的,需要读取主存。并且存储缓存也不是无限大的,那么当存储缓存满的时候,处理器还是要等待失效响应的。为了解决上面两个问题,引进了失效队列(invalidate queue)。处理失效的工作如下:

1.3 MESI和CAS关系

在x86架构上,CAS被翻译为”lock cmpxchg...“,当两个core同时执行针对同一地址的CAS指令时,其实他们是在试图修改每个core自己持有的Cache line,

假设两个core都持有相同地址对应cacheline,且各自cacheline 状态为S, 这时如果要想成功修改,就首先需要把S转为E或者M, 则需要向其它core invalidate 这个地址的cacheline,则两个core都会向ring bus发出 invalidate这个操作, 那么在ringbus上就会根据特定的设计协议仲裁是core0,还是core1能赢得这个invalidate, 胜者完成操作, 失败者需要接受结果, invalidate自己对应的cacheline,再读取胜者修改后的值, 回到起点.

对于我们的CAS操作来说, 其实锁并没有消失,只是转嫁到了ring bus的总线仲裁协议中. 而且大量的多核同时针对一个地址的CAS操作会引起反复的互相invalidate 同一cacheline, 造成pingpong效应, 同样会降低性能(参考[9])。当然如果真的有性能问题,我觉得这可能会在ns级别体现了,一般的应用程序中使用CAS应该不会引起性能问题

二、指令重排和内存屏障

2.1 指令重排

现代CPU的速度越来越快,为了充分的利用CPU,在编译器和CPU执行期,都可能对指令重排。举个例子:

LDR R1, [R0];//操作1
ADD R2, R1, R1;//操作2
ADD R3, R4, R4;//操作3

上面这段代码,如果操作1如果发生cache miss,则需要等待读取内存外存。看看有没有能优先执行的指令,操作2依赖于操作1,不能被优先执行,操作3不依赖1和2,所以能优先执行操作3。
JVM的JSR-133规范中定义了as-if-serial语义,即compiler, runtime, and hardware三者需要保证在单线程模型下程序不会感知到指令重排的影响。

在并发模型下,重排序还是可能会引发问题,比较经典的就是“单例模式失效”问题(DoubleCheckedLocking):

public class Singleton {
  private static Singleton instance = null;

  private Singleton() { }

  public static Singleton getInstance() {
     if(instance == null) {
        synchronzied(Singleton.class) {
           if(instance == null) {
               instance = new Singleton();  //
           }
        }
     }
     return instance;
   }
}

上面这段代码,初看没问题,但是在并发模型下,可能会出错,那是因为instance= new Singleton()并非一个原子操作,它实际上下面这三个操作:

memory =allocate();    //1:分配对象的内存空间
ctorInstance(memory);  //2:初始化对象
instance =memory;     //3:设置instance指向刚分配的内存地址

上面操作2依赖于操作1,但是操作3并不依赖于操作2,所以JVM是可以针对它们进行指令的优化重排序的,经过重排序后如下:

memory =allocate();    //1:分配对象的内存空间
instance =memory;     //3:instance指向刚分配的内存地址,此时对象还未初始化
ctorInstance(memory);  //2:初始化对象

可以看到指令重排之后,instance指向分配好的内存放在了前面,而这段内存的初始化被排在了后面。在多线程场景下,可能A线程执行到了3,B线程发现已经不为空就返回继续执行,就会出错。

在java里面volatile可以防止重排,当然还有另外一个作用即内存可见性,这个知道的人还应该比较普遍,就不说了

2.2 内存屏障

硬件层的内存屏障分为两种:Load Barrier 和 Store Barrier即读屏障和写屏障。内存屏障有两个作用:

1.阻止屏障两侧的指令重排序;
2.强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。

在JSR规范中定义了4种内存屏障:

对于volatile关键字,按照规范会有下面的操作:

具体到X86来看,其实没那么多指令,只有StoreLoad:


结合上面的【一】和【二】的内容,内存屏障首先阻止了指令的重排,另外也和MESI协议结合,确保了内存的可见性

三、happends-before

结合前面的两点,再看happends-before就比较好理解了。因为光说可见性和重排很难联想到happends-before。这个点在并发编程里还是非常重要的,再详细记录下:

四、实现 --> #lock

再往下挖一层,会发现volatile关键字,转换成指令以后,会有一个#lock前缀...原来以为会有相应的内存屏障指令,说好的内存屏障的那些呢?
后来参考了资料[11]以及其他一些文章以后才了解到,任何带有lock前缀的指令以及CPUID等指令都有内存屏障的作用。

参考

上一篇下一篇

猜你喜欢

热点阅读