CPU高速缓存与JMM

一、从CPU高速缓存到内存屏障

1.1 硬件内存架构

分为CPU内部和外部

  • CPU内部:有CPUL0、L1、L2缓存;
  • CPU外部:CPU外部就是主存、磁盘。

【数据同步八大原子操作(硬件层面,cpu) 】

  • lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态 ;
  • unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后 的变量才可以被其他线程锁定 ;
  • read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存 中,以便随后的load动作使用 ;
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中 ;
  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎 ;
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内 存的变量 ;
  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存 中,以便随后的write的操作 ;
  • write(写入):作用于工作内存的变量,它把store操作从工作内存中得到的变量值放入主内存的变量中。

工作内存操作主内存数据的步骤如下图:

1.2 CPU高速缓存

1.2.1 CPU高速缓存概念

CPU缓存是位于CPU与主内存之间的临时数据交换器,它的容量比内存小的多但是交换速度却比内存要快得多。CPU缓存一般直接跟CPU芯片集成或位于主板总线互连的独立芯片上。每个处理器都有其高速缓存,在缓存容量上,通常是内存 > L3 > L2 > L1,容量越小速度越快。其中L1和L2是由每个CPU核心独享的,L3缓存是由所有CPU核心共享的。如下图所示:

CPU的读(load)实质上就是从缓存中读取数据到寄存器(register)里,在多级缓存的架构中,如果缓存中找不到数据(cache miss),就会层层读取二级缓存三级缓存,一旦所有的缓存里都找不到对应的数据,就要去主内存里寻址了。寻址到的数据首先放到寄存器里,其副本会驻留到CPU的缓存中。

CPU的写(store)也是针对缓存作写入。并不会直接和内存打交道,而是通过某种机制实现数据从缓存到内存的写回(write back)

为了简化与内存之间的通信,高速缓存控制器是针对数据块,而不是字节进行操作的。高速缓存其实就是一组称之为「缓存行(Cache Line)」的固定大小的数据块组成的,典型的一行是64字节

1.2.2 CPU高速缓存意义

CPU往往需要重复处理相同的数据、重复执行相同的指令,如果这部分数据、指令CPU能在CPU缓存中找到,CPU就不需要从内存或硬盘中再读取数据、指令,从而减少了整机的响应时间。所以,缓存的意义满足以下两种局部性原理:

  • 时间局部性(Temporal Locality):如果一个信息项正在被访问,那么在近期它很可能还会被再次访问。
  • 空间局部性(Spatial Locality):如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。

1.3 缓存一致性问题

由于现在一般是多核处理器,每个处理器都有自己的高速缓存,那么会出现「缓存一致性问题」

  • 某一个数据多个处于“运行”状态的线程中进行读写共享时(例如ThreadA、ThreadB和ThreadC)
    • 第一个问题是多个线程可能在多个独立的CPU内核中“同时”修改数据A,导致系统不知应该以哪个数据为准;
    • 第二个问题是由于ThreadA进行数据A的修改后没有即时写回内存,ThreadB和ThreadC也没有即时拿到新的数据A,导致ThreadB和ThreadC对于修改后的数据不可见。这就是缓存一致性问题。

1.3.1 解决缓存一致性的解决方案

针对缓存一致性问题的几种操作方式:

  • 通写法(Write Through):每次 CPU 修改了缓存内容,立即更新到内存,也就意味着每次 CPU 写共享数据,都会导致总线事务。
  • 回写法(Write BACK):每次 CPU 修改了缓存数据,不会立即更新到内存,而是等到某个合适的时机才会更新到内存中去。
  • 写失效:当CPU写入一个缓存副本时,其它缓存中的副本将置为失效,这种方式能够确保CPU只能读写一个数据的副本,其它核心的副本都是无效的,这种手段也是现代CPU最常见的手段之一,MSI、MESI、MOSI、MOESI、MESIF协议都属于这类。
  • 写更新:当CPU写入一个缓存副本时,其它缓存中的副本将会通过CPU内部总线进行更新,相当于数据更新的一次广播,这种手段会引起总线的流量增大,所以比较少见,Dragon、firefly协议属于这类。

在 CPU 层面,提供了两种解决方案:

  1. 总线锁:处理器提供 lock# 信号,当其中一个核心在总线上输出 lock# 时,其它处理器的请求将会被阻塞,该处理器独占内存。总线锁这种做法锁定的范围太大了,导致CPU利用率急剧下降,因为使用 lock# 是把CPU和内存之间的通信锁住了,这使得锁定时期间,其它处理器不能操作其内存地址的数据 ,所以总线锁的开销比较大。
  2. 缓存锁:降低了锁的粒度,基于缓存一致性协议来实现。当其它核心更新了数据写回被核心1锁定的缓存行时,缓存将会失效。但是并不是所有情况缓存锁定都会有效,有两种情况会降级为总线锁:
    1. 数据跨多个缓存行的情况,缓存锁定将会失败,转而降级为总线锁
    2. 老的CPU不支持缓存锁定。

缓存一致性协议需要满足以下两种特性: 

  • 写传播(Write propagation):一个处理器对于某个内存位置所做的写操作,对于其他处理器是可见的。写传播可分为以下两种方式:
    • 「总线嗅探」(Snooping ):广播机制,即要监听总线上的所有活动。CPU的写事务会被窥探器在总线上嗅探到,窥探器会检查该变量的副本是否在其他核心缓存上也有一份,如果有该副本,则窥探器会执行策略来保证副本的一致性,这个策略可以是写更新或写失效,这取决于缓存一致性协议的实现。
    • 基于目录(Directory-based):点对点,总线事件只会发给感兴趣的 CPU (借助 directory)。
  • 写串行化(Write Serialization):对同一内存单元的所有写操作都能串行化。即所有的处理器能以相同的次序看到这些写操做。
    • 对于写串行化:总线上任意时间只能出现一个 CPU 的写事件,多核并发的写事件会通过总线仲裁机制将其转换成串行化的的写事件序列。

1.3.2 MESI协议_基于总线嗅探

MESI(Modified-Exclusive-Shared-Invalid)协议是一种广为使用的缓存一致性协议。MESI协议对内存数据访问的控制类似于读写锁,它使得针对同一地址的读内存操作是并发的,而针对同一地址的写内存操作是独占的

之所以叫 MESI,是因为这套方案把一个「缓存行(cacheline)」区分出四种不同的状态标记,他们分别是 Modified、Exclusive、Shared 和 Invalid。这四种状态分别具备一定的意义:

  • Modified(M):表示这个 cacheline 已经被修改,但是还未写回主存(cache中数据与主存中数据不一致!所有其它处理器core 对这个 cacheline的读操作必须在该 cacheline 写回主存后,写回主存后,状态变换到 share )。
  • Exclusive(E):表示这个 cacheline 目前是独有的且与主存一致,可以转换到shared 或者M,重点是可以转换到M,也就意味着可以对其修改。
  • Shared(S):表示这个 cacheline 在其它 处理器core 的 cache 中也存在,目前也与主存一致,随时会被 invalidate(失效)。
  • Invalid(I):表示这个cacheline 目前不可用(失效)。

这些状态本身是静态的,那么动态来看,又是如何产生状态变化的呢?

首先不同CPU之间也是需要沟通的,这里的沟通是通过在消息总线上传递message实现的。这些在总线上传递的消息有如下几种:

  • Read :带上数据的物理内存地址发起的读请求消息;
  • Read Response :Read 请求的响应信息,内部包含了读请求指向的数据;
  • Invalidate :该消息包含数据的物理内存地址,意思是要让其他如果持有该数据缓存行的 CPU 直接失效对应的缓存行;
  • Invalidate Acknowledge :CPU 对 Invalidate 消息的响应,目的是告知发起 Invalidate 消息的CPU,这边已经失效了这个缓存行啦;
  • Read Invalidate :这个消息其实是 Read 和 Invalidate 的组合消息,与之对应的响应自然就是一个Read Response 和 一系列的 Invalidate Acknowledge;
  • Writeback :该消息包含一个物理内存地址和数据内容,目的是把这块数据通过总线写回内存里。

1. 一般情况下,CPU 在对某个缓存行修改之前务必得让其他 CPU 持有的相同数据缓存行失效,这是基于 Invalidate Acknowledge 消息反馈来判断的;

2. 缓存行为 M 状态,意味着该缓存行指向的物理内存里的数据,一定不是最新;

3. 在修改变量之前:

  •  如果CPU持有该变量的缓存行:为 E 状态,直接修改;为 S 状态 ,需要在总线上广播 Invalidate;
  • 若CPU不持有该缓存行,则需要广播 Read Invalidate。

1.3.3 MESI协议导致的问题(引入指令重排解决)

这个极简的 CPU 缓存架构存在一定的问题,当相当一部分 CPU 持有相同的数据时(S 状态),如果其中有一个 CPU 要对其进行修改,则需要等待其他 CPU 将其共同持有的数据失效,那么这里就会有空等期(stall),甚至一旦某一个内核发生阻塞,将会导致其他内核也处于阻塞,从而带来性能和稳定性的极大消耗。这对于频率很高的CPU来说,简直不能接受!

所以这时「指令重排」开始发挥它的价值。想想这种等待有时是没有必要的,因为在这个等待时间内内核完全可以去干一些其他事情,即当内核处于等待状态时,不等待当前指令结束接着去处理下一个指令

缓存一致性协议导致的「伪共享问题」

因为读取缓存时以缓存行(Cache Line)为单位,每个缓存行64字节。例如:有两个变量a和b在同一个缓存行中,有两个线程,线程1会修改a的值,线程2修改b的值。当线程1读取变量a时,会将b一起读取到cpu缓存中(因为是以缓存行为单位),线程1修改a值后,其他包含这个缓存行都会失效(标记为i)。当线程b想要修改b的值时,发现b所在的缓存行被表示为失效,需要重新去主存中读取。做这种无用功操作就是伪共享问题。

「伪共享问题」解决方案

1. 使用缓存对齐 Padding

缓存读取是以缓存行为单位,一个缓存行64字节。为了防止伪共享问题,我们只需要把缓存行沾满即可(空间换时间).

以Long类型为例子,Long类型是8字节,那么占满是8个Long类型。我们只需要在a和b之间加上7个Long类型(为了避免无用字段被消除,通常我们会用volatile修饰一下),a和b就不会在同一个缓存行了。

2. @Sum.misc.Contended 注解(JDK8) (-XX:RestrictContended)

告诉JVM应当将字段放入不同的缓存行。注解可以使用在类上,也可以使用在变量上。

1.4 Store Buffers_存储缓冲区

1.4.1 引入 Store Buffers

前面介绍了「指令重排」将会减少处理器的等待时间进而去处理其他的指令,这种一个指令还未结束便去执行其它指令的行为称之为指令重排亦或流水线乱序,就是CPU未按用户的代码预期执行

这里引入了 Store buffers :

Store buffers 是一个 CPU核心在真正写入CPU缓存之前的缓冲区,位于CPU核心和CPU缓存之间,缓冲区作用在于 CPU 无需等待其他 CPU 的反馈,把要写入的数据先丢到 Store Buffer 中,通知其他内核,然后当前内核即可去执行其它指令。当收到其他内核的响应结果后,再把store buffer中的数据写回缓存,并修改状态为M,避免了CPU的傻等。(很类似分布式中,数据一致性保障的异步确认)

1.4.2 Store Buffers 引起的问题

引入 Store buffers 之后又带了新的问题,单个 CPU 在顺序执行指令的过程中,有可能出现:前面的已经执行写入变更,但对后面的代码逻辑不可见

举个例子:假设 a , b 初始值为0,

a=1 
b=a+1 
assert(b==2)

cpu对a赋值为1,此时a变量进入到store buffer,缓存中的a还是等于0,此时执行 b=a+1 得到的结果是b=1,assert不通过。

1.5 Store Forwarding_存储转发

1.5.1 引入Store Forwarding

解决方案就是采用 Store Forwarding :

对于同一个 CPU 而言,在读取 a 变量的时候,如若发现 Store Buffer 中有尚未写入到缓存的数据 a,则直接从 Store Buffer 中读取。这就保证了,逻辑上代码执行顺序,也保证了可见性

1.5.2 Store Forwarding 引起的问题

通过 Store Forwarding 解决了单个 CPU 执行顺序性和内存可见性问题,但是在全局多 CPU 的环境下,这种内存可见性恐怕就很难保证了。

void foo(void) {
    a = 1;
    b = 1;
}

void bar(void) {
    while (b == 0) continue;
    assert(a == 1);
}

假设上面的 foo() 方法被 CPU0 执行,bar() 方法被 CPU1 执行,也就是我们常说的多线程环境。试想,即便在多线程环境下,foo() 和 bar() 如若严格按照理想的顺序执行,是无论如何都不会出现 assert failed 的情况的。但往往事与愿违,这种看似很诡异的且有一定几率发生的 assert failed ,结合上面所说的 Store Buffer 就一点都不难理解了。

我们来还原 assert failed 的整个过程 :

  • 假设 a,b 初始值为 0 ,a 被 CPU0 和 CPU1 共同持有,b 被 CPU0 独占;
  • CPU0 处理 a=1 之前发送 Invalidate 消息给 CPU1 ,并将其放入 Store Buffer ,尚未及时刷入缓存;
  • CPU 0 转而处理 b=1 ,此时 b=1 直接被刷入缓存;
  • CPU 1 发出 Read 消息读取 b 的值,发现 b 为 1 ,跳出 while 语句;
  • CPU 1 发出 Read 消息读取 a 的值,发现 a 却为旧值 0,assert failed。

在日常开发过程中也是完全有可能遇到上面的情况,由于 a 的变更对 CPU1 不可见,虽然执行指令的时序没有真正被打乱,但对于 CPU1 来说,这造成了 b=1 先于 a=1 执行的假象,这种看是乱序的问题,通常称为 “重排序”。当然上面所说的情况,只是指令重排序的一种可能。

1.6 Memory Barriers_内存屏障

1.6.1 引入 Memory Barriers

解决办法就是 Memory Barrier(内存屏障),借助内存屏障可以很好地保证了「有序性」,内存屏障的功能有两个:

  • 阻止屏障两边的指令重排;
  • 刷新处理器缓存(保证内存可见性)。

例子如下:

void foo(void) {
    a = 1;
    smp_mb();
    b = 1;
}

void bar(void) {
    while (b == 0) continue;
    assert(a == 1);
}

这个屏障可以理解为两条指令之间的栅栏(fence),比如在上面的 foo() 方法中,a 的赋值和 b 的赋值之间势必要执行这个栅栏。这个栅栏有什么用呢?smp_mb() 首先会使得 CPU 在后续变量变更写入之前,把 Store Buffer 的变更写入 flush 到缓存;CPU 要么就等待 flush 完成后写入,要么就把后续的写入变更放到 Store Buffer 中,直到 Store Buffer 数据顺序刷到缓存。

CPU 的设计者兼顾性能和指令重排序之间做了权衡,认为其实在大多数场景下,多线程环境下的指令重排序和可见性问题是可以接受的,并且这有助于 CPU 发挥出该有的性能。如果真的有特殊需求,我们可以借助内存屏障来解决,虽然有一定的代码侵入性,但是这样的 tradeoff(权衡) 是相当划算的。

1.6.2 Memory Barriers 引起的问题

然而从目前设计看,还依然有问题。试想这么一个场景,CPU 写入一大串数据到 Store Buffer,而这些缓存行均被其他 CPU 持有,那么此时这个 CPU 需要等待一系列的 Invalidate Acknowledge 反馈后才能将这批数据 flush 到缓存行。

这里存在的问题是,Store Buffer 本身很小,如果写入变更指向的变量在CPU 本地缓存中均是 cache miss 的情况下,变更数量超过了 Store Buffer 能承载的容量,CPU 依然需要等待 Store Buffer 排空后才能继续处理。尤其是执行 Memory Barrier 以后,无论本地缓存是否 cache miss,只要 Store Buffer 还有数据,所有的写入变更都要进入 Store Buffer。这就导致 CPU 依然存在的空等(stall)现象。

1.7 Invalidate Queues_失效队列

CPU 设计者的思路是,尽可能减少 Invalidate Acknowledge 的时延,以减少CPU的无谓等待。目前的方案是,CPU 一旦收到 Invalidate 消息,先是会去缓存中标记该缓存状态为 I ,标记完毕后发送 invalidate ack 到消息总线。那如果 CPU 接收到 invalidate 消息,立马反馈 invalidate ack,而cache line 此时也并非强制要求马上失效,只要确保最终会失效即可,这样的思路是否可以呢?

可以,基于这个思路, Invalidate Queues 应运而生。

每个 CPU 都有一个 Invalidate Queue,用以把需要失效的数据物理地址存储起来,根据这个物理地址,我们可以对缓存行的失效行为 “延后执行” 。这样做的好处上面也说过,又一次释放了 CPU 的发挥空间,但依然有额外的副作用。继续来看上面的例子:

void foo(void) {
    a = 1;
    smp_mb();
    b = 1;
}

void bar(void) {
    while (b == 0) continue;
    assert(a == 1);
}

引入 Invalidate Queue 后,assert failed 死灰复燃。我们来重现下。假设 a,b 初始值为0,CPU0 执行 foo() 方法,CPU1 执行 bar() 方法,a 则被 CPU0 和 CPU1 共同持有,b 被CPU0 独占 :

  • CPU0 执行 a=1,由于缓存行状态为 S ,需要发送 Invalidate 消息到总线;
  • CPU1 接收到 Invalidate 消息,将数据内存地址放入 Invalidate Queue 后立马反馈 Invalidate Acknowledge;
  • CPU 0 收到反馈后,把 a=1 从 Store Buffer 刷到缓存后,执行 b=1,b 的新值 1 直接被写入到了 CPU 0 的缓存中;
  • CPU1 执行 while 语句,通过发送 Read 指令查询 b 的值,此时 b 为 1,跳出 while;
  • CPU1 执行 assert(a==1) ,a 的 invalidate 信息还在 Invalidate Queue 中,CPU1 缓存中的 a 仍然是旧值 0,assert failed。

表象上看依然是指令执行顺序被打乱了,这似乎用 Memory Barrier 也有问题呀,解决方案就是要使用更多的 Memory Barrier 。

void foo(void) {
    a = 1;
    smp_mb();
    b = 1;
}

void bar(void)
{
    while (b == 0) continue;
    smp_mb();
    assert(a == 1);
}

不过这里的 smp_mb() 有更丰富的语义,除了与 Store Buffer 的交互外,一旦执行到 smp_mb() 指令,CPU 首先将本地 Invalidate Queue 的条目全部标记,并且强制要求 CPU 随后的所有读操作,务必等待 Invalidate Queue 中被标记的条目真正应用到缓存后方能执行。这就很好解决了上面的重排序问题,但同理,会带来一定程度的性能损耗。

1.8 读内存屏障smp_rmb 和 写内存屏障smp_wmb

还有一个小问题,smp_mb 包含的语义有些“重”,既包含了 Store Buffer 的 flush,又包含了 Invalidate Queue 的等待环节,但现实场景下,我们可能只需要与其中一个数据结构打交道即可。于是,CPU 的设计者把 smp_mb 屏障进一步拆分为二smp_rmb 称之为读内存屏障smp_wmb 称之为写内存屏障。他们分别的语义也相应做了简化:

  • smp_wmb(StoreStore):执行后需等待 Store Buffer 中的写入变更 flush 完全到缓存后,后续的写操作才能继续执行,保证执行前后的写操作对其他 CPU 而言是顺序执行的;
  • smp_rmb(LoadLoad):执行后需等待 Invalidate Queue 完全应用到缓存后,后续的读操作才能继续执行,保证执行前后的读操作对本 CPU 而言是顺序执行的;

X86架构的内存屏障(cpu层面),使用cpu内存屏障(硬件) :

回到 Java 语言,JVM 是如何实现自己的内存屏障的?抽象上看 JVM 涉及到的内存屏障有四种(屏障类型指令示例说明):

其实JVM是屏蔽了不同处理器架构的差异,提供了统一化的内存屏障,在CPU硬件层面不同处理器架构有不同的内存屏障,例如X86架构的内存屏障有4种: ifence、sfence、mfence、lock前缀指令。

JVM 是如何分别插入上面四种内存屏障到指令序列之中的呢?这里的设计相当巧妙。

对于 volatile 读 or monitor enter :

int t = x; // x 是 volatile 变量
[LoadLoad]
[LoadStore]
<other ops>

对于 volatile 写 or monitor exit :

<other ops>
[StoreStore]
x = 1; // x 是 volatile 变量
[StoreLoad] // 这里带了个尾巴

二、JMM——Java内存模型

2.1 并发编程的三大特性

原子性可见性有序性。只要有一条原则没有被保证,就有可能会导致程序运行不正确。volatile关键字被用来保证可见性和有序性,即保证共享变量的内存可见性以解决缓存一致性问题。一旦一个共享变量被 volatile关键字修饰,那么就具备了两层语义:内存可见性禁止进行指令重排序

  • 原子性:就是一个操作或多个操作中,要么全部执行,要么全部不执行。
    • 例如:账户A向账户B转账1000元,这个么过程涉及到两个操作,(1)A账户减去1000元 (2)B账户增加1000元。这么两个操作必须具备原子性。否则A账户钱少了,B账户没增加。
  • 有序性: 程序执行顺序按照代码先后顺序执行。
    • 处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致(指令重排),但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。(此处的结果一致指的是在单线程情况下)
    • 指令重排的理解:单线程侠,如果两个操作更换位置后,对后续操作结果没有影响,可以对这两个操作可以互换顺序。
  • 可见性: 可见性是指多线程共享一个变量,其中一个线程改变了变量值,其他线程能够立即看到修改的值。

2.2 JMM概念

JMM(Java Memory Model)是用来屏蔽各种硬件和操作系统的内存访问差异,实现让Java程序在各种操作系统平台下都能达到一致的访问效果。Java内存模型(Java Memory Model简称JMM) 是一种抽象的概念,并不真实存在,描述了JAVA程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的细节

JMM规定所有变量都存储在主内存,主内存是共享内存区域,所有的线程都可以访问。当线程有对这个变量有操作时,必须把这个变量从主内存复制一份到自己的工作空间中进行操作操作完成后,再把变量写回主内存,不能直接操作主内存的变量。不同的线程无法访问其他线程的工作内存。

2.3 JMM和硬件内存架构的关系

JMM(Java内存模型)对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽象的概念,是一组规则,并不实际存在。不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机的内存中,当然也有可能存储到CPU缓存或者寄存器中。

2.4 JVM对Java内存模型(JMM)的实现

在JVM内部,JMM把内存分为两部分:线程栈区堆区。JVM运行过程中,每个线程都有自己的线程栈,线程栈包含了线程执行的方法相关信息,我们称为调用栈,是线程私有的。堆主要存储的是对象,线程共享的。

  • 局部变量:
    • 对于基本数据类型的局部变量,会直接保存在栈中。他们的值是线程私有的,不会共享。
    • 对于引用类型的局部变量:栈中保存的是对象的引用,对象实际存储在堆中。
  • 成员变量: 对于成员变量,无论是基本数据类型还是引用类型,会有存储到堆中。
  • static类型的变量:不管是基础数据类型还是引用类型,都直接存储在堆中。

2.5 Happens-Before

最后我们强调一点,前面说这么多原理,包括四个内存屏障,到底是为了什么?其实JMM为的就是提供给程序员一个编程规则:happen-before

2.5.1 JMM的设计意图

要学习 happens-before 这里首先介绍下JMM的设计意图。这个问题首先从实际出发:

  1. 我们程序员写代码时,是要求内存模型易于理解,易于编程,所以我们需要依赖一个强内存模型来编码。 也就是说向公理一样,定义好的规则,我们遵守规则写代码就完事了。
  2. 对于编译器和处理器的实现来说,它们希望约束尽量少一些,毕竟你限制它们肯定影响它们的执行效率,不能让他们尽己所能的优化来提供性能。所以他们需要一个弱内存模型。

好了,上面谈到的这两点明显就是冲突的,作为程序员我们希望JMM提供给我们一个强内存模型,而底层的编译器和处理器又需要一个弱内存模型来提高自己的性能

在计算机领域,这种需要权衡的场景非常多,比如内存和CPU寄存器,就引入了CPU多级缓存来解决性能问题,不过也引入了多核cpu并发场景下的各种问题。 所以这里也一样,我们需要找到一个平衡点,来满足程序员的需求,同时也尽可能满足编译器和处理器限制放松,性能最大化

因此JMM在设计时,定义了如下策略:

  • 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
  • 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM允许这种重排序)。

2.5.2 happens-before 规则

happens-before是JMM最核心的概念。

SR-133 提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。 与程序员密切相关的 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 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。
  • join()规则 :如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作 happens-before 于线程A从ThreadB.join()操作成功返回。

通过上面6种 happens-before 规则的组合就能为我们程序员提供一致的内存可见性。 常用的就是规则1和其他规则结合,为我们编写并发程序提供可靠的内存可见性模型。

「SR -133内存模型」,这是新的Java内存模型标准(JMM)。它主要的目的就是为了阐述基于内存操作时,进程(线程)之间的可见性原则 。之前老版本的Java内存模型存在很大的争议性,也有很多严重的问题,所以JDK5以后引入了SR-133内存模型

注意,两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。

2.5.3 happens-before总结

在 Java 语言里面,Happens-Before 的语义本质上是一种可见性,A Happens-Before B 意味着 A 事件对 B 事件来说是可见的,无论 A 事件和 B 事件是否发生在同一个线程里。例如 A 事件发生在线程 1 上,B 事件发生在线程 2 上,Happens-Before 规则保证线程 2 上也能看到 A 事件的发生。

JMM的设计分为两部分,一部分是面向我们程序员提供的,也就是happens-before规则,它通俗易懂的向我们程序员阐述了一个强内存模型,我们只要理解 happens-before规则,就可以编写并发安全的程序了。 另一部分是针对JVM实现的,为了尽可能少的对编译器和处理器做约束,从而提高性能,JMM在不影响程序执行结果的前提下对其不做要求,即允许优化重排序。 我们只需要关注前者就好了,也就是理解happens-before规则。毕竟我们是做程序员的,术业有专攻,能写出安全的并发程序就好了。

  • 13
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值