[笔记]JMM和MESI

286 阅读9分钟

JMM

参考

juejin.cn/post/691939…

JMM

jmm - JMM全称为java memory model,直译java内存模型,规定java程序中线程间通信的规则,主要规定了以下规则:

  • as-if-serial
  • happens-before

以及一些并发条件下的基础特性(主要是有序性)保证,决定了java中多线程编程的模式。

JMM对于上层而言是完全透明的,使用时只需要了解规则即可。


通俗点来说,JMM相当于线程条件下Java代码和底层操作系统(内存-CPU)之间的一个中间层,保证线程/多线程执行时的程序语义可以正确(至少是按照程序员的理解上正确)传递到底层操作系统。

两个规则/语义

这两个规则事实上都是对线程中的顺序性做出保证。

as-if-serial

(单线程条件下)

顾名思义,前后依赖相关的必须保证前后一致,即使经过重排之后,相关的顺序也必须保证一致。

happens-before

(多线程条件下)

在JUC中的package-info文件中简单描述了happens-before在java中的一个现象:

 The results of a write by one thread are guaranteed to be visible to a read by another thread only if the write operation happens-before the read operation. 

且指出了几个可以构造happens-before关系的案例:

 The {@code synchronized} and {@code volatile} constructs, as well as the
 {@code Thread.start()} and {@code Thread.join()} methods, can form
 happens-before relationships.

这是对于这些范式的描述(在可以正常通过编译,逻辑顺序正常的情况下)

  • synchronized

    • 加锁总在本线程释放锁之前;如果有锁,总在其他线程释放锁之后
  • volatile

    • Volatile变量写总在读之前。
    • Volatile变量的读写和monitor的进入退出在内存一致性的影像上相似,但并不包括互斥锁。
  • Thread.start,Thread.join

    • start的调用总在线程内代码执行之前被调用
    • join总在任何其他线程成功返回之后被调用。

    并且,JUC中的所有类和子包,都保证了happens-before。

基础特性保证

我们知道:并发条件下代码一般需要保证三特性的相对稳定

  • 可见性
  • 原子性
  • 有序性

JMM事实上是对于有序性的保证,

总结

JMM有2个重要规则:

  • as-if-serial
  • happens-before

分别规定了单线程情况下以及多线程下的相对有序性

其中:

  • as-if-serial在单线程主要是保证与变量声明与修改相关的代码必须按照声明顺序进行,而不会被重排序干扰。
  • happens-before主要是保证在多线程并发的情况下,在关键字或底层native方法正确使用的条件下,代码可以按照预定的顺序进行。

并提供了volatile和synchronized关键字,来让程序员通过这两个关键字,利用到happens-before原则控制代码运行。

JMM总体上对程序员而言是透明的。在实际开发中很少有人会去考虑重排序的问题,因为这是语言必须具备的一个基础保证,而JMM就是java语言做出的保证。

MESI

参考

juejin.cn/post/689379…

MESI

首先,MESI是什么?

  • 通俗点说,MESI指的是CPU缓存一致性协议。

高速缓存

根据计算机的发展历程,我们知道:

  • CPU发展速度很快
  • 然而内存和硬盘发展速度不及CPU

这些是从运行基础以及成本上决定的,(内存不清楚)而硬盘是由于是物理读写而导致无论如何随机读写速度都慢,SSD缓解了HDD由于物理结构而导致的随机读写慢的问题。

因此,CPU需要自己负责一部分关键数据的存储,这块存储就被称为高速缓存,来缓解IO速度与CPU运算速度不匹配的问题。

需要提前知道的是:

  • 和数据库类似,每一个不可分割的存储单元,在告诉缓存中都称为cache line

高速缓存原则

引入了这块高速缓存,那么我们需要至少知道一个问题:

  • 根据什么规则,把数据刷到高速缓存中?

这里有两个经验特性,并称为局部性原理

时间局部性(Temporal Locality) :如果一个信息项正在被访问,那么在近期它很可能还会被再次访问。

比如循环、递归、方法的反复调用等。

空间局部性(Spatial Locality) :如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。

比如顺序执行的代码、连续创建的两个对象、数组等。

根据这两个经验特性,我们就能判断需要把那些数据放到高速缓存中了。

高速缓存执行流程

  1. 程序以及数据被加载到主内存
  2. 指令和数据被加载到CPU的高速缓存
  3. CPU执行指令,把结果写到高速缓存
  4. 高速缓存中的数据写回主内存

多级缓存

因为CPU的速度对于IO而言快得多,因此为了进一步地缓解速度不一致问题,CPU厂商引入了多级缓存(一般是3级)。

多核心引入MESI

由于摩尔定律的失效以及CPU厂商为了保证性能的进步,CPU也引入了多核CPU。

那么多核心+高速缓存,临界区的变量修改必然是需要做行为规范的,否则高速缓存的数据就会出现污染的情况。这里就引入了MESI,作为一致性的保证。

引入的问题

缓存行伪共享

由于高速缓存的存储单位是缓存行(一般来说,当前的CPU缓存行大小都为64byte),这样可能会导致一个缓存行中存在着复数个变量。如果一个发生了修改,可能会导致共享缓存行的其他变量被预期之外地刷新。

这种行为就称为伪共享

解决伪共享最直接的方法就是对每个变量都保证其缓存行中存在的变量个数是唯一的。java8提供了这样的能力:

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

协议规范

MESI其实指的是cache line4个状态。

  • M - modified 修改 - 指的是CPU已完成修改操作,但未写入内存
  • E - Exclusive 互斥 - 指和内存中一致且数据只存在于本cache中
  • S- shared 共享 - 指和内存中一致且存在于多cache中
  • I - invalid 无效 - 本行无效

这里只有M和E状态是确定的,S状态是非一致的,需要等待其他的通知。

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

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

流程上,简单地说,M和E是本地已知的,S和I需要等待其他的缓存行事件通知,因此S和I可能因为延迟,缓存行未来得及及时调整。

  • S发生M事件:S-》E》M,其他cache变为I

问题、优化、问题

那么就可能存在一定的问题:

  • 为了保证数据的一致性,一旦发生了M事件,此时需要将I状态同步到其他缓存行并等待确认(否则这一数据的读就会发生了不一致)。所有的失效确认都被收到时才进行下一步动作。

    • 这个动作必然会阻塞CPU,影响性能。

为了解决这个问题,CPU引入了一个 store bufferes,作为M事件的缓存:

  • 主存到缓存的写入时,会被写到这里作为确认,处理器就去干其他事情了。
  • 失效确认的接收就放到这里了,收到了所有的失效确认,数据才从这里写入缓存。

也就是说这里的store bufferes起到的作用类似于处理的中转站。对于性能的提升固然是很有帮助的,但是引入了一个新的问题:

  • 这里收到失效确认并写入的时间是不可确认的。

那么就会有2个风险:

  • 处理器从这里读取值的时候可能还没有发生提交,那么读出来的数就不一致了。

这个的解决方案称为 Store Forwarding,它使得加载的时候,如果存储缓存中存在,则进行返回。

  • 保存的时间并没有保证,无关变量(这里指的是值上无关的变量)的读写顺序可能会和程序的顺序不一致。

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

这里就需要引入一个内存屏障,文中描述如下:

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

  • 对于所有的收到的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)是一条告诉处理器在执行任何的加载前,先应用所有已经在失效队列中的失效操作的指令。