JVM - Java 内存模型 (JMM)

JVM 解密 —— Java 内存模型 (JMM)

1. 为什么需要 JMM

在现代多核 CPU 架构下,为了弥补 CPU 与主内存之间的速度差异,每个 CPU 核心都有自己的高速缓存(如 L1, L2, L3 Cache)。这导致一个问题:缓存一致性问题。当多个线程在不同核心上运行时,它们可能操作的是各自缓存中的共享变量副本,导致一个线程的修改对另一个线程不可见。

同时,为了尽可能提升性能,编译器和处理器可能会对指令进行重排序

这些底层细节给并发编程带来了巨大的挑战。为了在各种不同的硬件和操作系统上,都能提供一个一致的、可靠的并发编程体验,Java 设计了一套语言级别的规范,这就是 Java 内存模型 (Java Memory Model, JMM)

  • 定义: JMM 是一个抽象的模型,它屏蔽了底层硬件和操作系统的内存访问差异,为 Java 开发者定义了一套在多线程环境下,如何以及何时可以看到其他线程修改过的共享变量的规则,以及在必要时如何同步访问共享变量。

  • 核心目标: JMM 围绕并发编程中的三个核心特性来建立规则:

    1. 原子性 (Atomicity)
    2. 可见性 (Visibility)
    3. 有序性 (Ordering)

2. JMM 的核心概念:主内存与工作内存

JMM 定义了所有变量都存储在主内存 (Main Memory) 中。每个线程都有自己的工作内存 (Working Memory),其中保存了该线程使用到的共享变量的副本

  • 线程操作规则:
    1. 线程对共享变量的所有操作(读取、赋值等)都必须在自己的工作内存中进行,不能直接读写主内存。
    2. 不同线程之间也无法直接访问对方的工作内存。
    3. 线程间变量值的传递都需要通过主内存来完成。

这个模型解释了为什么会产生可见性等并发问题:当一个线程修改了自己工作内存中的变量副本时,如果它没有及时将这个修改写回主内存,那么其他线程就无法看到这个变化。


3. JMM 如何保证并发编程的三大特性

JMM 通过 volatile, synchronized, final 等关键字,为并发编程提供了内存可见性、原子性和有序性的保证。下面我们结合生活中的例子来理解这三大特性。

3.1 可见性 (Visibility)

可见性指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。

  • 问题来源: 线程操作共享变量时,会先从主内存拷贝一份到自己的工作内存,修改后何时写回主内存是不确定的。这就好像多个人协同编辑一份云端文档,但每个人都先把文档下载到本地电脑上修改,如果修改后不及时上传,其他人就看不到最新的版本。

  • volatile 如何保证可见性:

    • 写操作: 当一个变量被声明为 volatile 后,对这个变量的修改会立即刷新(写入)到主内存中。
    • 读操作: 每次读取 volatile 变量时,线程都会强制从主内存中刷新(获取)最新的值,而不是使用工作内存中的副本。
    • 生活举例: volatile 就像一个公司的中央公告板。当领导(线程 A)发布一条新通知(修改共享变量)时,他会立即张贴在公告板(主内存)上。当员工(线程 B)需要获取最新信息时,他被要求必须直接去公告板查看,而不是依赖自己记忆中的旧信息(工作内存的副本)。这样,信息(变量)的更新对所有人都是实时可见的。
  • synchronized 如何保证可见性:

    • 释放锁时: 当一个线程释放 synchronized 锁时,它会把该线程工作内存中对共享变量的修改全部刷新到主内存中。
    • 获取锁时: 当一个线程获取 synchronized 锁时,它会使该线程的工作内存失效,强制从主内存中重新读取共享变量的最新值。
    • 生活举例: synchronized 就像一个带锁的会议室
      1. 当一个项目组长(线程 A)要修改共享的项目计划书(共享变量)时,他必须先拿到会议室的唯一钥匙(获取锁)
      2. 进入会议室后,他可以安心修改计划书。
      3. 修改完成后,他走出会议室并交还钥匙(释放锁),同时必须把最终版的计划书放在会议室的中央桌上(刷新到主内存)
      4. 当下一个组长(线程 B)拿到钥匙进入会议室时,他必须扔掉自己手头的旧草稿(工作内存失效),并以桌上的最新版计划书为准(从主内存加载)。通过这个“进出会议室”的规则,保证了项目计划书的可见性。

3.2 原子性 (Atomicity)

原子性指一个或多个操作在 CPU 执行过程中,要么全部执行成功,要么全部不执行,不能被中断。

  • 问题来源: 很多我们看起来是单一的操作,比如 count++,在底层实际包含“读取-修改-写入”三个步骤。在多线程环境下,这三个步骤之间可能被其他线程插入执行,导致数据不一致。

  • volatile 的局限性:

    • volatile 不能保证原子性。它只能保证每次读取到的 count 是最新的,但无法保证“读取-修改-写入”这个复合操作不被中断。
    • 生活举例: 还是用中央公告板的例子。假设公告板上有一个数字 count=5,记录了到场人数。两个人(线程 A 和 B)同时到场,他们都需要把人数加一。
      1. A 和 B 同时看到公告板上是 5 (volatile 保证了可见性,他们都读到了最新值)。
      2. A 在自己的脑子里计算 5+1=6
      3. 与此同时,B 也在自己脑子里计算 5+1=6
      4. A 上前把公告板的数字改成 6
      5. 紧接着,B 也上前把公告板的数字改成 6
        结果本应是 7,但却成了 6。问题出在“读-改-写”这个过程可以被“打断”。
  • synchronized 如何保证原子性:

    • synchronized 关键字通过 monitorentermonitorexit 指令,可以保证被其修饰的代码块或方法在同一时间只能被一个线程执行。
    • 生活举例: synchronized 就像一个带锁的卫生间。整个 count++ 操作就像是上厕所的全过程。
      1. 一个人(线程 A)想上厕所,他必须先锁上门(获取锁)
      2. 一旦门锁上,其他任何人(线程 B, C...)都只能在外面排队等着,不能闯入。
      3. 这个人可以安心地完成所有步骤(读取 count -> 加 1 -> 写回 count)。
      4. 完成后,他打开门锁(释放锁),下一个人才能进去。
        这个“锁门”机制确保了上厕所这个“操作”的完整性,不会被任何人中途打扰,从而保证了原子性。

3.3 有序性 (Ordering)

有序性指程序执行的顺序按照代码的先后顺序执行。

  • 问题来源: 为了提高性能,编译器和处理器可能会对指令进行重排序,打乱代码的书写顺序来执行。

  • volatile 如何保证有序性:

    • volatile强制禁止指令重排序。它会作为一个内存屏障 (Memory Barrier / Fence),像一道屏障,确保屏障前的所有操作都执行完,才能执行屏障后的操作。
    • 生活举例: 假设你有一个 volatile boolean initialized = false; 标志位。你写了这样一段代码来初始化一个系统:
      // 1. 加载配置
      // 2. 初始化资源
      initialized = true; // volatile 变量写操作
      
      volatile 就像一个严格的监工。他会确保“加载配置”和“初始化资源”这两件事必须在 initialized 被设置为 true 之前全部完成。不允许聪明的工人(编译器/CPU)为了效率,先把 initialized 设为 true,再回头去加载配置。这样,其他检查 initialized 状态的线程,一旦看到 true,就能确定所有准备工作都已就绪。
  • synchronized 如何保证有序性:

    • synchronized 保证的有序性,体现在它通过将多线程的并发执行,在同步代码块的范围内,强制变成了串行执行
    • 既然在 synchronized 块内,所有操作都是串行发生的,那么对于观察者(其他线程)来说,这些操作的顺序就是确定的,自然也就不会存在因指令重排序在多线程间造成的的逻辑混乱问题。
    • happens-before 规则来看,对一个锁的解锁操作,先行发生于(happens-before)后续对这个锁的加锁操作。这个规则也从内存模型的角度,保证了前一个线程的所有操作结果对后一个线程都是可见且有序的。
    • 生活举例: 回到带锁的会议室。虽然项目组长在会议室里的时候,可以自由安排他自己工作的先后顺序(比如先画图表还是先写文字,这属于块内重排序),但对于外面的人来说,他能观察到的只有:“组长 A 进去完成了一整套工作,然后出来了” -> “组长 B 进去完成了一整套工作,然后出来了”。这两个“整套工作”的顺序是绝对不会乱的。从宏观上看,synchronized 代码块之间的执行是有序的

4. 深度剖析:内存屏障 (Memory Barrier)

内存屏障是理解 volatilesynchronized 底层原理的关键。

4.1 什么是内存屏障?它解决了什么问题?

  • 基本概念:内存屏障(Memory Barrier,也叫内存栅栏),是一种CPU指令。它像一个“栅栏”,用于在代码中创建一个同步点,确保“栅栏”一侧的所有内存操作都执行完毕之后,才能执行另一侧的内存操作。
  • 作用:内存屏障主要解决了JMM中的两大问题:
    1. 指令重排序(Instruction Reordering):内存屏障可以禁止屏障两侧的指令进行重排序,从而保证代码的执行顺序。
    2. 缓存一致性(Cache Coherence):内存屏障可以强制将缓存中的数据写回主内存,或者强制从主内存中重新加载数据到缓存,从而保证多核之间的数据可见性。

4.2 JMM中的四种内存屏障

JMM定义了四种主要的内存屏障:

  1. LoadLoad(加载-加载屏障) 屏障:确保在屏障前的所有加载(Load)操作完成之后,才执行屏障后的所有加载操作。防止后面的加载操作被重排序到屏障之前。
  2. StoreStore(存储-存储屏障) 屏障:确保在屏障前的所有存储(Store)操作对其他处理器可见之后,才执行屏障后的所有存储操作。防止后面的存储操作被重排序到屏障之前。
  3. LoadStore(加载-存储屏障) 屏障:确保在屏障前的所有加载操作完成之后,才执行屏障后的所有存储操作。防止后面的存储操作被重排序到屏障之前。
  4. StoreLoad(存储-加载屏障) 屏障:确保在屏障前的所有存储操作对其他处理器可见之后,才执行屏障后的所有加载操作。防止后面的加载操作被重排序到屏障之前。这是开销最大的“全能屏障”,因为它强制刷新了所有缓存,并禁止了前后所有类型的重排序。
    总结:这些屏障的命名方式是 前一个操作类型 + 后一个操作类型 + 屏障,表示屏障前的前一个操作类型必须在屏障后的后一个操作类型之前完成。

4.3 volatilesynchronized 如何利用内存屏障

  • volatile 的实现

    • 写操作:在一个volatile写操作之后,会插入一个StoreLoad屏障。这个屏障强制将当前线程工作内存中所有修改(包括volatile变量和普通变量)都刷新到主内存。
    • 读操作:在一个volatile读操作之前,会插入一个LoadStore屏障(或其他组合)。这个屏障强制使当前线程的工作内存无效化,然后从主内存重新加载最新的值。
  • synchronized 的实现

    • monitorenter (获取锁):在执行monitorenter指令时,会隐式地插入一个Load屏障,使工作内存无效化,从主内存中加载最新的共享变量。
    • monitorexit (释放锁):在执行monitorexit指令时,会隐式地插入一个Store屏障,将工作内存中所有对共享变量的修改都刷新到主内存。
    • 通过这种方式,synchronized自然地保证了原子性、可见性和有序性

5. 核心重点:Happens-Before 原则

Happens-Before 是 JMM 最核心、最精髓的概念。它定义了在多线程环境下,两个操作之间天然的、无需任何同步措施就存在的先行发生关系。如果两个操作不满足 Happens-Before 关系,那么虚拟机就可以对它们进行任意的重排序。

主要的 Happens-Before 规则:

  1. 程序次序规则: 在一个线程内,按照代码书写的顺序,前面的操作先行发生于后面的操作。
  2. 管程锁定规则: 一个 unlock 操作先行发生于后面对同一个锁lock 操作。
  3. volatile 变量规则: 对一个 volatile 变量的操作,先行发生于后面对这个变量的操作。
  4. 线程启动规则: Thread 对象的 start() 方法先行发生于此线程的每一个动作。
  5. 线程终止规则: 线程中的所有操作都先行发生于对此线程的终止检测(如 Thread.join())。
  6. 传递性: 如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么可以得出操作 A 先行发生于操作 C。

结论: 我们在判断一段并发代码是否线程安全时,不再需要去纠结底层的内存屏障、重排序等复杂问题。我们只需要依据 Happens-Before 原则,去分析操作之间的关系,如果一个操作的结果需要对另一个操作可见,但它们之间又没有任何一条 Happens-Before 规则适用,那么我们就必须自己添加同步措施(如 volatile, synchronized, Lock)来保证线程安全。

posted @ 2026-01-21 16:08  我是刘瘦瘦  阅读(0)  评论(0)    收藏  举报