Java内存模型(JMM)

Java内存模型的理解

Java内存模型的定义

Java 内存模型(Java Memory Model, JMM) 是 Java 虚拟机 (JVM) 定义的一种规范,用于描述多线程程序中变量(包括实例字段、静态字段和数组元素)如何在内存中存储和传递的规则。规范了线程何时会从主内存中读取数据、何时会把数据写回主内存。

操作系统有一套内存模型,而 Java 是跨平台实现的,因此它需要自己定义一套内存模型屏蔽各操作系统之间的差异。

JMM 定义了 Java 源码到 CPU 指令执行一套规范,我们仅需直接使用 Java 提供的并发类(synchronizedvolatile 等),知晓它定义的 happens-before 原则,即可写出并发安全的代码,无需关心底层的 CPU 指令重排、多级缓存等各种底层原理。

常见并发模型

两种并发模型的比较

Java 使用的是共享内存并发模型

三大特性

JMM 的核心目标是确保多线程环境下的可见性、有序性和原子性,从而避免由于硬件和编译器优化带来的不一致问题。

原子性

原子性 指的是一个操作或一系列操作要么全部执行成功,要么全部不执行,期间不会被其他线程干扰。

  • 原子类与锁:Java 提供了 java.util.concurrent.atomic 包中的原子类,如 AtomicInteger , AtomicLong,来保证基本类型的操作具有原子性。此外,synchronized 关键字和 Lock 接口也可以用来确保操作的原子性。
  • CAS(Compare-And-Swap):Java 的原子类底层依赖于 CAS 操作来实现原子性。CAS 是一种硬件级的指令,它比较内存位置的当前值与给定的旧值,如果相等则将内存位置更新为新值,这一过程是原子的。CAS 可以避免传统锁机制带来的上下文切换开销。

可见性

可见性 指的是一个线程对共享变量的修改,能够被其他线程及时看见。

  • volatilevolatile 关键字是 Java 中用来保证可见性的轻量级同步机制。当一个变量被声明为 volatile 时,所有对该变量的读写操作都会直接从主内存中进行,从而确保变量对所有线程的可见性。
  • synchronizedsynchronized 关键字不仅可以保证代码块的原子性,还可以保证进入和退出 synchronized 块的线程能够看到块内变量的最新值。每次线程退出 synchronized 块时,都会将修改后的变量值刷新到主内存中,进入该块的线程则会从主内存中读取最新的值。
  • Java Memory Model(JMM):JMM 规定了共享变量在不同线程间的可见性和有序性规则。它定义了内存屏障的插入规则,确保在多线程环境下的代码执行顺序和内存可见性。

有序性

有序性 指的是程序执行的顺序和代码的先后顺序一致。但在多线程环境下,为了优化性能,编译器和处理器可能会对指令进行重排序。

  • 指令重排序:为了提高性能,处理器和编译器可能会对指令进行重排序。尽管重排序不会影响单线程中的执行结果,但在多线程环境下可能会导致严重的问题。例如,单例模式 中的 双重检查锁定(DCL) 模式在没有正确同步的情况下,由于指令重排序可能导致对象尚未完全初始化就被另一个线程访问。
  • happens-before 原则:JMM 定义了 happens-before 规则,用于约束操作之间的有序性。如果一个操作 A happens-before 操作 B,那么 A 的结果对于 B 是可见的,且 A 的执行顺序在 B 之前。这为开发者提供了在多线程环境中控制操作顺序的手段。
  • 内存屏障volatile 变量的读写操作会在指令流中插入内存屏障,阻止特定的指令重排序。

线程不安全问题

内存可见性问题

共享变量

Java运行时数据区域

对于每一个线程来说,栈都是私有的,而堆是共有的。

在栈中的变量(局部变量、方法定义的参数、异常处理的参数)不会在线程之间共享,也就不会有内存可见性的问题,也不受内存模型的影响。

而在堆中的变量是共享的,一般称之为共享变量。

CPU缓存模型

CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。

可以把 内存看作外存的高速缓存,程序运行的时候我们把外存的数据复制到内存,由于内存的处理速度远远高于外存,这样提高了处理速度。

工作方式:先复制一份数据到 CPU 缓存 中,当 CPU 需要用到的时候就可以直接从 CPU 缓存 中读取数据,当运算完成后,再将运算得到的数据写回 主存 中。

CPU 为了解决内存缓存不一致性问题可以通过制定缓存一致协议(比如 MESI 协议)或者其他手段来解决。

问题定义

问题定义如下:

  • CPU 缓存一致性 :每个 CPU 核心都可能对共享变量在本地缓存中进行修改,而不立即将修改的值写回到主存(RAM)。这意味着,一个线程对变量的修改可能只有它自己能看到,其他线程可能会看到旧的值,因为它们依然访问的是自己缓存中的旧值。
  • 写回延迟 :当线程修改变量时,它通常会先将修改结果写入自己的 CPU 缓存,而不立即更新到主存。这种写回延迟可能导致其他线程不能及时看到变量的更新,因为其他线程从主存读取数据时并没有得到最新的值。
  • 缓存刷新与同步 :由于缓存的写入并不是同步的,多个线程之间的修改没有立即同步到主存。线程 A 可能在自己的缓存中修改了变量 x,但线程 B 可能依然读取的是线程 A 修改前的值,直到缓存被刷新或被同步。

指令重排问题

问题定义

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

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

单线程情况下不会影响程序执行结果。多线程情况下,指令重排可能导致线程之间的数据不一致问题,影响并发的正确性。

指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题。

示例:单例模式中的“双重检查锁定”就是为了避免指令重排的问题。在初始化单例对象时,由于编译器或 CPU 的指令重排,可能会导致另一个线程读取到未初始化完成的对象。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class Singleton {
   private static volatile Singleton instance;

   private Singleton() {}

   public static Singleton getInstance() {
       if (instance == null) {
           synchronized (Singleton.class) {
               if (instance == null) {
                   instance = new Singleton();  // 可能会发生指令重排
               }
           }
       }
       return instance;
   }
}

instance = new Singleton(); 的执行步骤

  1. 分配内存空间。
  2. 初始化对象。
  3. 将对象指向内存地址。

如果没有 volatile 关键字,编译器或处理器可能会重排步骤 2 和步骤 3,这就会导致另一个线程可能读取到一个尚未初始化完成的对象

Java内存模型的结构

抽象的来看 JMM 会把内存分为 本地内存主存 ,每个线程都有自己的私有化的 本地内存,然后还有个存储共享数据的 主存

主存所有共享变量(实例变量、静态变量)存储在 主存(也就是所有线程共享的内存区域)。

本地内存:每个线程都有一个私有的本地内存,本地内存存储了该线程以读 / 写共享变量的 副本每个线程只能操作自己本地内存中的变量,无法直接访问其他线程的本地内存。 如果线程间需要通信,必须通过 主存 来进行。

JMM抽象示意图

由图可知

  • 所有的 共享变量 都存在 主存 中。
  • 每个线程都保存了一份该线程使用到的 共享变量 的副本。
  • 如果线程间需要通信,必须通过 主存 来进行
    • 当一个线程更改了本地内存中共享变量的副本后,它需要将这些更改刷新到主内存中,以确保其他线程可以看到这些更改。
    • 当一个线程需要读取共享变量时,它可能首先从本地内存中读取。如果本地内存中的副本是过时的,线程将从主内存中重新加载共享变量的最新值到本地内存中。

提示

为什么线程需要拥有自己的内存

  • 在多线程环境中,如果所有线程都直接操作主内存中的共享变量,会引发更多的内存访问竞争,这不仅影响性能,还增加了线程安全问题的复杂度。通过让每个线程使用本地内存,可以减少对主内存的直接访问和竞争,从而提高程序的并发性能。

  • 现代 CPU 为了优化执行效率,可能会对指令进行乱序执行(指令重排序)。使用本地内存(CPU 缓存和寄存器)可以在不影响最终执行结果的前提下,使得 CPU 有更大的自由度来乱序执行指令,从而提高执行效率。

内存可见性的保证

volatile 关键字

  • 写入 volatile 变量时,JMM 强制把值 刷新到主存
  • 读取 volatile 变量时,JMM 强制从主存 获取最新值,而不是从缓存读取。
  • 禁止指令重排序(确保 volatile 变量的读写顺序不会被 JVM 或 CPU 乱序执行)。

synchronized 关键字

  • 进入 synchronized 方法或代码块时,线程必须从主存读取最新变量
  • 退出 synchronized 方法或代码块时,线程必须把修改的变量刷新回主存
  • 由于同一时间只有一个线程能进入 synchronized 代码块,它可以确保变量修改对所有线程可见

顺序一致性的保证和happens-before规则

指令重排的限制条件

  • 在单线程环境下不能改变程序运行的结果
  • 存在数据依赖关系的不允许重排序。

happens-before规则的定义

  • 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  • 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序。

主要规则

  • 程序次序规则:在一个线程中,代码的执行顺序是按照程序中的书写顺序执行的,即一个线程内,前面的操作 happens-before 后面的操作。
  • 监视器锁规则:一个锁的解锁( unlock )操作 happens-before 后续对这个锁的加锁( lock )操作。也就是说,在释放锁之前的所有修改在加锁后对其他线程可见。
  • volatile 变量规则:对一个 volatile 变量的写操作 happens-before 后续对这个 volatile 变量的读操作。它保证 volatile 变量的可见性,确保一个线程修改 volatile 变量后,其他线程能立即看到最新值。
  • 线程启动规则:线程 A 执行 Thread.start() 操作后,线程 B 中的所有操作 happens-before 线程 A 的 Thread.start() 调用。
  • 线程终止规则:线程 A 执行 Thread.join() 操作后,线程 B 中的所有操作 happens-before 线程 A 从 Thread.join() 返回。
  • 线程中断规则:对线程的 interrupt() 调用 happens-before 线程检测到中断事件(通过 Thread.interrupted()Thread.isInterrupted())。
  • 对象的构造规则:对象的构造完成(即构造函数执行完毕) happens-before 该对象的 finalize() 方法调用。
最后更新于 2025-04-16 14:45 UTC
그 경기 끝나고 좀 멍하기 있었는데 여러분 이제 살면서 여러가
使用 Hugo 构建
主题 StackJimmy 设计