youyichannel

志于道,据于德,依于仁,游于艺!

0%

Java内存模型——JMM

JMM主要定义了对于一个共享变量,当线程B对这个共享变量执行写操作后,线程A对这个共享变量的可见性。

CPU缓存模型

CPU高速缓存的作用:为了解决CPU处理速度和内存处理速度不对等的问题。

类比的看,内存作用:缓存硬盘数据用于解决硬盘访问速度过慢的问题。

「 CPU Cache 示意图」

CPU Cache的工作流程:

  1. 复制一份数据到CPU Cache中
  2. CPU从CPU Cache中读取数据
  3. CPU 执行运算
  4. 运算数据写回 Main Memory

存在的问题:内存缓存不一致的问题。比如:

void f() { i++; }

两个线程同时执行上述代码,假设两个线程从CPU Cache中读取到的 i=1,两个线程执行完 i++后,写会主存的值 i=2,但是正确结果是i=3

当然,CPU为了解决内存缓存数据不一致的现象可以通过制定缓存一致性协议来解决,比如MESI协议,该协议作用在CPU Cache和主存之间交互时。不同的CPU使用的缓存一致性协议通常也会有所不同。

同理,操作系统也需要解决内存缓存一致性的问题。操作系统通过内存模型定义一系列的规范来解决这个问题。

指令重排序

概念:系统在执行代码时可能会对代码顺序做一定的调整后执行,目的是为了提高执行的性能。

常见的指令重排序情况:

  • 编译器优化重排:编译器在不改变单线程程序语义的前提下,重新安排语句的执行顺序
  • 指令并行重排:处理器在执行指令时对指令进行重排,以便更好地利用处理器的特性,如乱序执行、超标量执行等
  • 内存系统重排:对程序中的内存访问进行重排,以便更好地利用内存系统的特性,如缓存、预取等

由此可见,Java源代码会经历 编译器优化重排 —> 指令并行重排 —> 内存系统重排 ,最终才变成操作系统可执行的指令序列。

📢注意:执行重排序可以保证串行语义一致,但是没有保证多线程间的语义一致,因此在多线程的环境下,指令重排可能会导致一些问题。

编译器和处理器都有一些机制来确保指令不会被随意重排序,以保持程序的语义正确性和执行顺序。这是为了避免因指令重排序而导致的潜在问题,例如数据竞争和不一致的执行结果。编译器和处理器禁止指令重排序的方式:

  1. 编译器(Compiler)

    • 内存屏障(Memory Barrier): 编译器会在生成的汇编代码中插入内存屏障指令,以确保指令重排序不会破坏程序的语义。这些内存屏障指令告诉处理器要在某些点上同步内存操作,以确保指令的执行顺序。

    • 优化限制(Optimization Barrier): 编译器会根据优化等级(例如 -O1-O2-O3 等)来控制优化程度。较高级别的优化可能会引入更多的指令重排序,但较低级别的优化则会尽量保持源代码的执行顺序。

  2. 处理器(Processor)

    • 乱序执行(Out-of-Order Execution): 现代处理器通常支持乱序执行,这意味着处理器可能会在不改变程序的语义的前提下重新排序指令以提高执行效率。然而,处理器会使用复杂的技术来确保最终的执行结果与程序的顺序执行结果一致。

    • 内存屏障(Memory Barrier): 处理器也支持内存屏障指令,这些指令用于强制刷新处理器的缓存,以保持一致性。例如,在 x86 架构中,MFENCELFENCE 指令用于控制内存访问的顺序。

    • 指令重排序检测: 一些处理器还可以检测到指令重排序,如果检测到违规的重排序,它们将撤销或重新执行相关指令。

JMM (Java Memory Model)

JMM是什么?为什么需要?

JSR-133中,Java使用了新的内存模型JMM。

JMM存在的原因:

  1. 屏蔽不同系统的差异,提供统一的内存模型
  2. Java定义并发编程相关的一组规范,规定了线程和主内存之间的关系,规定了Java源代码到CPU可执行指令过程中的需要遵守的原则和规范 => 简化多线程编程,增强程序可移植性。

JMM抽象线程和主内存之间的关系

在JMM下,线程可以将变量保存到本地内存中,而不是直接在主存中进行读写。

存在的问题:造成一个线程修改了主存中的一个变量的值,另一个线程还在继续使用本地内存的值,造成数据的不一致。

主内存和本地内存的概念:

  • 主内存:所有线程创建的实例对象都存放在主内存中
  • 本地内存:每个线程都有一份私有的本地内存来存储共享变量的副本,并且每个线程只能访问自己的本地内存。本地内存是JMM抽象出来的一个概念,存储了主内存中的共享变量副本,本地内存可以是机器的寄存器。

「 JMM 抽象示意图 」

从上图可以看出,线程A想要和线程B通信,需要经历以下两个步骤:

  1. 线程A把本地内存中修改后的共享变量副本的值同步到主内存中
  2. 线程B从主内存中读取对应的共享变量的值

=> JMM 保障了共享变量的可见性

多线程下存在的问题:在多线程下,对主内存中的一个共享变量进行操作有可能诱发线程安全问题,比如

  1. 线程A和线程B分别对于同一个共享变量进行操作,一个执行修改操作,一个执行读取操作
  2. 线程B读取到的是线程A修改之前的值还是修改之后的值,这一点并不确定。因为线程A和线程B都是先讲共享变量从主内存拷贝到对应的线程的本地内存(工作内存)中的

JMM针对主内存和工作内存之间的具体交互协议定义了如下八中同步操作:

操作 作用的变量位置 描述
锁定 lock 主内存 将变量标记为一个线程的独享变量
解锁 unlock 主内存 解锁变量的锁定状态,被解锁状态的变量才能够被其他线程锁定
读取 read 主内存 线程将一个变量的值从主内存传输到自己的工作内存中
载入 load 工作内存 把read操作从主内存中得到的变量值放入工作内存变量的副本中
使用 use 工作内存 把工作内存中的一个变量的值传递给执行引擎
赋值 assign 工作内存 把一个从执行引擎接收到的值赋给工作内存的变量
存储 store 工作内存 将工作内存中的一个变量传送回主内存中
写入 write 主内存 将store操作从工作内存中得到的变量的值放入主内存的变量中

「 8大原子操作执行图 」

除此之外,还定义了以下同步规则来保证这些同步操作的正确执行:

  • 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中
  • 一个新的变量只能在主内存中出生,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量 => 对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作
  • 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁 => 锁的可重入
  • 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值
  • 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量

happens-before原则

1)如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果对于第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前 => 可见性,有序性

2)两个操作之间存在 happens-before 关系,并不意味着一定要按照 happens-before 原则制定的顺序来执行。如果重排序之后的执行结果和按照 happens-before 关系来执行的结果一致,那么可以进行指令重排

【🌰栗子】

int a = getA(); // 操作1
int b = getB(); // 操作2
int c = a + b; // 操作3

上述指令中:

  • 操作1 happens-before 操作2
  • 操作2 happens-before 操作3
  • 操作1 happens-before 操作3

虽然 操作1 happens-before 操作2,但是对这两个操作进行重排序并不会影响代码的执行结果,JMM是允许编译器和处理器执行这种重排序的。但是操作1和操作2必须在操作3之前执行,也就是 操作1、操作2 happens-before 操作3。

📢注意:happens-before原则表达的意义并不是一个操作发生在另一个操作之前,而是前一个操作的结果对后一个操作可见,无论这两个操作是否在同一个线程内。

happens-before 规则

1)次序规则

  • 一个线程内,按照代码顺序,写在前面的操作先行发生于写在后面的操作(强调的是一个线程)
  • 前一个操作的结果可以被后续的操作获取

2) 解锁规则:一个unlock操作先行发生于后面(时间上的先后)对同一个锁的lock操作 =>上一个线程unlock了,下一个线程才能获取到锁

3)volatile变量规则:对一个volatile变量的写操作先行发生于后面(时间上的先后)对这个变量的读操作,前面的写对后面的读是可见的

4)传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,可以得出A先行发生于操作C

5) 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于线程的每一个动作

6)线程中断规则(Thread Interruption Rule)

  • 对线程interrupt()方法的调用先发生于被中断线程的代码检测到中断事件的发生
  • 可以通过Thread.interrupted()检测到是否发生中断

7)线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测

8)对象终结规则(Finalizer Rule):对象没有完成初始化之前,不允许调用finalized()方法

如果两个操作不满足上述任意一个 happens-before 规则,那么这两个操作就没有顺序的保障,JVM 可以对这两个操作进行重排序。

happens-before 和 JMM的关系

「 《并发编程的艺术》 图」

参考文章

  • https://blog.csdn.net/TZ845195485/article/details/117599729
  • https://javaguide.cn/java/concurrent/jmm.html