JMM(Java Memory Model,Java 内存模型)并不像 JVM 内存结构一样是真实存在的运行实体,更多体现为一种规范和规则。

什么是 JMM

JMM 最初由 JSR-133(Java Memory Model and Thread Specification)文档描述,JMM 定义了一组规则或规范,该规范定义了一个线程对共享变量写入时,如何确保对另一个线程是可见的。实际上,JMM 主要有两大价值体现:

  1. JMM 提供了合理的禁用缓存以及禁止重排序的方法,所以其核心的价值在于解决可见性和有序性
  2. JMM 的另一大价值在于能屏蔽各种硬件和操作系统的访问差异,保证 Java 程序在各种平台下对内存的访问最终都是一致的。

另外,JMM 定义的两个概念:

  1. 主存:主要存储的是 Java 实例对象,所有线程创建的实例对象都存放在主存中,无论该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括共享的类信息、常量、静态变量。由于是共享数据区域,因此多条线程对同一个变量进行访问可能会发现线程安全问题。
  2. 工作内存:主要存储当前方法的所有本地变量信息(工作内存中存储着主存中的变量副本),每个线程只能访问自己的工作内存,即线程中的本地变量对其他线程是不可见的,即使两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括字节码行号指示器、相关 Native 方法的信息。注意,由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

JMM 中 Java 线程、工作内存、主存之间的关系如下:
image.png
JMM 将所有的变量都存放在公共主存中,当线程使用变量时,会把公共主存中的变量复制到自己的工作内存(或者叫作私有内存)中,线程对变量的读写操作是自己的工作内存中的变量副本。因此,JMM 模型也需要解决代码重排序和缓存可见性问题。JMM 提供了一套自己的方案去禁用缓存以及禁止重排序来解决这些可见性和有序性问题。JMM 提供的方案包括大家都很熟悉的 volatile、synchronized、final 等。JMM 定义了一些内存操作的抽象指令集,然后将这些抽象指令包含到 Java 的 volatile、synchronized 等关键字的语义中,并要求 JVM 在实现这些关键字时必须具备其包含的 JMM 抽象指令的能力。

JMM 与 JVM 物理内存的区别

JMM 属于语言级别的内存模型,它确保了在不同的编译器和不同的 CPU 平台上为 Java 程序员提供一致的内存可见性保证和指令并发执行的有序性。以 Java 为例,一个 i++ 方法编译成字节码后,在 JVM 中是分成以下三个步骤运行的:

  1. 从主存中复制i的值并复制到 CPU 的工作内存中
  2. CPU 取工作内存中的值,然后执行 i++ 操作,完成后刷新到工作内存
  3. 将工作内存中的值更新到主存

当多个线程同时访问该共享变量 i 时,每个线程都会将变量i复制到工作内存中进行修改,如果线程 A 读取变量 i 的值时,线程 B 正在修改 i 的值,问题就来了:线程 B 对变量 i 的修改对线程 A 而言就是不可见的。这就是多线程并发访问共享变量所造成的结果不一致问题,该问题属于 JMM 需要解决的问题。

JVM 模型定义了一个指令集、一个虚拟计算机架构和一个执行模型。具体的 JVM 实现需要遵循 JVM 的模型,它能够运行根据 JVM 模型指令集编写的代码,就像真机可以运行机器代码一样。

Java 代码是要运行在虚拟机上的,而虚拟机在执行 Java 程序的过程中会把所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途。其中,有些区域随着虚拟机进程的启动而存在,而有些区域则依赖用户线程的启动和结束而建立和销毁。
image.png
图-JVM运行时内存区域的结构

一般来说,JVM 有以下几个特点:

  1. JVM 模型定义了 Java 虚拟机规范,但是不同的 JVM 虚拟机的实现各不相同,一般会遵守规范
  2. JVM 模型定义中定义的方法区只是一种概念上的区域,并说明了其应该具有什么功能。但是并没有规定这个区域到底应该处于何处。所以,对于不同的 JVM 实现来说,是有一定的自由度的。不同版本的方法区所处的位置不同,方法区并不是绝对意义上的物理区域。在某些版本的 JVM 实现中,方法区其实是在堆中实现的
  3. 运行时常量池用于存放编译期生成的各种字面量和符号应用。但是,Java 语言并不要求常量只有在编译期才能产生。比如在运行期,String.intern 也会把新的常量放入池中
  4. 除了以上介绍的JVM运行时内存外,还有一块内存区域可供使用,那就是直接内存。Java 虚拟机规范并没有定义这块内存区域,所以它并不由 JVM 管理,是利用本地方法库直接在堆外申请的内存区域
  5. 堆和栈的数据划分也不是绝对的,如 HotSpot 的 JIT 会针对对象分配进行相应的优化

JMM 与硬件内存架构的关系

对于硬件内存来说只有寄存器、缓存内存、主存的概念,并没有工作内存(线程私有数据区域)和主存(堆内存)之分。也就是说 Java 内存模型对内存的划分对硬件内存并没有任何影响,因为 JMM 只是一种抽象的概念,是一组规则,并不实际存在,无论是工作内存的数据还是主存的数据,对于计算机硬件来说都会存储在计算机主存中,当然也有可能存储到 CPU 高速缓存或者寄存器中,因此总体上来说,Java 内存模型和计算机硬件内存架构是相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。

image.png
图 - JMM 与硬件内存架构的对应关系

JMM 的8个操作

JMM 定义了一套自己的主存与工作内存之间的交互协议,即一个变量如何从主存拷贝到工作内存,又如何从工作内存写入主存,该协议包含8种操作,并且要求 JVM 具体实现必须保证其中每一种操作都是原子的、不可再分的。

操作 作用对象 说明
Read(读取) 主存 把一个变量的值从主存传输到工作内存,以便随后的 Load 操作使用
Load(载入) 工作内存 把 Read 操作从主存中得到的变量值载入工作内存的变量副本中
Use(使用) 工作内存 将工作内存中的值传递给执行引擎。每当 JVM 遇到一个需要使用变量值的字节码指令时,执行 Use 操作
Assign(赋值) 工作内存 执行引擎通过 Assign 操作给工作内存的变量赋值。每当 JVM 遇到一个给变量赋值的字节码指令时,执行 Assign 指令
Store(存储) 工作内存 把工作内存中的一个变量值传递到主存,以便随后的 Write 操作使用
Write(写入) 主存 把通过 Store 操作得到的变量值存储在主存的变量中
Lock(锁定) 主存 把一个变量标记为某个线程独占状态
Unlock(解锁) 主存 把一个处于锁定状态的变量释放,释放后的变量才可以被其他的线程锁定
  1. 如果要把一个变量从主存复制到工作内存,就要按顺序执行 Read 和 Load 操作;如果要把变量从工作内存同步回主存,就要按顺序执行 Store 和 Write 操作。
  2. JMM 要求 Read 和 Load、Store 和 Write 必须按顺序执行,但不要求连续执行。也就是说,Read 和 Load 之间、Store 和 Write 之间可插入其他指令。

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

  1. 不允许 read 和 load、store 和 write 操作之一单独出现,以上两个操作必须按顺序执行,但没有保证必须连续执行,也就是说,read 与 load 之间、store 与 write 之间是可插入其他指令的
  2. 不允许一个线程丢弃它最近的 assign 操作,也就是说当线程使用 assign 操作对私有内存的变量副本进行变更时,它必须使用 write 操作将其同步到主存中
  3. 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主存中
  4. 一个新的变量只能从主存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说,就是对一个变量实施 use 和 store 操作之前,必须先执行 assign 和 load 操作
  5. 一个变量在同一个时刻只允许一个线程对其执行 lock 操作,但 lock 操作可以被同一个个线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁
  6. 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值
  7. 如果一个变量实现没有被 lock 操作锁定,就不允许对它执行 unlock 操作,也不允许 unlock 一个被其他线程锁定的变量
  8. 对一个变量执行 unlock 操作之前,必须先把此变量同步回主存(执行 store 和 write 操作)

JMM 如何解决有序性问题

JMM 提供了自己的内存屏障指令,要求JVM编译器实现这些指令,禁止特定类型的编译器和 CPU 重排序(不是所有的编译器重排序都要禁止)。

由于不同 CPU 硬件实现内存屏障的方式不同,JMM 屏蔽了这种底层 CPU 硬件平台的差异,定义了不对应任何 CPU 的 JMM 逻辑层内存屏障,由 JVM 在不同的硬件平台生成对应的内存屏障机器码。JMM 内存屏障主要有 Load 和 Store两类,具体如下:

  1. Load Barrier(读屏障):读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主存加载数据
  2. Store Barrier(写屏障):写指令之后插入写屏障,能让写入缓存的最新数据写回主存

在实际使用时,会对以上 JMM 的 Load BarrierStore Barrier 两类屏障进行组合,组合成 LoadLoad(LL)、StoreStore(SS)、LoadStore(LS)、StoreLoad(SL)四个屏障,用于禁止特定类型的 CPU 重排序。

  1. LoadLoad(LL)屏障

在执行预加载(或支持乱序处理)的指令序列中,通常需要显式地声明 LoadLoad 屏障,因为这些 Load 指令可能会依赖其他 CPU 执行的 Load 指令的结果。例如:

  1. Load1; LoadLoad; Load2;

该示例的含义为:在 Load2 要读取的数据被访问前,使用 LoadLoad 屏障保证 Load1 要读取的数据被读取完毕。

  1. StoreStore(SS)屏障

通常情况下,如果 CPU 不能保证从高速缓冲向主存(或其他 CPU )按顺序刷新数据,那么它需要使用 StoreStore 屏障。例如:

  1. Store1; StoreStore; Store2;

该示例的含义为:在 Store2 及后续写入操作执行前,使 StoreStore 屏障保证 Store1 的写入结果对其他 CPU 可见。

  1. LoadStore(LS)屏障

该屏障用于在数据写入操作执行前确保完成数据的读取。例如:

  1. Load1; LoadStore; Store2;

该示例的含义为:在 Store2 及后续写入操作执行前,使 LoadStore 屏障保证 Load1 要读取的数据被读取完毕。

  1. StoreLoad(SL)屏障

该屏障用于在数据读取操作执行前,确保完成数据的写入。例如:

  1. Store1; StoreLoad; Load2

该示例的含义为:在 Load2 及后续所有读取操作执行前,使 StoreLoad 屏障保证 Store1 的写入对所有 CPU 可见。

Volatile 语义中的内存屏障

在 Java 中,volatile 关键字主要有两重语义:

  1. 不同线程对 volatile 变量的值具有内存可见性,即一个线程修改了某个 volatile 变量的值,该值对其他线程立即可见
  2. 禁止进行指令重排序

总之,volatile 关键字除了保障内存可见性外,还能确保执行的有序性。volatile 语义中的有序性是通过内存屏障指令来确保的。为了实现 volatile 关键字语义的有序性,JVM 编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。JMM 建议 JVM 采取保守策略对重排序进行严格禁止。下面是基于保守策略的 volatile 操作的内存屏障插入策略。

  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障
  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障

:::info volatile 写操作的内存屏障插入策略为:在每个 volatile 写操作前插入 StoreStore(SS)屏障,在写操作后面插入 StoreLoad 屏障。如下图所示: ::: image.png :::info volatile 读操作的内存屏障插入策略为:在每个 volatile 写操作后插入 LoadLoad(LL)屏障和 LoadStore 屏障,禁止后面的普通读、普通写和前面的 volatile 读操作发生重排序。如下图所示: ::: image.png