跳至主要內容

Java 内存模型

AruNi_LuJava并发编程约 4806 字大约 16 分钟

本文内容

1. Java 内存模型基础

1.1 为什么需要并发

我们为什么需要并发?并发编程 Bug 的源头是什么?如何解决并发问题?分享一张图告诉大家:

image-20221214203801222

可见,Java 内存模型(JMM)在并发中是一个非常重要的角色,理解它,将让你在学习并发编程的过程中更加得心应手。

1.2 并发编程的问题

在并发编程中,需要处理两个关键的问题:

  • 线程之间如何 通信
  • 线程之间如何 同步

通信

通信 是指 线程之间以何种机制来交换信息。我们编程时实现线程间的通信机制有两种:共享内存消息传递

共享内存 的并发模型中,线程之间访问内存中的变量都是公共的,通过 读写内存中的公共变量,即可完成隐式的通信。

消息传递 的并发模型中,线程之间没有公共的变量,必须通过 发送消息 来进行显式的通信。

同步

同步 是指 程序中用于控制不同线程间操作发生相对顺序的机制

共享内存 的并发模型中,同步是显式进行的,即在对公共方法、代码块进行操作时,就存在同步问题。程序员必须显式地指定某个方法或代码块需要在线程之间 互斥的执行

消息传递 的并发模型中,由于消息的发送必须在消息的接收之前,因此同步是隐式的。

Java 中的并发模型

在 Java 中,并发采用的是 共享内存 模型,Java 线程之间的通信总是隐式进行的,整个通信过程对程序员完全透明

所以,如果我们不了解多线程之间通信的工作机制,就可能会遇到各种奇怪的内存可见性问题,俗称 Bug。

2. Java 内存模型是什么

2.1 JMM 的抽象结构

在 Java 中,所有 实例变量(引用对象)、静态变量和数组元素 都存储在 中,堆内存在线程之间共享,我们称这些为 共享变量。而局部变量、方法参数等不会在线程之间共享,因此它们不会有内存可见性问题,也不受内存模型的影响。

Java 线程之间的通信由 JMM 控制,JMM 决定着 一个线程对共享变量的写入何时对另一个线程可见

JMM 主要由三部分构成:1 个主内存、n 个线程、n 个工作内存,共享数据就在它们三者之间来回倒腾。

线程之间的 共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本,线程在操作变量时只能操作自己工作内存中的副本变量,而不能直接操作主内存。

本地内存是一个抽象的概念,并不真实存在,JMM 的抽象示意图如下:

image-20221214215603328

如果 线程间要进行通信,则需要通过 Java 给我们提供的 8 个原子操作,过程如下:

image-20221214220344781

从途中可以看出,一个变量从主内存拷贝到工作内存,再从工作内存同步回主内存的流程为:

  • |主内存| -> read -> load -> |工作内存| -> use -> |Java线程| -> assign -> |工作内存| -> store -> write -> |主内存|

2.2 JMM 中的 8 个原子操作

JMM 中的 8 个原子操作含义如下:

  • lock(加锁):作用于主内存,把变量标识为线程独占状态;

  • unlock(解锁):作用于主内存,把变量从锁定状态释放;

  • read(读取):作用于主内存,把变量值从主内存中读取到工作内存,以便后续的 load 使用;

  • load(加载):作用于工作内存,把 read 读取到的变量值放入工作内存的副本变量中;

  • use(使用):作用于工作内存,把工作内存中的变量传递给执行引擎,虚拟机在使用变量值的时候会执行这个操作;

  • assign(赋值):作用于工作内存,把执行引擎得到的变量值赋值到工作内存中的副本变量中,虚拟机在给变量赋值的时候会执行这个操作;

  • store(存储):作用于工作内存,把工作内存中的变量值存储进主内存,以便后续的 write 使用;

  • write(写入):作用于主内存,把 store 存储到主内存中的变量值写入主内存的变量中。

这 8 个原子操作有对应的执行规则,分为 加锁规则和变量拷贝规则

加锁规则:

  • 一个变量在 同一时刻只允许一个线程对其进行 lock,但可以被多次 lock(可重入);
  • 对一个变量进行 lock 操作清空这个变量在所有工作内存中的值。再使用时,需要通过 assignload 重新对这个变量进行初始化;
  • 对一个变量进行 unlock,必须将 该变量同步回主内存中,即执行 storewrite 操作;
  • 一个变量没有被 lock,就不能被 unlock,也不能去 unlock 一个被其他线程 lock 的变量。

变量拷贝规则:

  • 不允许 readloadstorewrite 单独出现
  • 不允许线程丢弃它最近的 assign 操作,即 工作内存变化后必须同步回主内存
  • 不允许一个线程在没有 assign 的情况下将工作内存同步回主内存中,也就是说虚拟机 只有遇到变量赋值的字节码时才会将工作内存同步回主内存
  • 新的变量只能从主内存中诞生,即不能在工作内存中使用未被 loadassign 的变量,一个变量在 usestore 前一定先经过了 loadassign

2.3 可见性、有序性问题

通过上面的图可以发现,Java 线程只能操作自己的工作内存,其对变量的所有读写操作,都必须在工作内存中进行,不能直接读写主内存中的变量。所以,可能会 存在可见性问题

  • 因为对于主内存中的变量 A,其在不同线程的工作内存中可能存在不同的变量副本 A1、A2、A3;
  • 不同线程间的 readloadstorewrite 不一定是连续执行的,中间可以穿插其他命令。Java 只能保证它们的执行 对于一个线程而言是连续的,但是并 不保证不同线程的执行是连续的

假设有两个线程 A 和 B,其中线程 A 在写入共享变量,线程 B 要读取共享变量,我们想让线程 A 先完成写入,线程 B 再完成读取,如下图:

image-20221214224411159

此时即便我们是按照 “线程 A 写入 -> 线程 B 读取” 的顺序开始执行的,真实的执行顺序也可能是这样的:storeA -> readB -> writeA -> loadB

上面的执行顺序 在线程 A 或线程 B 自己看来是有序的,但是整体上是无序的。这将导致线程 B 读取的是变量的旧值,而非线程 A 修改过的新值。

也就是说,线程 A 修改变量的执行先于线程 B 操作了,但这个操作对于线程 B 而言依旧是不可见的

如何解决上面的问题?

通过上述的分析可以发现,可见性问题的本身,也是由于不同线程之间的执行顺序得不到保证导致的,因此我们也可以将它的解决和有序性合并,即对 Java 一些指令的 操作顺序进行限制,这样既保证了有序性,又解决了可见性。

另外,由于 工作内存和主内存之间的同步差异,也会导致可见性问题,所以需要在有需要保证可见性的变量上及时的同步工作内存和主存之间的变量值。

为了解决整体执行顺序的不一致,Java 给出了一些命令执行的顺序规范,也就是大名鼎鼎的 Happens-Before 规则(后面会讲到)。

2.4 重排序问题

在执行程序时,为了 提高性能,编译器和处理器常常会 对指令做重排序

重排序分为三种:

  • 编译器优化 的重排序:编译器在不改变单线程程序语义的前提下,可以 重新安排语句的执行顺序
  • 指令级并行 的重排序:现代处理器采用了指令级并行技术来 将多条指令重叠执行。如果 不存在数据依赖性,处理器可以 改变语句对应机器指令的执行顺序
  • 内存系统 的重排序:由于处理器使用缓存和读写缓冲区,这使得 加载和存储操作看上去可能是在乱序执行

所以,从 Java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

image-20221214231026487

上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序可能会导致多线程程序中出现 内存可见性问题

重排序问题的经典案例

最经典的重排序问题就是 DLC(双重检测锁)下的单例模式,instance = new Singleton() 在我们程序员的眼里是这样执行的:

  1. 先分配一块内存;
  2. 在内存上初始化 Singleton 对象
  3. 最后将内存地址赋值给 instance 变量

但实际上优化后的执行流程是:

  1. 先分配一块内存;
  2. 将内存的地址赋值给 instance 变量
  3. 最后在内存上初始化 Singleton 对象

这就导致其他线程在判断 instance 变量不为空时,有可能此变量还没有被初始化,然后该线程返回的 instance 为 null,后续再访问的时候就会出现空指针异常。

具体的代码可自行搜索 DCL 下的单例模式。

怎么解决重排序问题?

对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。

对于处理器重排序,JMM 的处理器重排序规则会要求 Java 编译器在生成指令序列时,插入特定类型的 内存屏障(Memory Barriers)指令,通过内存屏障指令来禁止特定类型的处理器重排序

JMM 属于语言级的内存模型,它确保 在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证

2.5 小结

在并发编程中,导致可见性和有序性问题的关键都是不同线程之间的执行顺序得不到保证或者执行存在重排序问题。

想要解决上面的问题,最简单粗暴的方法就是禁用缓存和重排序优化,但是这样会大大影响程序执行的性能。所以 JMM 给出的方案是只在特定的情况下让缓存失效、或者强制读取主存中的变量值,以及禁止特定类型的指令重排序。

总结来说就是 按需禁用,以达到程序正确性与性能的平衡。

JMM 规范了如何按需禁用。这些方法包括 volatile、synchronized 和 final 三个关键字,以及 Happens-Before 规则和内存屏障等。

而导致原子性问题则是因为 Java 本身就只能保证 基本类型变量的定义是原子操作,例如 int i = 1。但是像 j = i 或者 i++ 这样的都不是原子操作,因为它们的操作在执行时都分成了多个步骤。

解决办法有使用锁机制、JUC 包下的原子类等。

3. Happens-Before 规则

3.1 什么是 Happens-Before

在 JMM 中,如果 一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系。这里的两个操作既可以是在一个线程之内,也可以是在不同线程之间。因此,JMM 可以通过 happens-before 关系向程序员提供跨线程的内存可见性保证,重点是能 跨线程

例如:如果 A 线程的写操作 a 与 B 线程 的读操作 b 之间存在 happens-before 规则,尽管操作 a 和 操作 b 在不同的线程中执行,但 JMM 也能向程序员保证 a 操作对 b 操作可见

happens-before 规则本质上是一种顺序约束规范,用来约束编译器的优化行为。也就是说,为了执行效率,我们允许编译器的重排序优化,但是为了保证程序运行的正确性,我们 要求编译器优化后需要满足 happens-before 规则

与程序员密切相关的 happens-before 规则如下

  • 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
  • 监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。
  • volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。这是 volatile 保证可见性的依据之一。
  • 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。

看完这些 happens-before 规则别懵逼,其实这些规则就是让我们知道我们编写的程序是怎么被运行的。我们只要能通过这些规则,去推断程序的运行结果,编写合理的代码即可。

也就是说,程序员是基于 happens-before 规则来编程的,这样程序才不会出错。

happens-before 是 JMM 最核心的概念,理解 happens-before 是理解 JMM 的关键。我们从 JMM 的设计来反观 happens-before。

3.2 JMM 的设计

从 JMM 设计者的角度看,需要考虑两个关键的因素:

  • 程序员对内存模型的要求:程序员希望内存模型易于理解、易于编程;
  • 编译器和处理器对内存模型的要求:编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能;

所以,程序员希望实现一个强内存模型,而编译器和处理器则希望实现一个弱内存模型。

可以发现,这两者是互相矛盾的,所以,JMM 的设计专家的核心目标就是找到一个好的平衡点:

  • 一方面,要为程序员提供足够强的内存可见性的保证;
  • 另一方面,对编译器和处理器的限制要尽可能地放松。

下面举一个例子,来看看 JMM 设计者是如何实现这个目标地。

double pi = 3.14;		// A
double r = 1.0; 	       	// B
double area = pi * r * r;      // C

上面计算圆的面积地示例代码中,存在 3 个 happens-before 关系:

  • A happens-before B;
  • B happens-before C;
  • A happens-before C;

在这 3 个 happens-before 关系中,第二、三个是必须地,但是第一个不是。

因此 JMM 把 happens-before 要求禁止的重排序分为了下面两类

  • 会改变程序执行结果的重排序
  • 不会改变程序执行结果的重排序

JMM 对这两种不同性质的重排序,采取了不同的策略,如下:

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

下图是 JMM 的设计示意图:

image-20221215231105006

从上图可以看出,即使是 happens-before 规则要求禁止的重排序,只要不会改变程序执行的结果,JMM 还是允许编辑器和处理器进行重排序优化,以提高性能

4. 内存屏障

4.1 什么是内存屏障

内存屏障,又称内存栅栏,是一个 CPU 指令,它可以禁止特定类型处理器的重排序,从而让程序按我们预想的流程去执行。还可以强制刷出缓存,从而影响某些数据的内存可见性。

所以,内存屏障有如下作用:

  • 保证特定操作的执行顺序,即 防止重排序
  • 影响某些数据的 内存可见性

编译器和处理器能够重排序指令,在保证最终结果不变的情况下,通过指令重排来尝试优化性能。插入一条 Memory Barrier 会告诉编译器和处理器:不管什么指令都不能和这条 Memory Barrier 指令重排序

Memory Barrier 所做的另外一件事是 强制刷出各种 CPU Cache,如一个 Write-Barrier(写入屏障)将刷出所有在 Barrier 之前写入 Cache 的数据,因此,任何 CPU 上的线程都能读取到这些数据的最新版本,保证了内存可见性

4.2 JMM 提供的内存屏障

JMM 为了屏蔽底层硬件平台的差异,提供了四类内存屏障指令:

屏障类型指令示例说明
LoadLoadLoad1;LoadLoad;Load2保证该屏障后第一个读操作(load2)之前,一定先读 load1 对应的数据
StoreStoreStore1;StoreStore;Store2保证该屏障后第一个写操作(store2)之前,store1 写操作对其他处理器可见(已刷到主内存)
LoadStoreLoad1;LoadStore;Store2该屏障后第一个写操作(store2)之前,保证 load1 的读操作已经结束
StoreLoadStore1;StoreLoad;Load2保证 store1 写操作的可见性(已刷到主内存)之后,load2 的读操作才能执行

内存屏障是 volatile 内存语义实现的重要依据,所以非常重要。

5. 参考文章

上次编辑于: