JAVA内存模型-高速缓存,指令重排,内存屏障

JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

JAVA内存模型-高速缓存,指令重排,内存屏障_第1张图片
java程序简单调用图

三大性质

JMM是围绕并发编程中原子性、可见性、有序性三个特征来建立的。

原子性
一个操作是不可中断的,要么全部执行成功要么全部执行失败,类似于事务。原子性变量操作包括read,load,assign,use,store,write。Java 基本类型的数据访问大都是原子操作,但是long 和 double 类型是 64 位,在 32 位 JVM 中会将 64 位数据的读写操作分成两次 32 位来处理,所以 long 和 double 在 32 位 JVM 中是非原子操作的。

可见性:
一个线程对主内存的修改可以及时被其他线程观察到。导致可见性问题的根本原因:高速缓存。

有序性
有序性指的是程序按照代码的先后顺序执行。导致有序性的根本原因:指令重排序

线程存在本地工作内存,线程共享的主内存。规则:所有线程都不能直接操作主内存,需先访问工作内存,再访问主内存。

JAVA内存模型-高速缓存,指令重排,内存屏障_第2张图片
内存简单访问方式

源代码>>>编译器的重排序>>>CPU层面的重排序(指令级,内存级)>>>最终执行的指令

Java 内存模型中的指令重排不会影响单线程的执行顺序,但是会影响多线程并发执行的正确性,所以在并发中我们必须要想办法保证并发代码的有序性;在 Java 里可以通过 volatile 关键字保证一定的有序性,还可以通过 synchronized、Lock 来保证有序性,因为 synchronized、Lock 保证了每一时刻只有一个线程执行同步代码相当于单线程执行,所以自然不会有有序性的问题;除此之外 Java 内存模型通过 happens-before 原则如果能推导出来两个操作的执行顺序就能先天保证有序性,否则无法保证。从 Java 内存模型我们就能看出来多线程访问共享变量都要经过线程工作内存到主存的复制和主存到线程工作内存的复制操作,所以普通共享变量就无法保证可见性了;Java 提供了 volatile 修饰符来保证变量的可见性,每次使用 volatile 变量都会主动从主存中刷新,除此之外 synchronized、Lock、final 都可以保证变量的可见性。

CPU高速缓存

我们都知道CPU/内存/IO三者计算速度差别很大,CPU>内存>IO;在计算机中,cpu和内存的交互最为频繁,相比内存,磁盘读写太慢,内存相当于高速的缓冲区。随着多核cpu时代的到来,内存的读写速度又远不如cpu。因此cpu上出现了高速缓存的概念。cpu上加入了高速缓存,用来解决处理器和内存访问速度差异。在多核cpu中,每个处理器都有各自的高速缓存(L1,L2,L3),而主内存确只有一个 。大概结构如下:

JAVA内存模型-高速缓存,指令重排,内存屏障_第3张图片
CPU缓存模型

L1是一级缓存,L1d是数据缓存,L1i是指令缓存
L2是二级缓存,比L1稍大
L3是三级缓存,L3缓存是cpu共享的高速缓存,主要目的是进一步降低内存操作的延迟问题。

  1. CPU-01读取数据A,数据A被读入 CPU-01 的高速缓存中。
  2. CPU-02读取数据A,同样存入CPU-02高速缓存中。这样 CPU1 , CPU2 的高速缓存拥有同样的数据。
  3. CPU-01修改了数据A,被修改后,数据A被放回CPU-01的高速缓存行,但是还没有写入到主内存 。
  4. CPU-02访问数据A,但由于CPU-01并没有将数据A写入主内存,导致了数据不一致。

高速缓存带来了缓存不一致问题:CPU层面的解决方案:总线锁,缓存锁

总线锁:处理器的锁,锁的是总线,锁住之后,会导致CPU串行化,效率很慢。(CPU与其它部件通信,是通过总线的方式来通信,当线程A与主内存通信时,先加个锁,此时其他线程不能与主内存通信)
缓存锁:简单的说,如果某个内存区域数据A,已经同时被两个或以上处理器核缓存,缓存锁就会通过缓存一致性机制阻止对其修改,以此来保证操作的原子性,当其他处理器核回写已经被锁定的缓存行的数据时会导致该缓存行无效。

缓存行(Cache line):CPU缓存中的最小缓存单位
目前主流的CPU Cache的Cache Line大小都是64Bytes。假设我们有一个512字节的一级缓存,那么按照64B的缓存单位大小来算,这个一级缓存所能存放的缓存个数就是512/64 = 8个。

在多核CPU的情况下,每个CPU都有独立的一级缓存,如何才能保证缓存内部数据的一致?一致性的协议MESI。

缓存一致性协议MESI

MESI实现方法是在CPU缓存中保存一个标记位,以此来标记四种状态。另外,每个CPU的缓存控制器不仅知道自己的读写操作,也监听其它CPU的读写操作,就是嗅探(snooping)协议。

缓存状态

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

状态 描述 监听任务
M 修改 (Modified) 该缓存行有效,但是数据A被修改了,和内存中的数据A不一致,数据只存在于本CPU中。 缓存行必须时刻监听所有试图读取主存中旧数据A的操作,当数据A写回主存并将状态变成S(共享)状态之后,解除该监听。
E 独享(Exclusive) 该缓存行有效,数据A和内存中的数据A一致,数据A只存在于本CPU中。 缓存行必须监听其它CPU读取主存中该数据A的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。
S 共享 (Shared) 该缓存行有效,数据A和内存中的数据A一致,数据A存在于很多CPU中。 缓存行必须监听其它CPU使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。
I 无效 (Invalid) 该缓存行无效。

对于M和E状态而言总是精确的,他们在和该缓存行的真正状态是一致的,而S状态可能是非一致的。如果一个缓存将处于S状态的缓存行作废了,而另一个缓存实际上可能已经独享了该缓存行,但是该缓存却不会将该缓存行升迁为E状态,这是因为其它缓存不会广播他们作废掉该缓存行的通知,同样由于缓存并没有保存该缓存行的copy的数量,因此(即使有这种通知)也没有办法确定自己是否已经独享了该缓存行。

从上面的意义看来E状态是一种投机性的优化:如果一个CPU想修改一个处于S状态的缓存行,总线事务需要将所有该缓存行的copy变成invalid状态,而修改E状态的缓存不需要使用总线事务。

状态转换

JAVA内存模型-高速缓存,指令重排,内存屏障_第4张图片
MESI状态转换

本地:本CPU操作
远程:其他CPU操作

本地读:无效状态的更新主存变成独享或共享;其他状态维持本状态。
本地写:M状态保持不变;共享状态、独享状态变M状态;无效状态更新主存并修改变为M修改状态。
远程读:独享变共享;共享不变;修改变共享;
远程写:所有状态变无效。

MESI带来的问题

缓存的一致性消息传递是耗时的,CPU切换时会产生延迟。当一个CPU发出缓存数据A的修改消息(缓存行状态修改消息等)时,该CPU会等待其他缓存了该数据A的CPU响应完成。该过程导致阻塞,阻塞会存在各种各样的性能问题和稳定性问题。

JAVA内存模型-高速缓存,指令重排,内存屏障_第5张图片
阻塞原因

存储缓存-Store Bufferes

为了解决CPU状态切换的阻塞问题,避免CPU资源的浪费,引入Store Bufferes。CPU把它想要写入到主存的值写到Store Bufferes,然后继续去处理其他事情。当其他CPU都确认处理完成时,数据才会最终被提交。
看一下该过程代码演示:

value = 3;
void cpu_01(){
  value = 10;//此时cpu_01发出消息,cpu_02变为I状态(store buffer 和 通知其他缓存行失效)(异步)
  isFinsh = true; //标记上一步操作发送消息完成,cpu_01修改->M状态,同步value和isFinish到主存;
}
void cpu_02(){
  if(isFinsh){
    //value一定等于10?!
    assert value == 10;
  }
}

Store Bufferes的风险:isFinsh的赋值可能在value赋值之前。
这种在可识别的行为中发生的变化称为指令重排序(指令级别的优化)。它只是意味着其他的CPU会读到跟程序中写入的顺序不一样的结果。为了解决这个问题,引入了内存屏障。

失效队列

缓存行状态修改不是一个简单的操作,它需要CPU单独处理,另外,Store Buffers大小有限,所以CPU需要等待状态修改确认处理完成的响应。这两个操作都会使得性能大幅降低。为了解决这个问题,又引入了失效队列。

由于CPU指令优化导致了问题,所以又提供了内存屏障的指令,明确让程序员告诉CPU什么地方的指令不能够优化

指令重排

前提:指令重排只针对单个处理器 和 编译器的单个线程 保证响应结果不变。

分两个层面:编译器和处理器的指令重排。

源代码-》编译器的重排序-〉CPU层面的重排序(指令级,内存级)-》最终执行的指令

看几个概念

1.数据依赖性:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。

说明 代码 描述
写后读 a=1 ; b=a ; 写一个变量之后,再读该变量
写后写 a=1 ; a=2 ; 写一个变量之后,再写该变量
读后写 b=a ; a=1 ; 读一个变量之后,再写该变量

2.as-if-serial 语义:不管怎么重排序,(单处理器/单线程)执行结果不变。编译器和处理器都必须遵守。

为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖性的操作做重排序,因为这种重排序会改变执行结果。

程序顺序规则:先行发生happens- before

重排序需要遵守happens-before规则,不能说你想怎么排就怎么排,如果那样岂不是乱了套。

1.程序顺序规则

程序顺序规则中所说的每个操作happens-before于该线程中的任意后续操作,并不是说前一个操作必须要在后一个操作之前执行,而是指前一个操作的执行结果必须对后一个操作可见,如果不满足这个要求那就不允许这两个操作进行重排序。对于这一点,可能会有疑问。顺序性是指,我们可以按照顺序推演程序的执行结果,但是编译器未必一定会按照这个顺序编译,但是编译器保证结果一定==顺序推演的结果。

2.监视器锁规则

对一个锁的解锁,happens-before于随后对这个锁的加锁。同一时刻只能有一个线程执行锁中的操作,所以锁中的操作被重排序外界是不关心的,只要最终结果能被外界感知到就好。除了重排序,剩下影响变量可见性的就是CPU缓存了。在锁被释放时,A线程会把释放锁之前所有的操作结果同步到主内存中,而在获取锁时,B线程会使自己CPU的缓存失效,重新从主内存中读取变量的值。这样,A线程中的操作结果就会被B线程感知到了。

3.volatile变量规则

对一个volatile域的写,happens-before于任意后续对这个volatile域的读。volatile变量的操作会禁止与其它普通变量的操作进行重排序。volatile变量的写操作就像是一条基准线,到达这条线之后,不管之前的代码有没有重排序,反正到达这条线之后,前面的操作都已完成并生成好结果。

4.传递性规则

A happens- before B;B happens- before C;==》A happens- before C;推导出

5.线程启动规则

如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。线程启动规则可以这样去理解:调用start方法时,会将start方法之前所有操作的结果同步到主内存中,新线程创建好后,需要从主内存获取数据。这样在start方法调用之前的所有操作结果对于新创建的线程都是可见的。

6.线程结束规则

线程中的任何操作都Happens-Before其它线程检测到该线程已经结束。假设两个线程s、t。在线程s中调用t.join()方法。则线程s会被挂起,等待t线程运行结束才能恢复执行。当t.join()成功返回时,s线程就知道t线程已经结束了。所以根据本条原则,在t线程中对共享变量的修改,对s线程都是可见的。类似的还有Thread.isAlive方法也可以检测到一个线程是否结束。可以猜测,当一个线程结束时,会把自己所有操作的结果都同步到主内存。而任何其它线程当发现这个线程已经执行结束了,就会从主内存中重新刷新最新的变量值。所以结束的线程A对共享变量的修改,对于其它检测了A线程是否结束的线程是可见的。

7.中断规则

一个线程在另一个线程上调用interrupt,Happens-Before被中断线程检测到interrupt被调用。
假设两个线程A和B,A先做了一些操作operationA,然后调用B线程的interrupt方法。当B线程感知到自己的中断标识被设置时(通过抛出InterruptedException,或调用interrupted和isInterrupted),operationA中的操作结果对B都是可见的。

8.终结器规则

一个对象的构造函数执行结束Happens-Before它的finalize()方法的开始。
“结束”和“开始”表明在时间上,一个对象的构造函数必须在它的finalize()方法调用时执行完。
根据这条原则,可以确保在对象的finalize方法执行时,该对象的所有field字段值都是可见的。

内存屏障(Memory Barriers)

编译器级别的内存屏障/CPU层面的内存屏障
CPU层面提供了三种屏障:写屏障,读屏障,全屏障

写屏障Store Memory Barrier是一条告诉CPU在执行后续指令之前,需要将该缓存行对应的store buffer中的全部写指令执行完成。

读屏障Load Memory Barrier是一条告诉CPU在执行后续指令之前,需要将该缓存行对应的失效队列中的全部失效指令执行完成。

全屏障Full Memory Barrier 是读屏障和写屏障的合集

void cpu_01() {
    value = 10;
    //在更新数据之前必须将所有存储缓存(store buffer)中的指令执行完毕。
    storeMemoryBarrier();
    finished = true;
}
void cpu_02() {
    while(!finished);
    //在读取之前将所有失效队列中关于该数据的指令执行完毕。
    loadMemoryBarrier();
    assert value == 10;
}

CPU缓存淘汰策略

CPU Cache的淘汰策略。常见的淘汰策略主要有LRU和Random两种。通常意义下LRU对于Cache的命中率会比Random更好,所以CPU Cache的淘汰策略选择的是LRU。当然也有些实验显示在Cache Size较大的时候Random策略会有更高的命中率

参考

CPU缓存一致性协议MESI
指令重排序

你可能感兴趣的:(JAVA内存模型-高速缓存,指令重排,内存屏障)