Java JMM

2023-12-14 00:32:40

JMM 全称: Java Memory Model (Java 内存模式)。
它是一种虚拟机规范, 用于屏蔽掉各种硬件和操作系统的内存访问差异, 以实现 Java 程序在各种平台下都能达到一致的并发效果。
主要规定了以下两点

  1. 一个线程如何以及何时可以看到其他线程修改过后的共享变量的值, 即线程之间共享变量的可见性
  2. 如何在需要的时候对共享变量进行同步

了解 JMM 大体的概念, 可以帮忙我们了解 Java 并发的一些设计。

1 线程通信和线程同步

1.1 线程通信

通信是指线程之间以何种机制来交换信息。
在命令式的编程中, 线程之间的通信机制有两种: 共享内存消息传递

共享内存并发的模型里, 线程之间共享程序的公共状态, 线程之间通过读 - 写内存中的公共状态来隐式进行通信。
消息传递的并发模型里, 线程之间没有公共状态, 线程之间必须通过明确的发送消息来显示进行通信, 在 Java 中典型的消息传递方式就是 wait() 和 notify()。

Java 的并发采用的就是 共享内存模型, Java 线程之间的通信总是隐式进行的, 整个通信过程对程序员是完全透明的。
这里提到的共享内存模型指的就是 Java 内存模型 (简称 JMM )。

1.2 线程同步

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

共享内存并发的模型里, 同步是显式进行的。程序必须显式指定某个方法或某段代码需要在线程之间互斥执行。
消息传递的并发模型里, 由于消息的发送必须在消息的接收之前, 因此同步是隐式进行的。

2 Java 对 JMM 的实现

2.1 JMM 在 Java 实现中的抽象结构模型

JMM 定义了线程和主内存之间的抽象关系:
线程之间的共享变量存储在主内存 (Main Memory) 中, 每个线程都有一个私有的本地内存 (Local Memory), 本地内存中存储了该线程已读/写共享变量的副本。
本地内存是 JMM 的一个抽象概念, 并不真实存在。它涵盖了缓存, 写缓冲区, 寄存器以及其他的硬件和编译器优化。

Alt 'JMM 抽象模型'

从上图来看, 线程 A 与线程 B 之间如要通信的话, 必须要经历下面 2 个步骤

  1. 线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去
  2. 线程 B 到主内存中去读取线程 A 更新的共享变量

2.2 JMM 在 Java 的具体实现

通过 JMM 抽象结构模型, 可以知道 Java 就是变量的传递, 达到了隐式通信的效果, 而这个过程需要借助 2 个重要的数据存储才能实现

  1. 本地内存
  2. 主内存

而 JVM 是如何实现这 2 个地方的呢?

Alt 'JVM 内存模型'

JVM 在执行 Java 程序的过程中会把它所管理的内存划分为若干不同的数据区域, 这些区域都有各自的用途以及创建和销毁的时间。

主要包含 2 类:

1. 线程共享区域

方法区 (Method Area): 方法区是各个线程共享的内存区域, 它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
堆 (Heap): 用来保存程序运行中所创建的所有对象、数组元素等

2. 线程私有区域

虚拟机栈 (VM Stack): 运行在 Java 虚拟机上的线程都拥有自己的线程栈, 主要用于存储线程执行方法时的各种状态数据等信息
本地方法栈 (Native Method Stack): 本地方法栈与虚拟机栈的作用相似, 不同之处在于虚拟机栈为虚拟机执行的 Java 方法服务, 而本地方法栈则为虚拟机使用到的 Native 方法服务
程序计数器 (PC Register) : 程序计数器保存着每一条线程下一次执行指令位置

3 JMM 在并发编程中需要解决的一些问题

3.1 主内存和本地内存设计带来的问题

备注:
我们知道操作系统之间, CPU 的运算速度很快, 而 IO 的效率和他比起来慢了很多, 即便是直接读取内存, 所以 CPU 和内存之间存在着多层高速缓存, 以保证 CPU 可以保持自己运算能力, 不受 IO 的影响。
同理, 线程在主内存之间, 维护了一套自己的本地内存, 也是为了保持自己的执行效率。
而这样的设计, 就和操作系统类似, 带来了一些其他问题。

3.1.1 可见性问题

Alt 'JVM 可见性问题'

如上图, 3 个共享变量 count, 2 个为副本。
启动 2 个线程分别对共享变量操作, 假设原本共享变量 count 为 0。

  1. 线程 A 从主内存将这个共享变量 count 加载到自己的本地内存, 值为 0
  2. 线程 B 执行同样的加载操作, 值为 0
  3. 线程 A 对这个共享变量 count + 1, count 的值变为 1, 没有将这个值同步到主内存
  4. 线程 B 这时候同样需要对这个共享变量 count + 1, 但是这时候 B 中的 count 还是 0, 没有感知到 A 对其做的修改

在多线程的环境下, 如果某个线程首次读取共享变量, 则首先到主内存中获取该变量, 然后存入工作内存中, 以后只需要在工作内存中读取该变量即可。
同样如果对该变量执行了修改的操作, 则先将新值写入工作内存中, 然后再刷新至主内存中, 这个刷新时间虽然很短但并不确定。

3.1.2 竞争问题

Alt 'JVM 竞争问题'

如上图:
如果这两个加 1 操作是串行的, 最终主内存中的 count 的值应该是 3。
然而图中两个加 1 操作是并行的, 当它们值更新到工作内存的副本后, 会争相刷新主内存。在这里, 不管是线程 1 还是线程 2 先刷新计算结果到主内存, 最终主内存中的值只能是 2。

3.1.3 主内存和本地内存

为了解决上面提到的问题, JVM 提供了很多的工具类和关键字, 达到加锁串行操作, 本地内存失效, 数据强制刷新主内存等效果, 达到解决问题

  1. synchronized 让线程之间串行的执行
  2. volatile 让变量变更立即刷入主内存, 其他线程相关的缓存失效
  3. Lock 加锁, 串行执行

这些不是本篇的重点, 就不展开了

3.2 重排序的影响

在上面, 我们讨论了主内存和本地内存在多线程的情况带来的影响: 可见性 + 竞争
这 2 个都是由本地内存和主内存带来的。

而在并发过程中, 除了这 2 个地方会引起问题外, 在编译器中还存在重排序问题: 在执行程序时, 为了提高性能, 编译器和处理器常常会对指令 (我们写的代码, 最终会被转为 1 到多条指令, 然后逐个执行) 做重排序。
这些系统内部的优化大部分都是有效的, 但是有时在并发编程中, 则会带来某些问题。

3.2.1 重排序的类型

一个好的内存模型实际上会放松对处理器和编译器规则的束缚, 也就是说软件技术和硬件技术都为同一个目标而进行奋斗: 在不改变程序执行结果的前提下, 尽可能提高并行度。
JMM 对底层约束比较少, 使其能够发挥自身优势。因此, 在执行程序时, 为了提高性能, 编译器和处理器常常会对指令进行重排序。一般重排序可以分为如下三种:

Alt 'JVM 重排序类型'

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

注: 指令并行重排序和内存系统重排序统称为处理器排序

3.2.2 重排序的影响

我们知道重排序是编译器或者系统的优化。
但是如果有些指令存在依赖性的话, 进行重排序会导致错误。

数据依赖性
如果两个操作访问同一个变量, 且这两个操作中有一个为写操作, 此时这两个操作之间就存在数据依赖性。
数据依赖分为下列 3 种类型, 这 3 种情况, 只要重排序两个操作的执行顺序, 程序的执行结果就会被改变。

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

这三种操作都存在数据依赖性, 如果重排序最终会导致结果受到影响。

控制依赖性

public int method(int a) {
    int num = 2;
    if (flag) {
        int num = a * a;
        return num;
    }
    return num;
}

在上面的代码中, 变量 num 的值依赖于 if (flag) 的判断值, 这里就叫控制依赖。

控制依赖在单线程的情况下, 对存在控制依赖的操作重排序, 不会改变执行结果。
但是在多线程的情况下, 就有可能出现问题。

比如下面的例子:

public class Demo {
    int a = 0;
    boolean flag = false;
    
    public void init() {
        // 步骤 1
        a = 1;
        // 步骤 2
        flag = true;
    }
    
    public void use() {
        // 步骤 3
        if (flag) {
            // 步骤 4
            int i = a * a;
        }
    }
}

在程序中, 当代码中存在控制依赖性时, 会影响指令序列执行的并行度。为此, 编译器和处理器会采用猜测 (Speculation) 执行来克服控制相关性对并行度的影响。

以处理器的猜测执行为例:
假设现在有 2 个线程 A, B, A 执行到了 init 方法, 线程 B 执行到了 use 方法。
执行线程 B 的处理器可以提前读取并计算 a*a, 然后把计算结果临时保存到一个名为重排序缓冲 (Reorder Buffer, ROB) 的硬件缓存中。
当步骤 3 的条件判断为真时, 就把该计算结果写入变量 i 中。
猜测执行实质上对操作 3 和 4 做了重排序, 问题在于这时候, a 的值可能还没被线程 A 赋值。

当步骤 1 和步骤 2 重排序, 步骤 3 和步骤 4 重排序时, 可能会产生什么效果?
步骤 1 和步骤 2 做了重排序。程序执行时, 线程 A 首先写标记变量 flag, 随后线程 B 读这个变量。由于条件判断为真, 线程 B 将读取变量 a。
此时, 变量 a 还没有被线程 A 写入, 这时就会发生错误!

所以在多线程程序中, 对存在控制依赖的操作重排序, 可能会改变程序的执行结果。

3.2.3 禁止重排序

通过上面的分析, 重排序可能导致线程安全的问题, 不做任何的限制, 会导致一些意向不到的事情。

所以对于重排序, JMM 对 Java 编译器做了一些限制要求

  1. 针对编译器重排序, Java 编译器按照规则禁止一些特定类型的编译器重排序
  2. 针对处理器重排序, Java 编译器在生成指令序列的时候会通过插入内存屏障指令来禁止某些特殊的处理器重排序

当然也不是全面禁止掉重排序, 在数据没有任何依赖性时, 重排序还是允许的。

举个例子:

// 步骤 1
double pai = 3.14;
// 步骤 2
double r = 1;
// 步骤 3
double area = pai * r * r;

上面是计算圆面积的代码, 步骤 1, 2 之间没有任何的关联, 所以 2 者之间可以重排序, 也可以说步骤 1 和 2 之间没有数据依赖性 (如果两个操作访问同一个变量, 且这两个操作有一个为写操作, 此时这两个操作就存在数据依赖)。
这里可以分为 3 种情况: 读后写 / 写后读 / 写后写, 这 3 种情况, 无论哪一种发生了重排序, 最终执行结果会存在影响。

最终的结论 Java 编译器通过禁止特定的编译器和内存屏障指令, 让编译器和处理器在重排序时, 会遵守数据依赖性, 不会改变存在数据依赖性关系的两个操作的执行顺序

4 Happens-Before - JMM 对操作系统的屏蔽

Happens-Before, 中文译名应该, 先行发生, 大体要表达的意思就是: A 操作会先于 B 操作执行

在上面的分析中, 主要是分析了线程的双内存重排序, 在并发中带来的问题, 涉及了大量的底层知识。
如果在编程中需要考虑这么多底层的知识, 那么对于编写程序的人的负担是很大的。

因此, JMM 为程序员在提供了一套 Happens-Before 的规则, 保证程序员编写的代码在满足对应的条件, 代码的效果都是都是遵循里面的规则。
这样程序员完全可以根据这套规则去和编写并发程序和解决并发的的各种问题, 而不必去思考操作系统底层具体操作, 如内存同步, 重排序等。

JMM 这么做的原因是: 程序员对于这两个操作的底层实现并不关心, 程序员关心的是程序执行时的语义不能被改变 (即执行结果不能被改变)。

大体的效果如下:

Alt 'JMM HappensBefore 效果'

4.1 定义

  1. 如果一个操作 Happens-Before 另一个操作, 那么第一个操作的执行结果将对第二个操作可见, 而且第一个操作的执行顺序排在第二个操作之前
  2. 两个操作之间存在 Happens-Before 关系, 并不意味着 Java 平台的具体实现必须要按照 Happens-Before 关系指定的顺序来执行。
    如果重排序之后的执行结果, 与按 Happens-Before 关系来执行的结果一致, 那么这种重排序并不非法 (也就是说, JMM 允许这种重排序)

第一条是 JMM 对程序员的保证。
如果 A Happens-Before B, 那么 JMM 将向程序员保证 —— A 操作的结果将对 B 可见, 且 A 的执行顺序排在 B 之前。

第二条是 JMM 对编译器和处理器重排序的约束原则。
JMM 允许两个操作之间存在 Happens-Before 关系, 不要求 Java 平台的具体实现必须要按照 Happens-Before 关系指定的顺序来执行。
如果重排序之后的执行结果, 与按 Happens-Before 关系来执行的结果一致, 那么这种重排序是允许的。

4.2 具体的规则

4.2.1 程序顺序规则 (Program Order Rule)

在一个线程中,按照代码的顺序,前面的操作 Happens-Before 于后面的任意操作。
简单理解: 同一个线程 A 中前面的所有写操作对后面的操作可见。

例子

int a = 1;
int b = a + 1;

在同一个线程, 对 a 的赋值操作的结果 (a = 1), 对于后面的操作 (int b = a + 1), 都是可以明确知道的, 即后面操作中, a 一定是 1, 不会是其他值。

备注:
这个结果的保证, 作为程序员的我们无需去关心底层是如何实现的, 内存如何同步, 是否重排序等。我们只需要知道 JVM 遵循了 Happens-Before 原则, 一定是这样的就行。

4.2.2 监视器锁规则 (Monitor Lock Rule)

同一个锁的 unlock 操作 Happens-Before 此锁的 lock 操作。
简单理解: 线程 A 获取锁成功, 做了一些数据的变更, 然后线程 A 释放锁, 线程 B 获取同一个锁成功了, 那么线程 A 释放锁前做的所有数据变更, B 线程都是可见的。

4.2.3 volatile 变量规则 (volatile Variable Rule)

对一个 volatile 变量的写操作 Happens-Before 后续每一个对该变量的读操作。
简单理解: 线程 A 对 volatile 修饰的变量 v 进行操作, 后面其他的线程对变量 v 的读取, 结果都是线程 A 对变量 v 的操作后的结果, 对其他线程 (包括自己) 是可见的。

4.2.4 线程启动规则 (Thread Start Rule)

Thread 的 start 方法 Happens-Before 调用 start 方法的线程前的每一个操作。
简单理解: 线程 T1 做了很多操作, 然后调用线程 T2 的 start 方法启动一个新的线程, 这时 T1 在调用 T2 的 start 方法前做到所有变更, 对线程 T2 都是可见的。

4.2.5 线程终止规则 (Thread Termination Rule)

线程中任何操作都 Happens-Before 其它线程检测到该线程已经结束。
简单理解: 线程 T1 做了很多操作, 然后线程 T2 感知到线程 T1 已经终止了, 那么线程 T1 做到变更, 对线程 T2 都是可见的。

例子

int num = 1;
Thread theadT1 = new Thread(() -> num = 2);
theadT1.start();

// 等待 T1 执行完成
theadT1.join();
// 这时当前线程 T2 读取 num 值一定是 2
4.2.6 线程中断规则 (Thread Interruption Rule)

对线程 interrupt 方法的调用 Happens-Before 于被中断线程的代码检测到中断事件的发生。
简单理解: 线程 T1 做了很多操作, 然后调用线程 T2 的中断方法 interrupt, 这时线程 T1 做的操作对于线程 T2 都是可见的。

int num = 1;

Thead threadT2 = new Thread(() -> {

  // 线程 T2 没有被中断, 就一直循环
  while(!Thread.currentThread().isInterrupted()){
    // 线程 T2 被中断了, 此时线程 T2 读取 num 值一定是 2
    System.out.println(x);
  }
})
threadT2.start();

// 线程 T1 修改 num 值
num = 2;

// 线程 T1 调用线程 T2 的中断方法
threadT2.interrupt();
4.2.7 对象终结规则 (Finalizer Rule)

一个对象的初始化完成(构造函数执行结束)Happens-Before 它的 finalize 方法的开始。
简单理解一个对象初始化一定在其 finalize 之前 (间接的表明了构造函数其间做的操作在 finalize 方法时都是可见的)。

public class A {

  private int num = 1;

  public A() {
    System.out.println("对象创建: " + num);
    this.num = 2;
  }

  @Override
  protected void finalize() throws Throwable {
    System.out.println("对象销毁: " + num);
  }
}

// 结果
//对象创建: 1
//对象销毁: 2
// 对象创建的日志打印一定在对象销毁之前
4.2.8 传递性 (Transitivity)

如果操作 A Happens-Before B, B Happens-Before C, 那么可以得出操作 A Happens-Before C。
简单理解就是 A 操作的结果对 B 操作可见, B 操作对 C 操作可见, 那么 A 操作对 C 操作同样可见。

4.3 Happens-Before 规则的真正意义

我们已经知道, 导致多线程间可见性问题的两个原因: CPU 缓存和重排序。
一种极端的解决多线程可见性问题的方式就是: 禁止所有 CPU 缓存和重排序。可行, 但是会极端影响处理器的性能。

为了解决多线程的可见性问题, 但是尽可能少的影响处理器性能, 可以选择一种折中的方法:
通过分割线将整个程序划分为若干个程序块

  1. 程序块内指令可以重排序, 但是程序块之间只能不能重排序
  2. 在程序块内, CPU 不需要和主内存交互, 直接使用自己的本地内存即可, 但是到了分割线处, 必须将执行结果同步到主内存, 同时从主内存读取最新的变量到本地内存

而在这个方法中, Happens-Before 就是定义了这些程序块的分割线。

Alt 'JMM HappensBefore 例子'

如图所示, 这里的 unlock M 和 lock M 就是划分程序的分割线。
在这里, 红色区域和绿色区域的代码内部是可以进行重排序的, 但是 unlock 和 lock 操作是不能与它们进行重排序的。
即第一个图中的红色部分必须要在 unlock M 指令之前全部执行完, 第二个图中的绿色部分必须全部在 lock M 指令之后执行。
并且在 unlock M 指令处, 红色部分的执行结果要全部刷新到主存中, 在 lock M 指令处, 绿色部分用到的变量都要从主存中重新读取。

在程序中加入分割线将其划分成多个程序块, 虽然在程序块内部代码仍然可能被重排序, 但是保证了程序代码在宏观上是有序的。并且可以确保在分割线处, CPU 一定会和主内存进行交互。
Happens-Before 原则就是定义了程序中什么样的代码可以作为分隔线。并且无论是哪条 Happens-Before 原则, 它们所产生分割线的作用都是相同的。

5 总结

JMM, Java Memory Model, 其本身只是一个虚拟机规范, 制定了 Java 虚拟机的内存抽象模式: 本地内存 + 主内存。
双内存模式和系统本身的指令重排序, 在并发编程中都会导致可见性问题, 所以 JMM 对实现者提供了一些规则进行确保程序的准确性。

同时为了减轻使用者对并发编程的要求, 提供了一套 Happens-Before 规则帮助使用者屏蔽底层的实现, 只需要使用者按照 Happens-Before 规则进行编程, 就能保证程序的可见性。

所以, 在整个 JMM 规范中 2 个比较重要的组成

  1. 内存模型的抽象
  2. Happens-Before 的保障

6 参考

《Java并发编程的艺术》
《Java 多线程编程实践实战指南 (核心篇) 》
JMM的介绍
再有人问你Java内存模型是什么, 就把这篇文章发给他。
Java 内存模型详解
java内存模型以及happens-bofore原则
JMM和底层实现原理
大厂很可能会问到的JMM底层实现原理
从Java多线程可见性谈Happens-Before原则

文章来源:https://blog.csdn.net/LCN29/article/details/134912571
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。