一、 可见性与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,其他都会被省略。