1 多核CPU的缓存一致性
由于CPU和内存的速度差异,现代CPU通常引入了缓存机制,如下是X86系列CPU缓存结构,不同CPU厂商结构可能不一样。
通常CPU的L1,L2缓存是私有,L3缓存是共有的,每个Cache被分为S个组,每个组是又由E行个最小的存储单元——Cache Line(缓存行)所组成,而一个Cache Line中有B(B=64)个字节用来存储数据,即每个Cache Line能存储64个字节的数据,每个Cache Line又额外包含一个有效位(valid bit)、t个标记位(tag bit),其中valid bit用来表示该缓存行是否有效;tag bit用来协助寻址。
由于L1,L2 缓存是私有的,所有在多线程环境下会导致缓存不一致问题。
1.1 MESI缓存一致性协议
为了解决缓存不一致问题,于是出现了缓存一致性协议,X86架构下使用MESI协议解决缓存一致性问题,其他CPU使用的协议可能不同。
MESI协议为每个缓存行定义了如下四种状态:
M(Modified) | 这行数据有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。 |
---|---|
E(Exclusive) | 这行数据有效,数据和内存中的数据一致,数据只存在于本 Cache 中。 |
S(Shared) | 这行数据有效,数据和内存中的数据一致,数据存在于很多 Cache 中。 |
I(Invalid) | 这行数据无效。 |
1.2 MESI解决一致性过程
说明:图中有两个CPU,主存中有一个变量X = 0,下面就介绍一个CPU A和CPU B读写X的过程。
- 初始状态,cache A和cache B中都没有X的副本,CPU A发起read请求向主内存,主内存会向总线发送ReadResponse,之后CPU A将Cache A的状态更改位E,表示独占。
- CPU B发起Read请求,此时CPU A和CPU B同时嗅探总线,发现主内存的变量X不只有一个副本,此时CPU A将Cache A的状态更改位S,CPU B收到ReadResponse之后,更改状态位S。
- 假如这时CPU A要修改变量X的值为1,这时CPU A先发起Invalidate请求,当CPU B嗅探该请求之后,会将Cache B的状态更新为I,之后回复Invalidate Acknowledge,当CPU A收到CPU B发送的ack之后,才会更改变量X的值为1,之后更新Cache A的状态为M。
- 如果此时CPU B要读取变量X,发现自己缓存的状态为I,则会发起Read请求,这时CPU A嗅探到Read请求,会将Cache A中的X的值刷新回主内存,然后会将自己的状态更新为E,之后,CPU A会将X的值同步给CPU B,在之后两者的状态都更新为S。
1.3 MESI性能优化
在上面的例子中,有这么一个场景,就是CPU A要修改Cache A的值,这个时候他需要先发送Invalidate请求,等到别的CPU都返回了ack,才可以真正的开始修改缓存中的值。这个过程其实有两个地方要等待。
- CPU A需要等待别的CPU返回ack,这个过程浪费时间
- CPU B需要先将Cache B的状态更新为I,之后再返回ack,这个过程也非常浪费时间
所以针对这两点,从硬件级别就做了两点优化,引入了store buffer(写缓冲区)和Invalidate queues(失效队列),对应于上面例子中的优化具体如下:
- CPU A将X的值写入到写缓冲区,之后直接发送Invalidate请求,然后CPU就去做别的事情,当所有的ack都收到之后,再把写缓冲区中的值更新到高速缓存。
- CPU B收到CPU A发送的invalidate请求之后,并不会直接去修改Cache B的状态,而是将请求信息放入Invalidate Queues中,等CPU有空闲了再处理。
这种一个指令还未结束便去执行其它指令的行为称之为:指令重排。
以上两个存储结构的引入的确可以解决MESI协议效率低的问题,但是由于延迟执行却带来了新的问题,就是常见的可见性和有序性的问题,下面就举例分析一下引入上面两个存储结构之后导致的可见性和有序性的问题。
可见性问题:
- 如果CPU A修改了X的值,但是并没有直接刷新回高速缓存,这个时候如果CPU A或者CPU B要使用X的值,对于CPU A来说,他的缓存状态时S,说明和内存中的状态一致,所以就直接使用了旧的值。对于CPU B来说,他的状态也是S,他也会直接使用这个值,但是其实这个时候X的值已经被CPU A修改过了,但是却没有生效。
有序性问题:
思考这么一段代码
public class Test {
static int a = 1;
static int c = 1;
public static void main(String[] args) {
new Thread(()->{
a = 2;
int b = c;
}).start();
}
}
在上面的代码中,有两个全局变量,在main中有一个线程,这个线程先执行了一个写操作,对a进行重新赋值,之后执行了一个读操作,如果在多线程的环境中,第一步写操作会先放到写缓冲区,收到ack之后将写缓冲区的数据刷新回主内存,在别的线程看来,其实是先执行的第二步赋值操作,而不是第一步,这样顺序就出现了问题,这就是所说的有序性问题。
1.4 内存屏障
上面提到了使用store buffer和invalidate queues之后会有可见性和有序性的问题,那如何解决这些问题,就是下面要介绍的内存屏障来解决。
内存屏障(memory barrier)是一个CPU指令。其基本作用:
- 阻止屏障两边的代码发生指令重排
- 强制将写缓冲区/高速缓存的数据刷新回主内存,并使得相应的缓存中的数据失效
在详细介绍内存屏障之前需要先介绍两个指令
- store指令:将数据刷新到主内存中
- load指令:从主内存中重新加载最新的数据
内存屏障在不同的硬件有不同的实现,本文介绍一下x86的内存屏障实现
Store Barrier:在x86中是sfence指令实现的,强制该屏障之前的store指令都执行完才可以执行sfence指令,然后才可以执行屏障之后的store指令。
Load Barrier:在x86中是lfence指令实现的,强制该屏障之前的load指令都执行完才可以执行Ifence指令,然后才可以执行屏障之后的load指令。
Full Barrier:在x86中是mfence指令实现的,该指令相当于sfence和Ifence两个指令的功能。
jvm为了屏蔽硬件的差异,定义了自己的内存屏障,其底层是使用硬件的内存屏障。
- LoadLoad内存屏障:相当于上面介绍的Load Barrier内存屏障的作用。
- StoreStore内存屏障:相当于上面介绍的Store Barrier内存屏障的作用。
- StoreLoad内存屏障:相当于上面介绍的Full Barrier内存屏障,这个是最全能的,相当于其他三个内存屏障的功能,但是相应的开销也更大。
- LoadStore内存屏障:这个在上面没有对应的硬件指令,不清楚jvm如何实现的,不过功能是在LoadStore内存屏障之前Load指令执行完之后才可以执行LoadStore,之后才可以执行后面的Store指令。
JVM定义的内存屏障实际使用时,使用的是Lock前缀指令,因为不同CPU支持的内存屏障指令不同,但是不同CPU都支持Lock前缀指令。早期Lock前缀指令是锁总线,由于性能问题,后来使用锁缓存,但是无法锁定缓存时,也会锁总线
Lock前缀的功能:
将当前处理器缓存行的数据写会系统内存
使其他缓存了该数据的缓存行失效
2 JMM
从上面可以看出,由于缓存的引入会造成缓存一致性的问题,而缓存一致性需要程序添加内存屏障来解决,由于各个硬件的缓存一致性协议和内存屏障不一致,JVM为了屏蔽底层硬件的差异,所以引入了JMM(Java Memory Model-Java内存模型)来解决多线程下的可见性和有序性问题。
JMM属于语言级别的抽象内存模型,可以简单理解为对硬件模型的抽象,它定义了共享内存中多线程程序读写操作的行为规范,通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了CPU 多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。 需要注意的是,JMM并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序,也就是说在JMM中,也会存在缓存一致性问题和指令重排序问题。只是JMM把底层的问题抽象到JVM层面,再基于CPU层面提供的内存屏障指令,以及限制编译器的重排序来解决并发问题。
2.1 JMM抽象内存模型
JMM 抽象模型分为主内存、工作内存;主内存是所有线程共享的,一般是实例对象、静态字段、数组对象等存储在堆内存中的变量。工作内存是每个线程独占的,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量,线程之间的共享变量值的传递都是基于主内存来完成,可以抽象为下图:
JVM内存与硬件内存架构之间存在差异。硬件内存架构没有区分线程栈和堆。对于硬件,所有的线程栈和堆都分布在主内存中。部分线程栈和堆可能有时候会出现在CPU缓存中和CPU内部的寄存器中。如下图所示:
从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:
- 线程之间的共享变量存储在主内存(Main Memory)中
- 每个线程都有一个私有的本地内存(Local Memory),本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。本地内存中存储了该线程以读/写共享变量的拷贝副本。
- 从更低的层次来说,主内存就是硬件的内存,而为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。
- Java内存模型中的线程的工作内存(working memory)是cpu的寄存器和高速缓存的抽象描述。而JVM的静态内存储模型(JVM内存模型)只是一种对内存的物理划分而已,它只局限在内存,而且只局限在JVM的内存。
2.2 JMM如何解决可见性
从JMM的抽象模型结构图来看,如果线程A与线程B之间要通信的话,必须要经历下面2个步骤。
线程A把本地内存A中更新过的共享变量刷新到主内存中去。
线程B到主内存中去读取线程A之前已更新过的共享变量。
下面通过示意图来说明这两个步骤:
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:
- lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
- unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用。
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
- write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。
Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:
- 如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
- 不允许read和load、store和write操作之一单独出现
- 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
- 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
- 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
- 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。
2.3 JMM如何解决有序性
有序性问题是由于指令重排序导致:
编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序,如下图:
其中2和3属于处理器重排序。而这些重排序都可能会导致可见性问题(编译器和处理器在重排序时会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序,JMM要求遵守happens-before规则和as-if-serial语义)。as-if-serial保障单线程内程序执行结果不被改变,happens-before保障正确同步的多线程程序的执行结果不被改变。
as-if-serial语义:
不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。(编译器、runtime和处理器都必须遵守as-if-serial语义)
happens-before规则:
在JMM中,如果一个操作执行的结果需要对另一个操作可见(两个操作既可以是在一个线程之内,也可以是在不同线程之间),那么这两个操作之间必须要存在happens-before关系:
程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
Join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。
对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。
内存屏障禁止特定类型的处理器重排序:
重排序可能会导致多线程程序出现内存可见性问题。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。上面已经提到过内存屏障了这里就不在赘述。
3 Java如何实现JMM
上面的定义和操作都很抽象,实际JVM为了解决共享变量的可见性提供了如下解决方案:
- Java中的volatile关键字:volatile关键字可以保证直接从主存中读取一个变量,如果这个变量被修改后,总是会被写回到主存中去。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是:volatile的特殊规则保证了新值能立即同步到主内存,以及每个线程在每次使用volatile变量前都立即从主内存刷新。因此我们可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。
- Java中的synchronized关键字:同步快的可见性是由“如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值”、“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store和write操作)”这两条规则获得的。
- Java中的final关键字:final关键字的可见性是指,被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那么在其他线程就能看见final字段的值(无须同步)。
Synchronize和volatile在CPU层级都是通过Lock前缀指令实现内存屏障的功能,禁止重排序和保障可见性。