JMM如何解决并发问题

2021-03-17  本文已影响0人  爱健身的兔子

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解决一致性过程
image

说明:图中有两个CPU,主存中有一个变量X = 0,下面就介绍一个CPU A和CPU B读写X的过程。

  1. 初始状态,cache A和cache B中都没有X的副本,CPU A发起read请求向主内存,主内存会向总线发送ReadResponse,之后CPU A将Cache A的状态更改位E,表示独占。
  2. CPU B发起Read请求,此时CPU A和CPU B同时嗅探总线,发现主内存的变量X不只有一个副本,此时CPU A将Cache A的状态更改位S,CPU B收到ReadResponse之后,更改状态位S。
  3. 假如这时CPU A要修改变量X的值为1,这时CPU A先发起Invalidate请求,当CPU B嗅探该请求之后,会将Cache B的状态更新为I,之后回复Invalidate Acknowledge,当CPU A收到CPU B发送的ack之后,才会更改变量X的值为1,之后更新Cache A的状态为M。
  4. 如果此时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,才可以真正的开始修改缓存中的值。这个过程其实有两个地方要等待。

所以针对这两点,从硬件级别就做了两点优化,引入了store buffer(写缓冲区)和Invalidate queues(失效队列),对应于上面例子中的优化具体如下:

这种一个指令还未结束便去执行其它指令的行为称之为:指令重排

以上两个存储结构的引入的确可以解决MESI协议效率低的问题,但是由于延迟执行却带来了新的问题,就是常见的可见性和有序性的问题,下面就举例分析一下引入上面两个存储结构之后导致的可见性和有序性的问题。

可见性问题

有序性问题

思考这么一段代码

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指令。其基本作用:

在详细介绍内存屏障之前需要先介绍两个指令

内存屏障在不同的硬件有不同的实现,本文介绍一下x86的内存屏障实现

jvm为了屏蔽硬件的差异,定义了自己的内存屏障,其底层是使用硬件的内存屏障。

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定义了线程和主内存之间的抽象关系:

2.2 JMM如何解决可见性

从JMM的抽象模型结构图来看,如果线程A与线程B之间要通信的话,必须要经历下面2个步骤。

  1. 线程A把本地内存A中更新过的共享变量刷新到主内存中去。

  2. 线程B到主内存中去读取线程A之前已更新过的共享变量。

下面通过示意图来说明这两个步骤:

关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:

Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

2.3 JMM如何解决有序性

有序性问题是由于指令重排序导致:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序

  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从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关系:

内存屏障禁止特定类型的处理器重排序:

重排序可能会导致多线程程序出现内存可见性问题。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。上面已经提到过内存屏障了这里就不在赘述。

3 Java如何实现JMM

上面的定义和操作都很抽象,实际JVM为了解决共享变量的可见性提供了如下解决方案:

Synchronize和volatile在CPU层级都是通过Lock前缀指令实现内存屏障的功能,禁止重排序和保障可见性。

MESI(缓存一致性协议) - 猿起缘灭 - 博客园

Java内存模型(JMM)总结

上一篇 下一篇

猜你喜欢

热点阅读