JMM内存模型、计算机内存模型、CPU缓存一致性原则(MESI)、volatile、指令重排和内存屏障(Memory Barrier)

转自知乎 作者:祥先生
链接:https://www.zhihu.com/question/296949412/answer/864851230

一、JMM概念

java内存模型,英文全称为java memory model,简称JMM。查询维基百科和oracle官网对JMM(java内存模型)的定义:

维基百科原文:The Java memory model describes how threads in the Java
programming language interact through memory. Together with the
description of single-threaded execution of code, the memory model
provides the semantics of the Java programming
language.中文翻译:java内存模型描述了java编程语言中的多线程(原文用了s,thread复数形式)是怎样与内存交互的。java内存模型与代码的单线程执行情况,一起提供了java编程语言的语义。

oracle官网原文:

The behavior of threads, particularly when not correctly synchronized,
can be confusing and counterintuitive. This chapter describes the
semantics of multithreaded programs; it includes rules for which
values may be seen by a read of shared memory that is updated by
multiple threads. As the specification is similar to the memory models
for different hardware architectures, these semantics are known as the
Java programming language memory model. When no confusion can arise,
we will simply refer to these rules as “the memory model”.

中文翻译:

多线程(原文用了s,thread复数形式)的行为可能会让人感到困惑和违反直觉,尤其是在没有正确同步的时候。这章描述了多线程程序的语义;它包括了一些规则,规定了被多线程更新的共享内存可以读取到哪些值。因为这个定义与不同硬件平台体系的内存模型很相似,所以这些语义一般被称为java程序语言内存模型。没有歧义的话,我们可以简单的称呼这些规则为“内存模型”。

维基百科和oracle官网都对java内存模型进行了相同定义:内存模型描述了java多线程与共享内存的交互过程。更进一步说,java内存模型描述了共享内存中的值被多线程修改和读取的过程,规定了共享变量的可见性和执行顺序。

在进一步了解java内存模型前,先看下面3个问题:
1)java内存模型到底是什么?
2)happen-before与java内存模型什么关系?
3)volatile等关键字底层怎么实现的?

要解答上面三个问题,就要跳出java内存模型的限制,先看下内存模型是什么,内存模型并不是java所特有的概念,由于通用计算机特殊的结构(遵循冯诺依曼体系),只有一个共享内存,内存读写速度往往是整个计算机的瓶颈,为了提高性能,增加了多CPU、缓存等部件,但也会造成多cpu在访问共享内存的情况更加复杂了,为了规范内存访问,计算机最先引入内存模型的概念。

二、计算机的内存模型

在了解计算机内存模型之前,需要知道两个概念:内存顺序和程序顺序。

2.1. 内存顺序和程序顺序

现在的计算机一般都是多cpu的,为了充分利用多cpu的性能,程序也会使用多线程,导致对共享变量的读写会很复杂。而在计算机发展早期,计算机结构很简单,只有一个cpu,程序也都是单线程的,cpu在执行完一个线程后才继续执行另一个线程,如下图:
单CPU单线程结构:
单CPU单线程结构
上图的计算机结构只有一个cpu,多线程按照队列方式顺序执行,一个线程执行完成将结果同步到内存中,下一个线程会读取到最新的内存,所以不存在并发访问内存的情况,程序运行的结果是正确的、可预测的。虽然多个线程按照队列方式顺序访问内存,但在每个线程内部,访问内存可能是乱序的,如下代码:

// 第一种情况:两个不依赖的变量
int number = 1;
boolean flag = true; 
 
// 第二种情况:两个依赖的变量
int number = 1;
int plus = number + 1;

上面代码分两个情况,第一种情况:变量 number和flag是不相互依赖的,当程序执行完成时,只要满足number的值为1,并且flag的值为true,就被认为正确执行了。定义两个名词:内存顺序(memory order)和程序顺序(program order)。从内存的角度来看,当程序执行完成,内存中的number为1,flag为true,结果始终保持一致,所以认为这段程序的内存顺序是一致的;而从程序执行的角度来看,number和flag在cpu的执行顺序可能会重排,比如flag可能会先执行,number会后执行,所以认为程序顺序不总是一致的。

第二种情况:上面的number和plus存在依赖,必须先设置number为1,才能计算plus的值,执行顺序不会被重排序,所以它们的程序顺序是一致的,内存顺序更是一致的(即这段程序执行完成,number的值一定为1,plus的值一定为2,不会存在其他值)。

在上面两种情况的程序中,程序顺序不总是一致的(不依赖的两个变量执行顺序可能会重排序,依赖的两个变量执行顺序不会重排序),但是内存顺序总是一致的,所以程序输出的结果是可预测的,如果出现了内存顺序不一致(即一段程序执行完成,每次内存中的值不一样)的情况,程序的结果变得不可预测了。

在上面单cpu单线程的结构中,多线程按照队列方式顺序访问内存,每个线程执行完内存顺序都是一致的,不会影响到下一个线程;而在多cpu+缓存的计算机中,为了提高内存访问效率,每个cpu会先缓存对内存的写和读,某个cpu执行完一段程序,执行完的结果可能不会立即刷新到内存中,所以内存中的值不总是一致的,即内存顺序不是一致的,为了描述可能存在的不同内存顺序,引入了内存模型的概念,在不同的内存模型中,内存顺序是不一样的。

2.2. SC内存模型

上面用单cpu单线程的例子简单的介绍了内存顺序和内存模型的关系,单cpu单线程的内存模型很简单,内存顺序总是一致的,程序顺序只要满足:相互依赖的变量不能重排序。在多cpu多线程中,多个cpu是同时访问内存的,如果还是照搬单cpu单线程,会产生不可预测的结果,如下代码:

// cpu1
int number = 1;
int flag = true;
 
// cpu2
if(flag){
    print(number);
}

cpu1和cpu2分别执行上述各自代码,cpu1负责设置number和flag的值,cpu2负责判断和输出值,这段代码的意图是:cpu1先设置number的值为1,然后设置flag的值为true,cpu2判断flag的值为true时,输出number的值为1。如果按照单cpu单线程的规定:number和flag不存在依赖,可以重排序,即cpu1可以先设置flag的值为true,然后cpu1还没有来得及设置number的值时,cpu2就开始执行了,cpu2判断if(flag)成立,但输出number的值不为1,执行结果违背了这段代码的初衷,为了保证cpu2输出的结果始终一致,引入了SC内存模型。

多cpu的内存模型有多种,最基础的是SC模型,其他模型都是从SC模型转换来的。SC全称Sequential Consistency,顺序一致性模型。
SC有两个规定:
1)每个cpu内的程序顺序都不能重排;
2)所有cpu的执行都有一个单一全局的内存顺序(队列)。
如下图:
SC模型:多CPU程序操作和内存访问
C的第一条规定:从程序执行的角度来看:每个cpu内的程序顺序都不能重排。单CPU的不同线程是顺序访问内存的,一个线程执行完的内存顺序是一致的,对下一个线程没有影响;但在多CPU中,多CPU中的线程可以并发访问内存,重排序某个cpu中的共享变量的程序顺序会影响到另一个cpu,就像上面例子中的number和flag,所以SC严格规定每个CPU中的程序顺序都不能重排;

SC的第二条规定:从内存的角度来看:所有的cpu的执行都有一个单一的内存访问顺序,多CPU的程序执行是并发的,如果访问内存也是并发的,不太好控制,如果访问的是不同的内存块影响不大,但如果访问的是同一个块,并发读写会产生不可意料的结果。为了简化,所以SC规定了不论访问的是否是同一块的内容,所有的内存访问都要排序进行。

有了SC严格的限制,程序顺序和内存顺序都是可控的,但是有个缺点就是访问内存串行化,体现不出多CPU并发执行的优势,所以实际中,这种模型并不会被使用。

2.3. TSO内存模型

另一种内存模型叫TSO,英文全称为:Total Store Ordering,中文名称为完全存储顺序,相对于SC模型,这种模型是针对实际的CPU结构做的优化。为了加快CPU读取内存数据,在CPU和内存的中间加入了缓存,预先读取内存中的数据。在多CPU结构中,缓存分为公共缓存和私有缓存,公共缓存是所有CPU共享的缓存,私有缓存是每个CPU独有的,不论是公共缓存还是私有缓存对CPU来说都是黑盒的,CPU只管读取数据,不论读取的数据是怎么来的,同样CPU写数据,也不会关注缓存,所以写缓存要满足两个条件(MESI协议):
1)写缓存的数据自动同步到内存中;
2)所有CPU的私有缓存应该保持一致:一个CPU写缓存,其他CPU的私有缓存都要更新。拥有缓存的多CPU结构如下:
多CPU+缓存结构
CPU操作分为两种:load(读)、store(写),加入缓存的目的是提前缓存内存的数据,提高load的效率,但是store的速度降低了,因为CPU将数据store到内存多了写缓存的步骤,并且需要同步所有CPU的私有缓存。在上面SC模型中,不论load还是store内存中的数据,都要排队执行,如果在CPU+缓存结构中,还是按照SC模型,store操作会严重阻塞后续的load操作,这样加缓存的意义完全就没有了,不仅没能提高load的效率,反而阻塞了load。为了解决load被阻塞的问题,在CPU中加入了新的组件store buffer(写队列),如下图:

带有store buffer的多cpu+缓存结构

每个CPU都有一个store buffer,当CPU需要执行store操作时,会将store操作先放入store buffer中,不会立即执行store操作,等待合适的时机再执行(store buffer满了等等情况会真正执行store操作),在store后面的load操作不用再等store真正执行完毕才能执行,只要store放入了store buffer中,load就可以执行了。store buffer造成的结果就是事实上store-load操作被重排序了:store操作后面的load先执行(store-load表示程序代码里先store后load,store-store,load-store,load-load依旧不能重排序)。

TSO在放松store-load重排序的同时,也较大的放松了对内存访问顺序,TSO只规定了所有CPU的store操作保持单一全局顺序(所有CPU的store访问内存时在一个队列中排队访问内存,不能并发访问内存),而load操作可以并发访问。 相对于上面SC内存访问顺序,TSO模型中多CPU对内存的load操作效率大大提高了。

综上,TSO有两个规定:
1)程序顺序:每个CPU的store-load操作可以重排;
2)内存访问顺序:所有CPU的store操作都有一个单一的全局内存访问顺序。TSO是相对宽松的内存模型,相对于SC,限制更少了,程序执行速度更快,但在多线程编程时,TSO会导致程序的结果变得不可预测了,如下例:

// 初始 a=0, b=0
 
// cpu1
a = 1;  // store操作
x = b;  // load操作
 
// cpu2 
b = 1; // store操作
y = a; // load操作

CPU1和CPU2分别执行各自的代码,观察 x 和 y 的值。在TSO模型中,store-load操作有可能重排序,CPU1和CPU2的第一行代码都是store,第二行代码都是load,所以CPU1和CPU2内存访问顺序和输出结果可能为:
TSO模型程序执行
上图只列出了TSO模型程序执行部分可能出现的情况,得出x和y值有四种情况,无法准确预测x和y的最终值。

2.4. 其他内存模型

PSO:在TSO基础上,解除了所有CPU的store单一全局的内存访问顺序,并允许单个CPU内的store-store重排序。
RMO:完全宽松的内存模型,对内存访问顺序不限制,并且每个cpu内的store和load都可以任意重排序。

常见计算机平台的内存模型和CPU重排序情况:
CPU重排序情况
上表中不同内存模型的CPU中store、load的重排序限制不同,但在数据依赖的情况下,都是不允许重排序的,保证了单线程始终能够正确执行,单线程的执行遵循as-if-serial,给人的感觉是代码一行一行按顺序执行的,但实际上不相互依赖的变量是可以重排序的,不会影响最终结果。

三 JAVA内存模型

java程序可以部署在任何平台上,比如上面的x86和PowerPC等平台,由于x86和PowerPC等平台内存模型不一样,为了屏蔽平台差异性,JMM为java程序提供了统一的内存模型,但在统一内存模型时,JMM会面临两种问题:1)如何保证java代码的执行结果可预测,降低编写多线程代码的难度
2)JMM内存模型不能太严格,否则会牺牲性能

针对这两个问题,JMM的解决方法是提供“足够够用”的内存模型:
1)对线程内的共享变量(类变量、类成员变量、数组)的一致性要求很严格;
2)对线程的非共享变量不做任何处理。

JMM第一条内容:对线程内的共享变量(类变量、类成员变量、数组)的一致性要求很严格。java提供了volatile、synchronized等关键字,严格规定多线程的共享变量可见性和限制重排序。
JMM第二条内容:对线程的非共享变量不做任何处理。一个线程的非共享变量对其他线程没有影响,所以非共享变量的实际执行情况依赖部署平台的内存模型,但是都遵循as-if-serial。

为了准确描述JMM,JMM“约法三章”,一般称为Happen-Before规则。Happen-Before描述了变量可见性和重排序,如果操作A Happen-Before 操作B, 那么操作A先执行,B后执行(程序顺序),并且A的结果对B是可见的(内存顺序)。只要遵守JMM的“约法三章”(Happen-Before),很容易编写多线程程序。

Happen-Before规则如下:
1)监视器的解锁Happen-Before对该监视器的后续加锁
2)对volatile变量的写Happen-Before对该变量的后续读
3)对线程start()方法的调用Happen-Before该线程内的任何操作
4)一个线程内的所有操作Happen-Before其他线程成功join()该线程
5)任意对象的默认初始化Happen-Before程序的其他操作

上面5条Happen-Before规则涵盖了多线程编程中的锁、共享变量读写、线程生命周期和对象初始化等等重要内容,普通开发人员不用深入了解JMM,只需要知道这5条规则,可以很轻松的处理多线程场景。

四 volatile实现原理

上面的Happen-Before规则主要作用是指导开发人员编写多线程程序,对JMM实现原理并没有说明,这里用常见的volatile关键字来介绍JMM的实现原理。

java程序可以部署在不同系统平台上,比如windows系统、linux系统等,这些系统都是基于x86或其他架构的,以x86举例,上面我们介绍过x86的内存模型是TSO,java程序运行时,java多线程最底层是被分配到CPU上执行的,所以部署在x86的java程序,底层的操作也是遵循TSO模型的,而JMM是统一的内存模型,与平台无关的,所以要将x86的TSO转换成JMM,要在底层特殊处理。

不同平台内存模型的差异性在于cpu的读写是否能够重排,如果能够限制读、写重排序,严格规定读写顺序,就可以统一所有平台的内存模型。根据读(load)和写(store)顺序不同,重排序的限制分为4类(专业名词称为内存屏障),如下四种内存屏障:
四种内存屏障

注:上面四种屏障在不同的平台稍有不同,比如在x86中只有3中屏障:
sfence,lfence,mfence,sfence实现了StoreStore Barriers,lfence实现了LoadLoad Barriers,mfence同时满足上面4种屏障。

JMM使用上面四种屏障提供了java程序运行时统一的内存模型,以volatile为例:
1)volatile变量执行写操作,会在写之前插入StoreStore Barriers,在写之后插入StoreLoad Barriers。
解释:volatile变量在执行写操作之前插入StoreStore Barriers,代表在执行volatile变量写之前的所有Store操作都已执行,数据同步到了内存中(将store buffer中的store操作刷新到内存);写之后插入StoreLoad Barriers,代表该volatile变量的写操作也会立即刷新到内存中,其他线程会看到最新值。

2)volatile变量执行读操作,会在读之前插入LoadLoad Barriers,在读之后插入LoadStore Barriers。

解释:volatile变量在执行读操作之前插入LoadLoad Barriers,代表在执行volatile变量读之前的所有Load从内存中获取最新值;在读之后插入LoadStore Barriers,代表该读取volatile变量获得是内存中最新的值。

看下面这道思考题,用volatile变量修饰其中一个变量,满足当cpu2判断flag为true成立时,number的值一定为1:

//初始 number=0, flag=false
// cpu1
int number = 1;
int flag = true;
  
// cpu2
if(flag){
    print(number);
}

答案是:用volatile修饰变量flag。因为cpu1会在执行flag = true之前插入StoreStore Barriers,所以number = 1一定会先执行,并将number的数据刷新到内存中,然后再执行flag = true;cpu2判断flag为true成立时,number的值一定为1。

假设不用volatile修饰变量flag,cpu1可能会重排序,flag = true会先执行;然后cpu2判断flag为true,直接输出number的值,这时还未执行number = 1,输出number的值为0。

五、再来看MESI与JMM的关系

在上面《2.3. TSO内存模型》中介绍了CPU的私有缓存,MESI是私有缓存同步的协议,对上层cpu和下层内存来说是黑盒的,如果连私有缓存的同步都做不到的话,cpu也就无法正常工作了。但mesi协议的引入会导致缓存的写入效率降低,所以引入了store buffer等部件(上面《2.3. TSO内存模型》已经详细介绍了),store buffer将store操作缓存起来,不会立即写入缓存,导致多CPU内的值同步会有一定延迟,间接导致cpu的操作重排序,多cpu的共享变量的操作会发生混乱,所以JMM中可以使用volatile强制刷新store buffer,让多CPU中的值同步没有延迟,保证多CPU共享变量不会发生混乱。
所以MESI与JMM没有直接的关系,MESI协议是黑盒协议。

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值