学妹要我教她 JMM


JMM 内存模型

为什么需要内存模型?

我们知道 CPU 的运算速度是很快的,与内存也有几个数量级上的差距,所以一般现代计算机系统都会在内存与 CPU 之间加入一层或多层读写速度尽可能接近 CPU 运算速度的高速缓存来作为缓冲。

将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

为此,这不可避免的带来了一个新的问题:缓存一致性(Cache Coherence)。

就是说当多个 CPU 的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。如果真的发生这种情况,那同步回到主内存时该以谁的缓存数据为准呢?

为了解决一致性的问题,需要各个 CPU 访问缓存时都遵循一些协议,在读写时要根据协议来进行操作。于是,我们引出了内存模型的概念,模型图如下:

image-20220327113153615

在物理机层面,内存模型可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。

显然,不同架构的物理机器可以拥有不一样的内存模型,而 Java 虚拟机也拥有自己的内存模型,称为 Java 内存模型(Java Memory Model,JMM),其目的就是为了屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果

当然了,JMM 与这里我们介绍的物理机的内存模型具有高度的可类比性。

Java 内存模型是什么东东

JMM 规定了所有的变量都存储在主内存(Main Memory)中,每条线程还有自己的工作内存(Working Memory)。线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。模型图如下:

image-20220327113159452

此处的主内存可以与前面所说的物理机的主内存类比,当然,实际上它仅是虚拟机内存的一部分,工作内存可与前面讲的高速缓存类比。

注意:这里说的变量其实和我们日常编程中所说的变量不一样,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后面这俩是线程私有的,不会被共享,自然就不会存在竞争问题。

原子性

什么是原子性

JMM 中定义了以下 8 种操作规范来完成一个变量从主内存拷贝到工作内存、以及从工作内存同步回主内存这一类的实现细节。Java 虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的

原子性一般是指原子性操作,意思就是不可被中断的一个或一系列操作。

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

具体流程如下:

image-20220327113210279

如何保证原子性

由 Java 内存模型来直接保证的原子性变量操作包括 readloadassignusestorewrite这 6 个,我们大致可以认为,基本数据类型的访问、读写都是具备原子性的(例外就是 long 和 double 的非原子性协定,各位只要知道这件事情就可以了,无须太过在意这些几乎不会发生的例外情况)。

如果应用场景需要一个更大范围的原子性保证,Java 内存模型还提供了 lockunlock 操作来满足这种需求。

尽管 JVM 并没有把 lockunlock 操作直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorentermonitorexit 来隐式地使用这两个操作。这两个字节码指令反映到 Java 代码中就是同步块 — synchronized 关键字,因此在 synchronized 块之间的操作也具备原子性。

而除了 synchronized 关键字这种 Java 语言层面的锁,juc 并发包中的 java.util.concurrent.locks.Lock 接口也提供了一些类库层面的锁,比如 ReentrantLock

另外,随着硬件指令集的发展,在 JDK 5 之后,Java 类库中开始使用基于 cmpxchg 指令的 CAS 操作(又来一个重点),该操作由 sun.misc.Unsafe 类里面的 compareAndSwapInt()compareAndSwapLong() 等几个方法包装提供。不过在 JDK 9 之前 Unsafe 类是不开放给用户使用的,只有 Java 类库可以使用,譬如 juc 包里面的整数原子类,其中的 compareAndSet()getAndIncrement() 等方法都使用了 Unsafe 类的 CAS 操作来实现。

上面的这些操作可以保证原子性。

可见性

什么是可见性

回到物理机,前文说过,由于引入了高速缓存,不可避免的带来了一个新的问题:缓存一致性。而同样的,这个问题在 Java 虚拟机中同样存在,表现为工作内存与主内存的同步延迟,也就是内存可见性问题。

何为可见性?就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。

我们从 JMM 内存模型图中可以看出,如果线程 A 与线程 B 之间要通信的话,必须要经历下面 2 个步骤:

  • 线程 A 把工作内存 A 中更新过的共享变量刷新到主内存中去
  • 线程 B 到主内存中去读取线程 A 之前已更新过的共享变量

也就是说,线程 A 在向线程 B 的通信过程必须要经过主内存

那么就会出现一个问题,我们以下面这个例子看看:

// 线程 1 执行的代码
int i = 0;
i = 1;
// 线程 2 执行的代码
j = i;

当线程 1 执行 i = 1 这句时,会先去主内存中读取 i 的初始值,然后加载到线程 1 的的工作内存中,再赋值为1,至此,线程 1 的工作内存当中 i 的值变为 1 了,不过还没有写入到主内存当中。

如果在线程 1 准备把新的 i 值写回主内存的时候,线程 2 执行了 j = i 这条语句,它会去主存读取 i 的值并加载到线程 2 的工作内存当中,而此时主内存当中 i 的值还是 0,那么就会使得 j 的值为 0,而不是 1。

这就是内存可见性问题,线程 1 修改了共享变量 i 的值,线程 2 并没有立即得知这个修改。

如何保证可见性

各位可能脱口而出使用 volatile 关键字修饰共享变量,但除了这个,容易被大家忽略的是,其实 sunchronizedfinal 这俩关键字也能保证可见性

上面说过,为了保证 Java 程序中的内存访问操作在并发下仍然是线程安全的,JMM 规定了在执行 8 种基本原子操作时必须满足的一系列规则,这其中有一条规则正是 sychronized 能够保证原子性的理论支撑,如下:

  • 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store、write 操作)

也就是说 synchronized在修改了工作内存中的变量后,解锁前会将工作内存修改的内容刷新到主内存中,确保了共享变量的值是最新的,也就保证了可见性。

至于 final 关键字的可见性需要结合其内存语义深入来讲,这里就先简单的概括下:被 final 修饰的字段在构造器中一旦被初始化完成,并且构造器没有把 this 的引用传递出去,那么在其他线程中就能看见 final 字段的值。

有序性

什么是有序性

OK,说完了可见性,我们再回到物理机,其实除了增加高速缓存之外,为了使 CPU 内部的运算单元能尽量被充分利用,CPU 可能会对输入代码进行乱序执行优化,CPU 会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。

与之类似的,Java 的编译器也有这样的一种优化手段:指令重排序(Instruction Reorder)。

那么,既然能够优化性能,重排序可以没有限制的被使用吗?

当然不,在重排序的时候,CPU 和编译器都需要遵守一个规矩,这个规矩就是 as-if-serial 语义:不管怎么重排序,单线程环境下程序的执行结果不能被改变。

为了遵守 as-if-serial 语义,CPU 和编译器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。

那什么是数据依赖关系呢?

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖性分为三种类型:写后读、写后写、读后写。

名称 代码示例 说明
写后读 a = 1; b = a; 写一个变量之后,再读这个变量
写后写 a = 1; a = 2; 写一个变量之后,再写这个变量
读后写 a = b; b = 1; 读一个变量之后,再写这个变量

上面 3 种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。

如何保证有序性

Java 语言提供了 volatilesynchronized 两个关键字来保证线程之间操作的有序性。

volatile 本身除了保证可见性的语义外,还包含了禁止指令重排序的语义,所以天生就具有保证有序性的功能。而 synchronized 保证有序性的理论支撑,仍然是 JMM 规定在执行 8 种基本原子操作时必须满足的一系列规则中的某一个提供的:

  • 一个变量在同一个时刻只允许一条线程对其进行 lock 操作

这个规则决定了持有同一个锁的两个 synchronized 同步块只能串行地进入。

引用:

《Java 并发编程的艺术》

https://mp.weixin.qq.com/s?__biz=MzI0NDc3ODE5OQ==&mid=2247486744&idx=1&sn=36500b4c0d9669dd8d9c74f5259f27c8&chksm=e959d8a0de2e51b60b8de8ce71128bae337ea8bb9205048d8af5d44f7ad963621b81ec952f3d&scene=178&cur_album_id=1683346627601743872#rd


文章作者: Gtwff
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Gtwff !
  目录