当谈到并发编程时,Java 内存模型(Java Memory Model,简称 JMM)是一个关键概念。它定义了线程如何与主内存交互以及如何在自己的工作内存中存储数据。理解和遵守 Java 内存模型对于编写正确且高效的多线程程序至关重要。

注意:Java 内存模型并不是 JVM 内存模型。JVM 内存模型指的是 JVM 内存是如何划分的,比如我们平常所说的堆、栈、方法区等。而 Java 内存模型定义了 Java 语言如何与内存进行交互,具体地说是 Java 语言运行时的变量,如何与我们的硬件内存进行交互。

从 CPU 说起

缓存一致性

在计算机中 CPU 负责计算,内存负责存储,每次运算 CPU 需要从内存中获取数据。但是 CUP 的运算速度远远大于内存的速度,这样会出现每次计算时 CPU 等待内存的情况。

为了弥补 CPU 和内存之间存在的速度差异,因此引入了 CPU 高速缓存,CPU 高速缓存介于 CPU 和内存之间。每次运算先从内存读取到 CPU 高速缓存中,CPU 再从 CPU 高速缓存中读取。

下面是我现在的使用的电脑,有三级缓存。

随着技术的发展,出现了多核 CPU,进一步提升了 CPU 的计算能力,但同时也引入了新的问题:缓存一致性。在多 CPU 下,每个 CPU 共享内存,同时每个 CPU 又有自己的高速缓存,如果多个 CPU 同时处理一块主内存区域的数据时,那该如何保证数据的正确。比如下面这段代码

1
i = i + 1;

假设 i 初始值为 0,按照正常的逻辑 ,2 个 CPU 运算完成后 i 的值会是 2,但是因为高速缓存的存在,会有下面的运算过程。

  • CPU1 读取 i 的初始值放到 CPU1 高速缓存中,
  • CPU2 读取 i 的初始值放到 CPU2 高速缓存中,
  • CPU1 进行运算,得出结果 1,运算结束,写回内存,此时内存 i 的值为 1。
  • CPU2 进行运算,得出结果 1,运算结束,写回内存,此时内存 i 的值为 1。

那么,如何保证数据的一致性呢?答案是:缓存一致性协议。在多 CPU 系统中,一种用来保持多个高速缓存之间,以及高速缓存与主存储器之间数据一致的机制。

在不同的 CPU 中,会使用不同的缓存一致性协议。例如 奔腾系列的 CPU 中使用的是 MESI 协议, AMD 系列 CPU 中使用的是 MOSEI 协议,Intel 的 core i7 使用 MESIF 协议。

处理器优化和指令重排序

为了使 CPU 内部运算单元被充分利用,CPU 会对输入的代码进行乱序执行(Out-Of-Order Execution)优化,CPU 会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致

如果是在单核处理器上运行,这是没有问题的。但是在多核处理器下,如果存在一个核的计算任务依赖另一个核的计算任务的中间结果,而且对相关数据读写没做任何防护措施,那么其顺序性并不能靠代码的先后顺序来保证。

除了 CPU 会对代码进行优化处理,很多现代编程语言的编译器也会做类似的优化,比如像 Java 的即时编译器(JIT)会做指令重排序

处理器优化其实也是重排序的一种类型,重排序可以分为三种类型:

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

并发编程的问题

并发编程的三个特点:可见性有序性原子性。如果从更深层次看这三个问题,其实就是上面讲的缓存一致性处理器优化指令重排序造成的。

缓存一致性问题其实就是可见性问题,处理器优化可能会造成原子性问题,指令重排序会造成有序性问题。

了解过 JVM 得都清楚,JVM 运行时内存区域是分片的,分为栈、堆等,其实这些都是 JVM 定义的逻辑概念。在传统的硬件内存架构中是没有栈和堆这种概念。从下图中可以看出栈和堆既存在于高速缓存中又存在于主内存中。

Java 一直秉持着「Write Once, Run Anywhere」,即一次编译哪里都可以运行的理念。为了达到这个目地必须要解决上面这些问题,Java 定义出了一套内存模型, 规范内存的读写操作。

Java 内存模型

Java 内存模型用于屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台都能达到一致的内存访问效果。

Java 内存模型定义了程序中各个变量的访问规则,即在 Java 虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。这里说的变量包括了实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数。因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。

主内存和工作内存

Java 内存模型规定所有的变量都存储在主内存(Main Memory)中。每条线程还有自己的工作内存(Working Memory),工作内存中保留了该线程使用到的变量的主内存的副本。

工作内存是 JMM 的一个抽象概念,并不真实存在,它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。

线程对变量的操作必须是在工作内存中,不能直接操作主内存。不同的线程间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

内存间的交互

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存,以及如何从工作内存同步回主内存的细节,Java 内存模型定义了 8 种操作来完成。Java 虚拟机实现的时候必须保证下面提及的每一种操作都是原子的、不可再分的。

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

JMM 还规定了上述 8 种基本操作,需要满足以下规则:

有关变量拷贝过程的规则:

  • read 和 load 必须成对出现;store 和 write 必须成对出现。即不允许一个变量从主内存读取了但工作内存不接受,或从工作内存发起回写了但主内存不接受的情况出现。
  • 不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改变了之后必须把变化同步到主内存中。
  • 不允许一个线程无原因的(没有发生过任何 assign 操作)把数据从工作内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load 或 assign )的变量。换句话说,就是对一个变量实施 use 和 store 操作之前,必须先执行过了 load 或 assign 操作。

有关加锁的规则:

  • 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。所以 lock 和 unlock 必须成对出现。
  • 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。
  • 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定的变量。
  • 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)

一个变量从主内存拷贝到工作内存,再从工作内存同步回主内存的流程为:

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

可见性和有序性问题

在上图中,Java 中每个线程只能操作自己得工作内存,这样就有可能产生可见性问题。

对于在主内存中的变量 A,不同线程中的工作内存中有着不同得副本 A1,A2,A3。不同线程的readloadstorewrite不一定是连续的,中间可以插入其他命令,Java 只能保证 read 和 load、store 和 write 的执行对于一个线程而言是连续的,但是并不保证不同线程的 read 和 load、store 和 write 的执行是连续的

比如下图中:

两个线程 A、B,其中 A 写入共享变量,B 读取变量。 假设 A 先写。B 再读取,按照正常得逻辑应该是storeA -> writeA -> readB -> loadB。但是 Java 并不能保证不同线程的执行是连续的,可能得会有这样的顺序storeA -> readB -> writeA -> load,在 A 还没写完时,B 已经读取了共享变量得旧值。

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

Happens-Before 原则

Happens-Before 规则保证:前面的操作的结果对后面的操作一定是可见的

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

根据类别,可以将 Happens-Before 规则分为了以下 4 类:

操作的顺序:

  • 程序顺序规则: 如果代码中操作 A 在操作 B 之前,那么同一个线程中 A 操作一定在 B 操作前执行,即在本线程内观察,所有操作都是有序的。
  • 传递性: 在同一个线程中,如果 A 先于 B ,B 先于 C 那么 A 必然先于 C。

锁和 volatile:

  • 监视器锁规则: 监视器锁的解锁操作必须在同一个监视器锁的加锁操作前执行。
  • volatile 变量规则: 对 volatile 变量的写操作必须在对该变量的读操作前执行,保证时刻读取到这个变量的最新值。

线程和中断:

  • 线程启动规则: Thread#start() 方法一定先于该线程中执行的操作。
  • 线程结束规则: 线程的所有操作先于线程的终结。
  • 中断规则: 假设有线程 A,其他线程 interrupt A 的操作先于检测 A 线程是否中断的操作,即对一个线程的 interrupt() 操作和 interrupted() 等检测中断的操作同时发生,那么 interrupt() 先执行。

对象生命周期相关:

  • 终结器规则: 对象的构造函数执行先于 finalize() 方法。

根据 Happens-Before 原则,要确保上图中得问题能够正常运行,只要在共享变量上加个volatile即可。

内存屏障

Java 底层是怎么实现这些规则来保证有序性和可见性? 通过内存屏障(memory barrier)。

内存屏障是一种 CPU 指令,用来禁止处理器指令发生重排序。常见有 4 种屏障:

  • LoadLoad 屏障 - 对于这样的语句Load1; LoadLoad; Load2,在 Load2 及后续读取操作要读取的数据被访问前,保证 Load1 要读取的数据被读取完毕。
  • StoreStore 屏障 - 对于这样的语句 Store1; StoreStore; Store2,在 Store2 及后续写入操作执行前,保证 Store1 的写入操作对其它处理器可见。
  • LoadStore 屏障 - 对于这样的语句 Load1; LoadStore; Store2,在 Store2 及后续写入操作被执行前,保证 Load1 要读取的数据被读取完毕。
  • StoreLoad 屏障 - 对于这样的语句 Store1; StoreLoad; Load2,在 Load2 及后续所有读取操作执行前,保证 Store1 的写入对所有处理器可见。它的开销是四种屏障中最大的(冲刷写缓冲器,清空无效化队列)。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

比如下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
public class VolatileTest {
private volatile static boolean flag = false;

public static void main(String[] args) {
long i = 0L;
flag = true;
while (!flag) {
i++;
}
System.out.println("count = " + i);
flag = true;
}

编译成 CPU 汇编指令后,被volatile修饰得flag变量在进行写操作时会有一个lock指令

在 Intel CPU 中lock指令功能如下:

  • 被修饰的汇编指令成为“原子的”
  • 与被修饰的汇编指令一起提供内存屏障效果

Java 语言级别的处理

上面说的这些都是 Java 底层的处理逻辑,我们真正在使用 Java 时,并不需要关心底层的编译器优化、缓存一致性等问题,Java 已经提供了关键字来处理并发安全问题。。所以,Java内存模型,除了定义了一套规范,还提供了一系列原语,封装了底层实现后,供开发者直接使用

Java 语言级别中来解决要解决原子性、有序性和一致性的问题的方法。

原子性

使用synchronized来保证方法和代码块内的操作是原子性的。

可见性

volatile关键字其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用 volatile 来保证多线程操作时变量的可见性。

另外,synchronized 和 final 两个关键字也可以实现可见性。只不过实现方式不同。

有序性

volatile 关键字会禁止指令重排。synchronized 关键字保证同一时刻只允许一条线程操作。

参考