第1篇:Java内存模型(JMM)与volatile——并发编程的基石

从一段诡异的代码说起

java 复制代码
public class VolatileDemo {
    private static boolean flag = false;  // 不加volatile
    
    public static void main(String[] args) {
        new Thread(() -> {
            while (!flag) {  // 线程B:死循环等待flag变成true
            }
            System.out.println("线程B退出");
        }).start();
        
        Thread.sleep(1000);
        flag = true;  // 线程A:1秒后将flag设为true
        System.out.println("线程A已设置flag=true");
    }
}

你猜结果是什么? 在server模式下,线程B永远不会退出------明明线程A已经把flag改成了true,线程B却看不见。这不是CPU缓存的问题吗?但JVM内存模型中的"本地内存"又是什么?

本文核心问题:

  1. Java内存模型(JMM)到底是什么?和JVM内存结构是一回事吗?
  2. 可见性问题是怎么发生的?底层原因是什么?
  3. volatile为什么能保证可见性?内存屏障做了什么?
  4. 指令重排序是什么?volatile如何禁止重排序?
  5. happens-before规则有哪些?怎么用?
  6. DCL单例为什么必须加volatile?
  7. volatile能保证原子性吗?和synchronized的区别?
  8. JMM和硬件内存模型的关系是什么?

读完本文你将彻底理解Java并发编程中最基础也最核心的可见性、有序性、原子性三大问题。


一、JMM到底是什么?和JVM内存结构是两码事

疑问:JMM和JVM运行时数据区(堆、栈、方法区)是什么关系?

回答:这是两个完全不同层面的概念,80%的人会搞混。

JVM内存结构(运行时数据区)------物理划分

这是你在JVM系列学到的:

复制代码
JVM运行时数据区:
├── 线程私有
│   ├── 程序计数器
│   ├── 虚拟机栈(栈帧:局部变量表、操作数栈)
│   └── 本地方法栈
└── 线程共享
    ├── 堆(对象实例)
    └── 方法区/元空间(类信息、常量)

关键词:内存区域物理划分、对象存在哪、GC发生在哪。

JMM(Java内存模型)------并发模型抽象

JMM是一个抽象规范,定义了一套规则:

复制代码
JMM抽象模型:
├── 主内存(Main Memory)------ 所有线程共享,存放共享变量
├── 本地内存(Local Memory)------ 每个线程私有,存放共享变量的副本
└── 八个原子操作:lock/unlock/read/load/use/assign/store/write

关键词:并发访问规则、可见性保证、happens-before约束。

举一个例子彻底区分

java 复制代码
public class User {
    private String name = "张三";  // name存储在堆中(JVM内存结构视角)
}

// 线程A
user.setName("李四");  // JMM视角:线程A在本地内存中修改副本,再同步到主内存

// 线程B
System.out.println(user.getName());  // JMM视角:线程B从主内存读取(可能是旧值)
  • JVM内存结构回答:name这个字符串对象存在堆里
  • JMM回答:线程B能否看到线程A的修改、什么时候能看到

一句话总结:JVM内存结构管"东西放哪";JMM管"多个线程怎么读写同一个东西"。


二、可见性问题的真相------从CPU缓存到JMM抽象

疑问:为什么线程A改了flag,线程B看不见?到底是CPU缓存还是JMM的锅?

回答:三层递进关系------底层硬件CPU缓存 → JMM规范抽象 → Java代码行为。

你之前写的那篇「线程本地缓存?CPU缓存!」已经点到了核心------"线程没有本地内存",那是什么?

2.1 硬件层:CPU三级缓存

复制代码
CPU 核心0                    CPU 核心1
   │                           │
L1 缓存(32KB,私有)        L1 缓存(32KB,私有)
   │                           │
L2 缓存(256KB,私有)       L2 缓存(256KB,私有)
   │                           │
   └──────── L3 缓存 ──────────┘
             (共享)
                │
           主内存(RAM)

可见性问题的硬件根因 :每个CPU核心有自己的L1/L2高速缓存。线程A运行在核心0上,修改了变量flag,这个修改可能只停留在核心0的L1缓存里,还没有刷新到主内存。线程B运行在核心1上,从自己核心的L1缓存里读到的还是旧值。

这就是你文章中"线程本地内存其实是CPU缓存"的正确答案------JMM规范里说的"本地内存",在硬件上对应的是CPU的私有缓存。

2.2 JMM层:抽象模型

JMM把这个硬件事实抽象为"主内存"和"本地内存":

复制代码
JMM抽象模型:

线程A                    主内存                    线程B
┌──────────┐         ┌──────────┐         ┌──────────┐
│ flag=true│         │ flag=false│        │ flag=false│(不可见)
│ (副本)   │         │ (主内存) │         │ (副本)   │
└──────────┘         └──────────┘         └──────────┘
  • 线程A修改了flag,但还没有写回主内存
  • 线程B从主内存读到的还是false
  • JMM不保证一个线程的修改立即可见于其他线程,除非显式使用同步机制

2.3 Java代码层

java 复制代码
private static boolean flag = false;  // 没有volatile

// 线程A:flag = true;   // 可能只写到CPU缓存,没到主内存
// 线程B:while(!flag){} // 可能只读CPU缓存,看不到线程A的修改

三层总结:

复制代码
Java层:      没有volatile修饰 → JMM不保证可见性
JMM层:      允许线程在本地内存操作 → 不需要立即同步回主内存  
硬件层:     CPU缓存延迟刷新 → 其他核心看不到

三、volatile如何保证可见性?内存屏障的魔法

疑问:volatile到底做了什么,让flag的修改能被所有线程看见?

回答:volatile通过在指令序列中插入内存屏障(Memory Barrier),强制完成三件事。

3.1 volatile的读写语义

java 复制代码
private static volatile boolean flag = false;

加上volatile后,JMM规定:

操作 语义
volatile写 将当前线程本地内存中修改的值立即刷新到主内存
volatile读 每次读取都从主内存重新加载,不使用本地内存的缓存值

3.2 内存屏障的种类和作用

JMM定义了四种内存屏障:

复制代码
屏障类型           指令示例              作用
──────────────────────────────────────────────────
LoadLoad        Load1;LoadLoad;Load2    确保Load1在Load2之前完成
StoreStore      Store1;StoreStore;Store2 确保Store1在Store2之前完成
LoadStore       Load1;LoadStore;Store2   确保Load1在Store2之前完成
StoreLoad       Store1;StoreLoad;Load2   确保Store1在Load2之前完成(最重)

volatile的插入规则

java 复制代码
// volatile写之前插入 StoreStore 屏障
// 确保在写volatile之前,之前的普通写操作全部完成
storestore();
volatile变量 = 新值;

// volatile写之后插入 StoreLoad 屏障  
// 确保本次volatile写对后续读可见
storeload();

// ====================

// volatile读之后插入 LoadLoad 屏障
// 确保后续普通读操作能读到最新值
int val = volatile变量;
loadload();

// volatile读之后插入 LoadStore 屏障
// 确保后续普通写操作不重排到volatile读之前
loadstore();

用底层术语讲 :内存屏障本质上是一条CPU指令(如x86的mfencelfencesfence),它强制CPU将写缓冲区的数据刷到缓存/内存,并使其他核心的缓存行失效。

3.3 缓存一致性协议(MESI)

volatile除了内存屏障,还依赖CPU的缓存一致性协议

复制代码
MESI四种状态:
M (Modified)  : 该缓存行只在本核心,已被修改,需要写回主内存
E (Exclusive) : 该缓存行只在本核心,与主内存一致
S (Shared)    : 该缓存行在多个核心,与主内存一致
I (Invalid)   : 该缓存行无效,需要从主内存重新读取

volatile写时:
→ 将本地缓存行状态置为M
→ 通过总线发送消息,使其他核心的对应缓存行失效(置为I)
→ 其他核心读取时发现缓存失效,从主内存重新加载

可见性的完整链路

复制代码
线程A写volatile变量:
  StoreStore屏障 → 刷新写缓冲区 → CPU发RFO消息 → 
  其他核心缓存行失效(I) → 写入主内存 → StoreLoad屏障

线程B读volatile变量:
  本地缓存失效(I) → 从主内存加载 → LoadLoad屏障 → 读到最新值

四、指令重排序与volatile的有序性保证

疑问:加了volatile就能禁止指令重排吗?什么是重排序?

回答:volatile能禁止特定位置的重排序,但不是禁止全部。

4.1 编译器和CPU的重排序

java 复制代码
// 你写的代码
a = 1;          // 1
b = 2;          // 2
flag = true;    // 3 (volatile写)
c = 3;          // 4
d = 4;          // 5

JMM允许的重排序范围

复制代码
重排序自由区:
  a=1 和 b=2 可以互换(1和2之间没有屏障)
  c=3 和 d=4 可以互换(4和5之间没有屏障)

重排序禁止区:
  所有在 volatile写之前的操作,不能重排到volatile写之后
  所有在 volatile写之后的操作,不能重排到volatile写之前

本质:volatile就像一个"栅栏",只能管住栅栏两侧的操作不互换,但栅栏同侧的操作依然可以自由重排。

4.2 一个经典例子

java 复制代码
// 线程A
data = 100;          // 1
ready = true;        // 2 (volatile写)

// 线程B
if (ready) {         // 3 (volatile读)
    System.out.println(data);  // 4
}

volatile保证了什么?

  • 1一定在2之前执行(不会被重排到2之后)
  • 3一定在4之前执行(不会被重排到4之后)
  • 所以:线程B看到ready=true时,一定能看到data=100

如果没有volatile修饰ready :1和2可能重排,线程B可能看到ready=truedata=0(data还没赋值)。


五、happens-before规则------JMM的终极法则

疑问:除了volatile,还有哪些情况能保证可见性?

回答:JMM定义了一套happens-before规则,只要满足其中一条,前一个操作的结果就对后一个操作可见。

八大happens-before规则

java 复制代码
1. 程序次序规则:同一个线程内,前面的代码 happens-before 后面的代码
   int a = 1;      // 1
   int b = 2;      // 2
   // 1 happens-before 2

2. volatile变量规则:volatile写 happens-before volatile读
   volatile int v;
   v = 1;   // 写
   int x = v;  // 读,能看到v=1

3. 锁规则:unlock happens-before lock
   synchronized(obj) { a = 1; }  // 释放锁
   synchronized(obj) { int x = a; }  // 获取锁,x一定等于1

4. 传递性:A hb B, B hb C → A hb C
   // 结合规则1+2:volatile写前的所有操作,对volatile读后的所有操作可见

5. 线程启动规则:Thread.start() happens-before 该线程的run()
   t.start();
   // t.run()能看到start()之前的所有修改

6. 线程终止规则:线程的所有操作 happens-before join()返回
   t.join();
   // join返回后,能看到t线程的所有修改

7. 线程中断规则:interrupt() happens-before 被中断线程检测到中断
   t.interrupt();
   // t检测到中断时,能看到interrupt()之前的所有修改

8. 对象终结规则:构造函数执行完 happens-before finalize()

happens-before是最重要的并发概念,它是判断"线程B能不能看到线程A的修改"的唯一标准。不满足任何一条规则,就不能保证可见性。


六、DCL单例为什么必须用volatile?

疑问:双重检查锁定(DCL)单例模式,为什么volatile不能省?

回答:因为new操作不是原子的,没有volatile会导致指令重排序,线程可能拿到"半初始化"的对象。

6.1 没有volatile的DCL

java 复制代码
public class Singleton {
    private static Singleton instance;  // 没有volatile
    
    public static Singleton getInstance() {
        if (instance == null) {         // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) { // 第二次检查
                    instance = new Singleton();  // 问题出在这!
                }
            }
        }
        return instance;
    }
}

6.2 new Singleton() 的实际执行过程

JVM将这一行代码分解为三条指令:

复制代码
memory = allocate();        // 1. 分配内存空间
ctorInstance(memory);       // 2. 调用构造函数初始化对象
instance = memory;          // 3. 将instance指向分配的内存地址

问题:指令2和3可能被重排序!

复制代码
memory = allocate();        // 1
instance = memory;          // 3(重排后先执行) ← instance已经非null了!
ctorInstance(memory);       // 2(重排后后执行) ← 但对象还没初始化!

多线程下的灾难

复制代码
时间线:
T1: 进入synchronized,执行new操作
T1: 分配内存 → instance指向内存(但还没初始化)  
T2: 第一次检查 instance != null → 直接返回instance
T2: 拿到一个没初始化的对象!可能NPE或拿到错误的字段值
T1: 初始化对象(已经晚了)

6.3 加volatile解决

java 复制代码
private static volatile Singleton instance;

volatile禁止了指令2和3的重排序。因为instance = memory是一个volatile写,它之前的ctorInstance(memory)(普通写)不能重排到volatile写之后。

完整的DCL

java 复制代码
public class Singleton {
    private static volatile Singleton instance;
    
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();  // volatile保证安全
                }
            }
        }
        return instance;
    }
}

七、volatile不能保证原子性

疑问:volatile保证了可见性,那i++用volatile安全吗?

回答:不安全。volatile只保证可见性和有序性,不保证原子性。

java 复制代码
private static volatile int count = 0;

// 10个线程各执行10000次 count++
count = 0  → 最终结果 ≠ 100000(远小于预期)

原因count++不是原子操作,它分为三步:

复制代码
1. 从主内存读取count的当前值(比如42)
2. 在CPU里执行 42 + 1 = 43
3. 把43写回主内存

并发问题

复制代码
时间线:
T1: 读取 count = 42
T2: 读取 count = 42   ← 两个线程都读到了42
T1: count = 43         ← T1写回
T2: count = 43         ← T2写回,也写43!两次++但值只加了1

volatile能做到什么 :T1写回后,T2能立即看到最新值(不会读到过期值),但不能阻止T2在T1写回之前就已经读取了旧值

解决方案

方案 用法
synchronized synchronized(this) { count++; }
AtomicInteger atomicInteger.incrementAndGet()
LongAdder 高并发计数最优
java 复制代码
private static final AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet();  // CAS保证原子性

八、JMM与硬件内存模型的对应关系

疑问:JMM为什么设计得这么抽象?直接映射硬件不更简单吗?

回答:因为不同CPU架构的内存模型差异巨大,JMM必须"屏蔽硬件差异",提供统一的行为保证。

硬件平台 内存模型特点 重排序程度
x86/x64 强内存模型(TSO) 只允许StoreLoad重排
ARM/PowerPC 弱内存模型 几乎所有重排都可能
SPARC TSO或RMO可选 取决于配置

JMM的设计折中

复制代码
强一致性模型(如x86 TSO):
  优点:程序员容易理解,几乎不需要内存屏障
  缺点:限制了硬件优化,性能差

弱一致性模型(如ARM):
  优点:硬件可以大幅重排,性能好
  缺点:程序员需要大量使用屏障,容易出错

JMM的折中:
  为Java程序员提供统一的、易理解的happens-before规则
  用volatile/synchronized声明需要保证可见性的地方
  JVM负责在不同平台上插入对应的内存屏障

同一个volatile,不同平台的实现

java 复制代码
// Java代码:volatile写
x = 1;

// JVM在x86上的实现(只禁止StoreLoad重排,用mfence):
mov [x], 1
mfence

// JVM在ARM上的实现(禁止所有相关重排,用dmb):
dmb ish
str r1, [r0]
dmb ish

JMM的意义 :你只需要写一次volatile,剩下的屏障插入、平台适配全由JVM负责。这就是"一次编写,到处运行"在并发领域的体现。


总结

  • JMM是并发访问的抽象规范,JVM内存结构是内存区域的物理划分,两者完全不同的概念
  • 可见性问题的硬件根因是CPU私有缓存,JMM用"本地内存"抽象了它
  • volatile通过内存屏障强制刷新写缓冲区、使其他核心缓存失效,保证可见性
  • volatile通过禁止特定位置的重排序保证有序性,但不禁止所有重排
  • happens-before是判断并发操作间可见性的唯一标准,掌握八条规则即可
  • DCL单例必须加volatile,因为new操作可能被重排,导致拿到半初始化对象
  • volatile不保证原子性,i++这种复合操作需要synchronized或AtomicInteger
  • JMM的设计目的是屏蔽不同硬件平台的差异,提供统一的内存可见性保证

下一篇预告:第2篇------synchronized与ReentrantLock深度对比。

相关推荐
是宇写的啊4 小时前
MyBatis-Plus
java·开发语言·mybatis
SamDeepThinking5 小时前
如何让订单系统和营销系统解耦
java·后端·架构
消失的旧时光-19435 小时前
线程池解决了什么?为什么还不够?(从线程到协程 · 第2篇)
java·大数据·数据库
jay神5 小时前
基于团队模式的C程序设计课程辅助教学管理系统
java·spring boot·vue·web开发·管理系统
薪火铺子5 小时前
Shiro权限框架深度解析
java·后端
1.14(java)5 小时前
Spring AOP核心概念与实战指南
java·后端·spring
亚历克斯神5 小时前
Java 安全最佳实践:构建安全的 Java 应用
java·spring·微服务
橙子圆1236 小时前
java之拦截器和适配器模式
java·开发语言
lifewange6 小时前
Claude Code可以安装在IDEA和Pycharm中么
java·pycharm·intellij-idea