volatile底层之缓存一致性协议MESI

写在前面

前面对JMM模型以及volatile进行了一个总结,接下来对CPU的MESI,也就是缓存一致性协议来做一个复习总结,学习了解了MESI之后将会对volatile会有一个更加深入的认识。

在这之前,已经了解了CPU的内部结构以及设置了L1、L2、L3多级缓存。那么在多核CPU的情况下有多个一级缓存,如何保证缓存内部数据的一致,不让系统数据混乱。这里就引出了一致性协议MESI。在了解MESI之前先来了解下java代码从JVM到cpu运行的一个流程。

JVM-CPU底层执行流程

image.png
如上图所示,在此先不细说java类是如何加载到jvm中,后续有时间会详细介绍。这里就默认java类已经加载到了jvm中了,jvm会将class翻译成java字节码指令,通过解释执行器翻译成汇编指令,此时CPU还不能直接执行指令,还需要将汇编指令转化成二进制且CPU有多余的线程来执行二进制指令,这时候CPU才正在的执行。没一行代码都需要经过这么一个流程交给CPU来执行。

上一篇中介绍过volatile的相关内容,知道在字段前加上volatile关键字,jvm会在字节码中加上ACC_VOLATILE修饰,那在转换成汇编指令的时候会是怎样的呢?来看看以下程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
java复制代码// -server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*CodeVisibility.refresh
public class CodeVisibility {

private volatile static boolean initFlag = false;

private static int counter = 0;

private static Integer counter2 = 0;

public static void refresh(){
log.warn("refresh data.......");
initFlag = true;
log.warn("refresh data success.......");
}

public static void main(String[] args){
Thread threadA = new Thread(()->{
while (!initFlag){

}
log.warn("线程:" + Thread.currentThread().getName()
+ "当前线程嗅探到initFlag的状态的改变");
},"threadA");
threadA.start();

try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}

Thread threadB = new Thread(()->{
refresh();
},"threadB");
threadB.start();
}
}

查看汇编指令需要下载两个插件hsdis-amd64.dll和hsdis-amd64.lib,可自行百度下载。将这两文件放置到jdk下的jre/bin下即可,在运行时加上参数,即可输出汇编指令。

image.png
生成的汇编指令可用发现,在使用了volatile修饰的initFlag前面多了一个lock指令。通过查阅发现lock指令会触发总线锁,且不对应用程序禁止,也就是说,应用程序可用使用lock这个指令代码。lock的主要功能是:在修改内存操作时,使用lock前缀去调用加锁的读-修改-写操作(原子的)。这种机制多用于多处理器系统中处理器之间进行可靠的通讯。
image.png
如上图所示,CPU要想去从主内存中获取变量数据,需要通过总线来获取,如图,此时core0的Thread0需要从主内存中去获取x=1的变量数据,此时会通过总线锁来锁住bus总线,此时其他核例如core1就无法通过使用bus总线来获取主内存中的数据,也就是说,在早期通过使用lock前缀,会锁住bus总线,多核的CPU同时只能有一个核心的CPU才能获取到bus总线的使用权,这就是lock前缀的作用,但这样的效率低下。

因此可以得知,早期时是使用的总线锁来保证缓存一致。

缓存一致性协议MESI

MESI是指4种状态的首字母。每个缓存行有4个状态,可用2个bit表示,它们分别是:

状态 描述 监听任务
M 修改 (Modified) 该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。 缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。
E 独享、互斥 (Exclusive) 该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。 缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。
S 共享 (Shared) 该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。 缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。
I 无效 (Invalid) 该Cache line无效。

通过一个例子来看:

通过上面早期的总线锁来看,会发现,想要获取主内存中的数据,必须会经过bus总线,因此,随着技术的发展,在硬件上也做了改进,在cpu读取主内存中的数据时,只要读取的数据有被lock前缀修饰,那么这个数据也会被其他cpu监听到。
image.png

如图所示,在主内存中存在x=1数据,此时core0来读取x=1变量到L3缓存中,没有其他cpu来读取,这个时候的x状态为E(独占),如果这个时候又有一个cpu,core1来读取x=1变量L3缓存中(注意:每个cpu读取数据都会先读取到L3cache中,不论其他cpu是否已经读取过),这个时候core0所读取的x=1变量状态就会由E(独占)变成S(共享状态),core1所读取的x=1变量状态则为S(共享状态)。读取数据似乎看起来并没有什么问题。但是如果两个cpu要同时修改x变量的值呢?接下来往下看。

image.png

如图所示,此时两个cpu都分别将数据读取到各自的L1缓存中,在之前的文章中有讲过,每个缓存中都有64byte的缓存行,用来存放变量数据,如果此时两个cpu同时都要对x变量进行修改的话,会对各自x变量数据所在的缓存行进行竞争同一把锁来进行加锁,由竞争到的cpu进行加锁,加锁成功则可以对x进行修改,反之不能修改。以上图为例,由cpu的core1抢到锁并对x变量所在的缓存行加锁成功同时会发送一个信号给bus总线,如果有x变量的cpu会收到这个信号,会将本地的x变量的状态由S(共享)改成I(失效),此时core1就可以对x变量进行修改将x=1改成x=3,且状态由S(共享)变为M(修改)。但是,还没有结束。cpu的运行速度非常的快,如果这个时候两个cpu都对自己本地的缓存行加锁成功了,那该怎么办?这时候要听谁的?接着往下看。

image.png

如果这个时候两个cpu同时都对自己本地的缓存行进行加锁成功了,两边都会往bus总线发送一个本地写缓存的信号,此时就交给bus总线来进行裁决。判定是由哪个cpu加锁成功。

最后:上面的例子中,所举的x变量数据在一个缓存行中可以放的下,如果是一个128字节的数据,需要多个缓存行才能放下这个变量数据。那么这个时候就没有办法使用MESI来保证缓存的一致性,这时候就会升级为最开始介绍的总线锁。

小结

从java代码中字段变量用volatile修饰,经过解释执行器翻译成汇编指令,在volatile修饰的变量,会有lock前缀,cpu从主内存读取数据的时候,会经过bus总线,bus总线会监听带有lock前缀的数据,保证每个cpu对变量的可见性。
image.png

如图所示各个状态之间的转换。各个状态之间的触发事件如下表:

触发事件 描述
本地读取(Local read) 本地cache读取本地cache数据
本地写入(Local write) 本地cache写入本地cache数据
远端读取(Remote read) 其他cache读取本地cache数据
远端写入(Remote write) 其他cache写入本地cache数据

缓存行伪共享

CPU缓存系统中是以缓存行(cache line)为单位存储的。目前主流的CPU Cache 的 Cache Line 大小都是64Bytes。在多线程情况下,如果需要修改“共享同一个缓存行的变量”,就会无意中影响彼此的性能,这就是伪共享(False Sharing)。

举个例子:现在有2个long 型变量 a 、b,如果有t1在访问a,t2在访问b,而a与b刚好在同一个cache line中,此时t1先修改a,将导致b被刷新!

在java8中新增了一个注解@sun.misc.Contended,解决了此问题,加上这个注解的类会自动补齐缓存行,需要注意的是此注解默认是无效的,需要在jvm启动时设置 -XX:-RestrictContended 才会生效。

MESI优化和带来的问题

缓存的一致性消息传递是要时间的,这就使其切换时会产生延迟。当一个缓存被切换状态时其他缓存收到消息完成各自的切换并且发出回应消息这么一长串的时间中CPU都会等待所有缓存响应完成。可能出现的阻塞都会导致各种各样的性能问题和稳定性问题。

举个例子:比如你需要修改本地缓存中的一条信息,那么你必须将I(无效)状态通知到其他拥有该缓存数据的CPU缓存中,并且等待确认。等待确认的过程会阻塞处理器,这会降低处理器的性能。因为这个等待远远比一个指令的执行时间长的多。

为了避免这种CPU运算能力的浪费Store Bufferes被引入使用。处理器把它想要写入到主存的值写到缓存,然后继续去处理其他事情。当所有失效确认(Invalidate Acknowledge)都接收到时,数据才会最终被提交。但是,这么做会存在两个风险。

  • 第一、就是处理器会尝试从存储缓存(Store buffer)中读取值,但它还没有进行提交。这个的解决方案称为Store Forwarding,它使得加载的时候,如果存储缓存中存在,则进行返回。
  • 第二、保存什么时候会完成,这个并没有任何保证。
    举个例子:
1
2
3
4
5
6
7
8
9
10
11
ini复制代码value = 3;
void exeToCPUA(){
value = 10;
isFinsh = true;
}
void exeToCPUB(){
if(isFinsh){
//value一定等于10?!
assert value == 10;
}
}

试想一下开始执行时,CPU A保存着finished在E(独享)状态,而value并没有保存在它的缓存中。(例如,Invalid)。在这种情况下,value会比finished更迟地抛弃存储缓存。完全有可能CPU B读取finished的值为true,而value的值不等于10。
即isFinsh的赋值在value赋值之前。

这种在可识别的行为中发生的变化称为重排序(reordings)。注意,这不意味着你的指令的位置被恶意(或者好意)地更改。
它只是意味着其他的CPU会读到跟程序中写入的顺序不一样的结果。

NIO的设计和Store Bufferes的设计是非常相像的。

硬件内存模型

执行失效也不是一个简单的操作,它需要处理器去处理。另外,存储缓存(Store Buffers)并不是无穷大的,所以处理器有时需要等待失效确认的返回。这两个操作都会使得性能大幅降低。为了应付这种情况,引入了失效队列。它们的约定如下:

  • 对于所有的收到的Invalidate请求,Invalidate Acknowlege消息必须立刻发送
  • Invalidate并不真正执行,而是被放在一个特殊的队列中,在方便的时候才会去执行。
  • 处理器不会发送任何消息给所处理的缓存条目,直到它处理Invalidate。
    即便是这样处理器已然不知道什么时候优化是允许的,而什么时候并不允许。

干脆处理器将这个任务丢给了写代码的人。这就是内存屏障(Memory Barriers)。
写屏障 Store Memory Barrier(a.k.a. ST, SMB, smp_wmb)是一条告诉处理器在执行这之后的指令之前,应用所有已经在存储缓存(store buffer)中的保存的指令。

读屏障Load Memory Barrier (a.k.a. LD, RMB, smp_rmb)是一条告诉处理器在执行任何的加载前,先应用所有已经在失效队列中的失效操作的指令。

1
2
3
4
5
6
7
8
9
10
11
scss复制代码void executedOnCpu0() {
value = 10;
//在更新数据之前必须将所有存储缓存(store buffer)中的指令执行完毕。
storeMemoryBarrier();
finished = true;
}
void executedOnCpu1() {
while(!finished);
//在读取之前将所有失效队列中关于该数据的指令执行完毕。
loadMemoryBarrier();
}

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%