深入解析J.U.C并发包(三)——并发基础Volatile

一、 可见性与MESI

1.1可见性

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

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

1.2缓存一致性和MESI

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

1、独占(exclusive):仅当前处理器拥有该缓存行,并且没有修改过,是最新的值。

2、共享(share):有多个处理器拥有该缓存行,每个处理器都没有修改过缓存,是最新的值。

3、修改(modified):仅当前处理器拥有该缓存行,并且缓存行被修改过了,一定时间内会写回主存,写成功时,状态会变为S。

4、失效(invalid):缓存行被其他处理器修改过,该值不是最新的值,需要读取主存上最新的值。

协议协作如下:

  • 一个处于M状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回CPU。
  • 一个处于S状态的缓存行,必须时刻监听使该缓存行失效或者独享缓存行的请求,如果监听到,则必须把其缓存行状态设置为I。
  • 一个处于E状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存状态设置为S。
  • 当CPU需要读取数据时,如果其缓存行的状态是I的,则需要从内存中读取,并把自己状态变成S,如果不是I,则可以直接读取缓存中的数据,但在此之前,必须要等待其他CPU的监听结果,如其他CPU也有该数据的缓存状态且状态是M,则需要等待其把缓存更新到内存之后,读取。
  • 当CPU需要写数据时,只有在其缓存行是M或E的时候才能执行,否则需要发出特殊的RFO指令(Read Or Ownership,这是一种总线事务),通知其他CPU置缓存无效(I),这种情况下性能开销是相对交大的。在写入完成后,修改其缓存状态为M。

                                                           

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

存储缓存(Store Buffer)

当处理器修改一个变量时,执行修改操作不直接针对于缓存行,而是针对于一个StoreBuffer的位置来执行的。这样当CPU操执行修改操作的时候,直接把数据写入到Store Buffer里,并发出广播告知其他CPU,需要将缓存行变为invalid状态,处理器就可以去干别的事了,把剩下的事交给存储缓存,等StoreBuffer接收到valid ack(失效响应)时才会把SoreBuffer中的内容写到缓存行中。

失效队列(Invalidate Queues)

处理失效的缓存也不是简单的,需要读取主存,并且存储缓存也不是无限大的,那么当存储缓存满了的时候,处理器还是要等待失效响应(valid ack)返回才可以继续运算。为了解决上面两个问题,引进了失效队列(Invalidate Queue)。失效队列的工作如下:

1、收到失效消息时,放到失效队列中去。

2、为了不让处理器就等失效响应,收到失效消息需要马上回复失效响应(valid ack)。

3、为了不频繁阻塞处理器,不会马上读主存以及设置缓存为invalid,合适的时候再一块处理失效队列。

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这个操作,那么在ring bus上就会根据特定的设计协议仲裁是core0,还是core1能赢得这个invalidate,胜者完成操作,失败者需要接受结果,invalidate自己对应的cacheline,再读取胜者修改后的值,回到起点。

对于我们的CAS操作来说,其实锁并没有消失,只是转嫁到了ring bus的总线仲裁协议中,而且大量的多核同时针对一个地址的CAS操作会引起反复的互相invalidate同一cacheline,造成pingpong效应,同样会降低性能。

二、指令重排和内存屏障

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语义,保证了单线程模型下的程序不会感知到指令重排的影响。

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

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线程发现已经不为空就返回继续执行,就会出错。

2.2内存屏障

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

1.阻止屏障两侧的指令重排序。

2.强制把写缓冲区/高速缓冲中的脏数据等写回主内存,让缓存中相应的数据失效。

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

1、LoadLoad屏障:(指令Load1;LoadLoad;Load2),在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。

2、LoadStore屏障:(指令Load1;LoadStore;Store2),在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。

3、StoreStore屏障:(指令Store1;StoreStore;Store2),在Store2及后续写入操作被执行前,保证Store1的写入操作被其他处理器课件。

4、StoreLoad屏障:(指令Store1,;StoreLoad;Load2),在Load2及后续所有操作执行前,保证Store1的写入对所有处理器可见。它的开销是4种屏障种最大的,在大多数处理器中,这个屏障是个万能屏障,兼具了其它三种屏障的功能。

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

1、在每个volatile写之前,插入一个StoreStore,写入之后,插入一个StoreLoad。

2、在每个volatile读之前,插入一个LoadLoad,之后插入一个LoadStore

volatile写与volatile读的内存屏障插入策略非常保守,但是在实际执行中,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。

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写
    }

    …                    //其他方法
}

è¿éåå¾çæè¿°

针对不同的处理器平台,插入的有不同,以x86的为例,上图除了StoreLoad,其他都会被省略。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值