当前位置: 首页 > ai >正文

深入解析JMM:Java内存模型与并发编程

本文约8600字,需要20-30分钟阅读。

JMM(JAVA memory model)

3.0 一些基础概念

JMM,即Java memory model,是 Java 虚拟机规范中定义的一种抽象模型,用于描述 Java 多线程程序中不同线程之间如何通过内存进行交互。因此只是一种抽象出的、用于解决多线程编程中出现的问题(在多线程并发的情况下,会出现一系列的问题,例如由于cpu的指令重排序、或者编译优化后导致的可见性问题)的模型。或者你可以理解为一种约定俗成的规范,但之所以被称为【模型】,是因为这并非是确定的规范,而是对问题的抽象。因此JMM的出现设定了对应的规范和应对方法,开发者可以便利的利用JMM的规范和原则进行多线程编程而不至于出错。

下面是一些对于JMM的更详细的理解,读者可以阅读整篇文章后再来阅读这一部分:


1. JMM 是理论抽象,而非具体实现

  • JMM 不直接规定 JVM 应该如何实现内存访问,而是定义了一个理论框架,说明在多线程环境下,哪些行为是允许的,哪些是不允许的。
  • 不同的JVM(如HotSpot、OpenJ9)可以有不同的实现方式,只要它们遵守JMM的规则。

2. JMM 定义了线程、主内存和工作内存的交互方式

JMM 不仅仅是一套"写代码的规范",而是定义了线程如何与内存交互的正式模型:

  • 主内存(Main Memory):存储共享变量(如堆中的对象)。
  • 工作内存(Working Memory):每个线程有自己的工作内存(可能是CPU缓存或寄存器),存储它使用的变量的副本。
  • 内存屏障(Memory Barriers):JMM 规定了何时需要同步内存,以确保可见性。
Thread 1       Thread 2|               |v               v
Working Memory  Working Memory|               |v               v
---------Main Memory---------

这个模型描述了线程如何读取、写入和同步数据,而不仅仅是"如何写代码"。


3. JMM 提供了 happens-before 规则,而不仅仅是语法

JMM 的核心是 happens-before 关系,它定义了操作的可见性和顺序性,例如:

  • synchronized 解锁 happens-before 后续的加锁
  • volatile 写 happens-before 后续的读
  • 线程启动(Thread.start())happens-before 该线程的所有操作

这些规则不仅仅是"编程建议",而是严格的数学约束,JVM 必须保证这些规则成立,否则程序的行为就是未定义的。


4. JMM 允许优化,但限制优化范围

  • JVM 和 CPU 会进行指令重排序(Reordering)以提高性能,但 JMM 规定了哪些重排序是允许的,哪些是不允许的。
  • 例如:
    int x = 1;
    int y = 2;
    
    单线程下,xy 的写入顺序可以交换(JVM 可能优化)。
    但在多线程下,如果另一个线程依赖 xy 的写入顺序,JMM 会规定是否需要禁止这种优化。

JMM 的作用就是在保证正确性的前提下,允许最大程度的优化,而不仅仅是"告诉你怎么写代码"。


5. JMM 是语言无关的内存模型

  • JMM 不仅适用于 Java,其他基于 JVM 的语言(如 Kotlin、Scala)也必须遵守它。
  • 类似的模型也存在于其他语言,如 C++11 内存模型(C++ Memory Model),它们都是正式的并发理论,而不仅仅是编程规范。

JMM 更像是并发编程的"物理定律",而不仅仅是"编程风格指南"。


3.0.1 JMM和JVM的关系?

JVM是内存划分和线程管理,而JMM专门定义多线程并发场景下Java线程之间的可见性、有序性和原子性的约定。

jmm要解决的问题主要在如下几个方面:

  • 原子性:保证指令不会受到线程上下文切换,要么都执行,要么都不执行。
  • 可见性:保证指令不会受 cpu 缓存的影响。线程对共享变量的修改能被其他线程立马得知。
  • 有序性:保证指令不会受 cpu 指令并行优化的影响
    想必如果你有疑惑,也是在工作内存、主内存和JVM的内存模型上有区别,可以看下面:

3.0.2 JMM定义的内容?

  1. 主内存(Main Memory)
  • 定义:在JMM中,主内存是所有线程共享的内存区域

  • 存储内容:存储所有的共享变量(实例字段、静态字段等),约等于JVM堆中真正被多线程共享的部分 + 方法区的静态变量,或者是计算机中的内存(注意内存和外存的区别)

  • 特点:
    1.线程对共享变量的所有操作(读/写)都必须在主内存中进行
    2.是线程间通信的媒介
    3.对应于物理内存,但不等同于JVM堆内存

  1. 工作内存(Working Memory)
  • 定义:每个线程私有的内存区域

  • 存储内容:存储该线程使用到的变量的主内存副本,约等于 CPU缓存/寄存器 + 线程栈中的临时数据。这一部分可以展开说一下,因为涉及到了计算机组成原理的比较底层的内容。
    cpu不会直接操作主存,而是寄存器、cache(当然,现代计算机都是多级缓存,一般是三级)。这是因为读写速度的不一致导致的,操作cache的性能更好,但这也衍生出一个问题,我们需要同步cache和主存中的数据,因为写入cache的数据并未写入主存,如果此时被修改,那可能会导致主存的数据出现错误,类似于数据库中的【丢失修改】。我们一般的cache写回主存的策略如下:
    1.Write-Through(直写)
    每次修改缓存时 同时写入主存
    性能较差,但数据一致性最强
    类似 volatile 的写操作
    2.Write-Back(回写)
    先只修改缓存,延迟写入主存(直到缓存行被替换)
    性能更好,但可能导致 脏数据(其他CPU读到旧值)
    类似普通变量的非同步访问
    因此,JMM将cpu、cache和主存的同步问题抽象出来,使用synchronized、volatile等操作插入内存屏障,来解决这些一致性上的问题。(实际我们成为可见性,后面有)

  • 特点:
    1.线程对变量的所有操作(读/写)都必须在工作内存中进行
    2.不能直接访问其他线程的工作内存
    3.对应于CPU缓存和寄存器,但不等同于JVM栈内存

也因此,JVM给JMM的内存上的问题你应该有了大致的理解:JVM的是在物理层面之上的抽象,即JVM的内存模型不考虑物理层面的cpu、cache、主存等问题,只是抽象为堆、栈、方法区等(实际情况更加复杂),而JMM则是对于物理设备的抽象,即对于硬件方面的抽象,并且让程序员可以用代码来解决这些一致性的问题。也因此,程序员不需要关注底层架构(不管是x86还是arm),都可以用volatile等关键字主动操作。所以,如果你对于计算机组成原理有了解,那么这里的JMM解决的就是相关的问题。

  1. 内存屏障

内存屏障(Memory Barrier),也称为内存栅栏(Memory Fence),是计算机系统中的一种同步机制,用于控制处理器对内存操作的顺序和可见性。

内存屏障主要有三个作用:

  1. 防止指令重排序

    • 编译器和处理器为了提高性能会对指令进行重排序,内存屏障会限制这种重排序
  2. 保证可见性

    • 确保屏障前的写操作对其他处理器/核心可见
    • 强制刷新缓存或使缓存失效
  3. 保证执行顺序

    • 确保某些操作按照程序顺序执行

内存屏障有以下几种类型

1. 硬件层面的内存屏障 了解即可

load是从内存到寄存器,store反之,从寄存器到内存

(1) LoadLoad屏障
  • 确保屏障前的Load操作先于屏障后的Load操作完成
  • 对应指令:lfence(x86架构)
(2) StoreStore屏障
  • 确保屏障前的Store操作先于屏障后的Store操作完成
  • 对应指令:sfence(x86架构)
(3) LoadStore屏障
  • 确保屏障前的Load操作先于屏障后的Store操作完成
(4) StoreLoad屏障
  • 确保屏障前的Store操作先于屏障后的Load操作完成
  • 对应指令:mfence(x86架构)
  • 这是最强的内存屏障,开销也最大

2. JVM中的内存屏障

JVM会根据不同平台将Java层面的同步操作转换为适当的内存屏障:

Java操作插入的内存屏障
volatile读LoadLoad + LoadStore
volatile写StoreStore + StoreLoad
monitor enterLoadLoad + LoadStore
monitor exitStoreStore + StoreLoad
final字段写StoreStore

内存屏障的实现原理

  1. 禁止重排序

    • 在屏障两侧的指令不会被重排序跨越屏障
  2. 刷新缓存

    • 强制将缓存数据写回主内存(对于写操作)
    • 使缓存失效(对于读操作)
  3. 等待执行完成

    • 确保屏障前的所有操作完成后再执行屏障后的操作

下面是一段代码,来理解主内存和工作内存:

public class MemoryVisibilityDemo {// 共享变量 - 存储在主内存中private static boolean sharedFlag = false;private static int sharedValue = 0;public static void main(String[] args) throws InterruptedException {// 线程A - 修改共享变量Thread threadA = new Thread(() -> {// 在工作内存中修改(对主内存不可见)// 如果没有同步操作,这些修改可能不会立即写回主内存sharedValue = 42;       // 操作1sharedFlag = true;      // 操作2System.out.println("Thread A: 修改完成");});// 线程B - 读取共享变量Thread threadB = new Thread(() -> {while (!sharedFlag) {// 空转等待,从工作内存读取sharedFlag}// 这里读取到的sharedValue可能是0或42 因为读取的是主内存的内容System.out.println("Thread B: sharedValue = " + sharedValue);});threadB.start();Thread.sleep(100); // 确保线程B先运行threadA.start();}
}

3.1 JMM解决的问题/并发编程的重要特性

说完了一些基础知识,下面先说一下可能会因为硬件出现哪些问题:
首先就是上面说到的cpu、cache之间的问题,这可能线程A写入cache而未写入主存,线程B读取到错误的主存数据导致一致性问题。还有一个问题就是指令重排序:现代处理器会进行指令级并行优化,即每一句代码会翻译为若干句的指令,编译器会在编译的时候进行一定程度的优化,因此这些指令的顺序可能会出现问题。这可能需要读者有计算机系统架构的知识,即了解流水线和乱序发射(当然,不知道也可以,需要知道的就是编译优化会导致指令的顺序发生改变,且这个改变很难判断,想了解可以看一看这个:文章)。由于指令重排序就可能导致多线程的执行结果和预期冲突。
针对这两种问题(cpu缓存模型和乱序发射),衍生出可见性和有序性的问题。

3.1.1 可见性

例如下面的代码,可能会一直运行。因为 run 没有使用 volatile 或同步机制,线程可能无法感知主线程的修改(JIT 可能将其优化为寄存器变量,导致永久循环)。说白了,主线程修改 run 时,虽然最终会同步到主内存,但线程 t 的工作内存可能缓存了旧值(未强制刷新)。

static boolean run = true;public static void main(String[] args) throws InterruptedException {Thread t = new Thread(() -> {while (run) {// ...}});t.start();sleep(1);run = false; // 线程t不会如预想的停下来
}

图解如下:
在这里插入图片描述

因此我们得出结论:可见性是关于cpu和缓存的问题。是否可见,即因为一些优化会导致在主线程的修改对于t线程是不可见的。也就是线程可能使用自己的工作内存而不去使用主内存。这样可以加快处理速度,因为对于代码示例,总运行while循环且总需要读取主内存会浪费资源。解决办法也很简单,就是将工作内存的可能会影响一致性的内容强制刷新到主内存。

volatile,易变的,如果用volatile修饰,则避免线程到自己的工作缓存中查找变量的值,而是每次都要从主内存中获取变量,因此不会出错。同时,synchronized也可以解决可见性问题,因为在jmm中,进入/退出synchronized同步块时,必须完成与主内存数据的同步(synchronized通过"锁的内存屏障"机制保证可见性)。可重入锁也可以。

注意,可见性被保证的同时不能保证原子性,这是两回事。一个是在本地内存和主内存的同步,一个是两个线程对一个变量由于指令交错导致的问题。因此synchronized既可以保证原子性也可以保证可见性,而volatile只能保证可见性。对于上面的修改方法,就是将静态变量加volatile的修饰符。

3.1.2 有序性

这个对应的是指令重排序的问题。执行顺序是可以改变的,在多线程的情况下会导致出错误。例如五段流水的情况下,指令重排序会导致冲突,进而导致错误。例如,b的值是依赖于a的,如果强行重排序,那一定会出现问题。

int a=3;
int b=a-1;
// 这里重排序就会导致错误

例如如下代码:这里的actor是压测工具的注解,通过大量测试探测到所有可能的内存可见性的问题。本段代码会因为指令重排序的原因,有概率将ready=true修改到num=2的前面,因此就会出现0这个结果。

public class ConcurrencyTest {int num = 0;boolean ready = false;@Actorpublic void actor1(I_Result r) {if (ready) {r.r1 = num + num;} else {r.r1 = 1;}}@Actorpublic void actor2(I_Result r) {num = 2;ready = true;}
}

我们可以通过加volatile来解决,即:

volatile boolean ready = false;

这样可以防止ready之前的代码进行指令重排序。你可能疑惑为什么可以解决有序性?原理如下:

3.1.2.1 volatile的原理:

底层实现原理是内存屏障(Memory Fence/Barrier),即对volatile变量的写指令后加入写屏障,volatile变量的读指令之前加入读屏障。

  1. 保证可见性和有序性

​ 写屏障保证在该屏障之前,对于共享变量的改动,都同步到主内存中。且确保指令重排序时,不会将写屏障之前的代码排到写屏障之后。

public void actor2(I_Result r){num = 2;ready=true;//ready 是 volatile 赋值带写屏障// 写屏障 也就是这个地方之前的内容都同步到主内存中
}

​ 读屏障则是在该屏障之后,对于共享变量的读取加载的是主内存的最新数据。读屏障会保证指令重排序的时候,不会将读屏障之后的代码排在读屏障之前。

public void actor1(I_Result r){
// 读屏障 因为现在要读取ready
//ready 是 volatile 读取值带读屏障if(ready){r.r1=num + num;} else{r.r1 = 1;}
}

但还是要注意,读写屏障只能保证可见性(主内存,属于优化)和有序性(重排序,也是优化),但是不能保证指令交错,指令交错是由于多线程导致的。

3.1.2.2 double-checked locking问题(DCL)

这个例子的特点是,懒惰实例化,即先判断是否为null再创建单例。但是如果t1访问到null且还未修改,此时t2也访问null,则会创建两个实例。因此可以在方法上加synchronized,可以保证原子性。但是问题是,加入了synchronized,后续访问的效率变差。

public final class singleton{private singleton(){ }private static singleton INSTANCE = null;public static singleton getInstance(){if(INSTANCE == null){ // t1 t2INSTANCE = new singleton();}return INSTANCE;}
}
// double check
public final class singleton{private singleton(){ }private static singleton INSTANCE = null;public static singleton getInstance(){// 只有首次访问会synchronized 后续不需要if(INSTANCE == null){synchronized(singleton.class){// 对static方法加锁,实际上就是锁类if(INSTANCE == null){ INSTANCE = new singleton();}}} // if return INSTANCE;}
}
// 但是还是会出问题,第一个check的时候可能会因为指令重排导致问题。没有volatile会导致【双重检查锁定失效】的问题

构造过程实际上可能是三步:

  1. 分配内存空间
  2. 调用构造方法初始化对象内容
  3. 把INSTANCE指向分配的内存(此时INSTANCE!=null)

也就是先构造出对象再将instance指向构造出的对象。但是实际运行的时候可能会发生编译优化,即先进行分配再初始化。这样的重排序会导致这样的问题:

  1. 线程A 进入第一次if判断,INSTANCE为null,进入synchronized块。
  2. 线程A 继续判断INSTANCE为null,执行INSTANCE = new Singleton();,发生指令重排序 :先分配内存,再提前把INSTANCE指向了这块内存 ,还没初始化。
  3. 此时线程B 也进来,看到INSTANCE已经不是null(因为A已经提前让其指向了内存),于是B直接return INSTANCE 。
  4. 但此时A还没执行到对象初始化,INSTANCE代表的是一个“还未初始化完的对象”。
  5. B 拿到这个“半初始化”对象,继续使用可能报错或行为异常。

synchronized无法保证不出现错误,因为synchronized只能保证临界区内部的代码的顺序,对于临界区外的无法保证。但是使用volatile就可以解决问题,即将INSTANCE加上volatile的关键字。volatile保证写操作后,会插入写屏障,保证写操作以及之前发生的操作都刷新到主内存中,对其他线程都可见。也就是,在执行到INSTANCE=new Singleton();这个操作后,会插入写屏障,也就保证之前的操作其他线程可见,禁止写屏障之前的指令重排序。

所有更改后的代码如下:

public final class Singleton{private singleton(){ }private static volatile Singleton INSTANCE = null;public static Singleton getInstance(){// 只有首次访问会synchronized 后续不需要if(INSTANCE == null){synchronized(Singleton.class){// 对static方法加锁,实际上就是锁类if(INSTANCE == null){ INSTANCE = new Singleton();// 写屏障}}} // if return INSTANCE;}
}

3.1.3 happen-before

happens-before规定了一个线程对共享变量的写操作对其它线程的读操作可见。具体来说,如果操作 A “happens-before” 操作 B,那么操作 A 对共享内存的修改将对操作 B 可见。下面是一些满足happens-before的写法:

  1. 程序顺序规则:一个线程内,按照代码顺序,书写在前面的操作 happens-before 于书写在后面的操作;但是这里不是不能用重排序优化,是第一条代码一定在第二条代码执行结束前结束。
int x = 1;    // 操作A
int y = x + 1; // 操作B (能看到A的修改)
  1. 解锁规则:对于lock和synchronized都适用,对一个锁的解锁happens-before随后对这个锁的加锁。也就是解锁后的修改都会同步到主内存。
synchronized(lock) { // 加锁x = 42;
} // 解锁
// 其他线程加锁时一定能看到x=42
  1. volatile 变量规则:对volatile变量的写操作happens-before后续对它的读操作.
volatile boolean flag = false;// 线程A
x = 42;      // 普通写
flag = true; // volatile写// 线程B
if (flag) {  // volatile读// 这里保证能看到x=42
}
  1. 线程启动规则:线程start之前的写都可以。
int x = 0;Thread t = new Thread(() -> {// 这里保证能看到x=1
});
x = 1;
t.start();
  1. 有传递性,本例中y=10也是可见的。
volatile static int x;
static int y;new Thread(() -> {y = 10; // 线程 t1 设置 y 的值为 10x = 20; // 线程 t1 设置 x 的值为 20
}).start();new Thread(() -> {System.out.println(x); // 线程 t2 打印 x 的值
}).start();
http://www.xdnf.cn/news/8173.html

相关文章:

  • 2025-05-22 学习记录--Python-函数
  • 使用docker compose部署dify(大模型开发使用平台)
  • DV通配符和OV通配符区别?如何选择?
  • hicFindTADs生成的domains.bed文件解析
  • Android --- CopyOnWriteArrayList 的使用场景及讲解
  • 技术篇-2.5.Matlab应用场景及开发工具安装
  • DDR5和LPDDR5的CA采样时刻对比,含DDR5的1N/2N模式
  • JDK8中的 Stream流式编程用法优化(工具类在文章最后)
  • ollama接口配合chrome插件实现商品标题和描述生成
  • CLIP阅读笔记
  • 亚远景-ASPICE评估中的常见挑战及应对策略
  • 云蝠智能大模型:深度赋能的智能化功能,是怎么做到的?
  • 深入对比分析 Python 中字典和集合 异同
  • 高等数学-曲线积分与曲面积分
  • SpringBoot 对象转换 MapStruct
  • 《函数指针数组:创建与使用指南》
  • 【T2I】Controllable Generation with Text-to-ImageDiffusion Models: A Survey
  • 嵌入式学习笔记 D25 :标准i/o操作(2)、文件i/o
  • 2025年5月通信科技领域周报(5.12-5.18):6G太赫兹技术商用突破 空天地一体化网络进入规模部署期
  • Windows解除占用(解除文件占用、解除目录占用)查看文件进程(查看父进程、查看子进程、查看父子进程)占用文件占用、占用目录占用
  • 纳斯达克与标普500的技术博弈:解析美股交易系统的低延迟与高安全解决方案
  • 基于SpringBoot的动漫交流与推荐平台-036
  • 【学习笔记】计算机操作系统(五)—— 虚拟存储器
  • 数据库5——审计及触发器
  • 模拟地和数字地的连接方式
  • Java中的大根堆与小根堆
  • 无人机避障——深蓝学院浙大Ego-Planner规划部分
  • 工具看点 | 澳鹏多模态标注工具:构建AI认知的语义桥梁
  • 第四十五节:目标检测与跟踪-Meanshift/Camshift 算法
  • MCP Server Resource 开发学习文档