一、简介

volatile 是 Java 提供的一种轻量级的同步机制。Java 语言包含两种内在的同步机制:同步块(或方法)和 volatile 变量,相比于 synchronized(synchronized 通常称为重量级锁),volatile 更轻量级,因为它不会引起线程上下文的切换和调度。但是 volatile 变量的同步性较差(有时它更简单并且开销更低),而且其使用也更容易出错。

使用

一、只能修饰:成员变量 / 静态变量

1、实例成员变量

private volatile int num;

2、静态成员变量

public static volatile boolean flag;

二、绝对不能修饰

  • 不能修饰局部变量(方法里的变量不行)
  • 不能修饰方法
  • 不能修饰类
  • 不能修饰常量 final

三、日常 3 大使用场景

1、多线程 可见性开关 停止线程标记、状态开关

        一个线程改,其他线程立刻看见。

volatile boolean stop = false;

2、禁止指令重排(双重检查锁 DCL 单例)

解决对象初始化乱序问题

private static volatile Singleton instance;

3、轻量级状态标记

只做状态读写,不保证原子性

二、并发编程的 3 个基本概念

1. 原子性

定义:即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

原子性是拒绝多线程操作的,不论是多核还是单核,具有原子性的量,同一时刻只能有一个线程来对它进行操作。简而言之,在整个操作过程中不会被线程调度器中断的操作,都可认为是原子性。例如 a=1 是原子性操作,但是 a++a += 1 就不是原子性操作。Java 中的原子性操作包括: (1)基本类型的读取和赋值操作,且赋值必须是值赋给变量,变量之间的相互赋值不是原子性操作。 (2)所有引用 reference 的赋值操作。 (3)java.concurrent.Atomic.* 包中所有类的一切操作。


2. 可见性

定义:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。Java 提供了volatile来保证可见性,当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,其他线程读取共享变量时,会直接从主内存中读取。当然,synchronizedLock都可以保证可见性。synchronizedLock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。


3. 有序性

定义:即程序执行的顺序按照代码的先后顺序执行。

Java 内存模型中的有序性可以总结为:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句是指 “线程内表现为串行语义”,后半句是指 “指令重排序” 现象和 “工作内存主主内存同步延迟” 现象。

在 Java 内存模型中,为了效率是允许编译器和处理器对指令进行重排序,当然重排序不会影响单线程的运行结果,但是对多线程会有影响。Java 提供volatile来保证一定的有序性。最著名的例子就是单例模式里面的 DCL(双重检查锁)。另外,可以通过synchronizedLock来保证有序性,synchronizedLock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

三、可见性和有序性

(1)从CPU内存模型产生的可见性和有序性的问题

因为 CPU 跑得太快、内存跟不上,为了不让 CPU 摸鱼等数据,才设计 L1、L2 多层高速缓存。

🔹 核心观点

        现代 CPU 有多个核心(Core),每个核心都有自己的 L1/L2 缓存(Cache),而主内存(RAM)是共享的。CPU 为了性能,会先把变量从主内存加载到自己的缓存中操作,而不是每次都访问慢速的 RAM

 📌 模型现状

* CPU 核心 Core A 读取变量 x,将其从主存加载到 Core A 的 L1 缓存中。
* CPU 核心 Core B 也读取变量 x,加载到 Core B 的 L1 缓存中。

✅ 这是正常行为:每个核心有自己的高速缓存,避免频繁访问主内存。

问题爆发点(关键矛盾)

* Core A 修改了 x 的值为 1。通常 CPU 为了快,会先写回自己的缓存(Write Back 策略),而不是立刻写回主存。
* 此时,主存里的 x 还是 0,Core B 缓存里的 x 也是 0。
* Core B 继续用 x=0 计算,导致逻辑错误。

❗ 根本矛盾

CPU 缓存与主内存的数据不一致 —— 多个核心看到的同一变量值不同。

✅ 解决方案(硬件层面)

  • MESI 协议(Modified, Exclusive, Shared, Invalid):CPU 之间通过总线(Bus)通信,当某个核心修改了缓存行,会广播通知其他核心“该数据已失效”,强制它们刷新缓存。
  • 缓存一致性协议(Cache Coherence Protocol):确保所有核心看到的变量值最终一致。

✅ 但注意:这依赖于硬件支持,且有性能开销。Java 不能依赖它,所以需要自己的内存模型来保证。

MESI 协议

🔍 一、为什么需要 MESI?——问题的根源

现代 CPU 的速度远超主内存(DRAM):

  • CPU 一个周期 ≈ 0.3 ns(纳秒)
  • 访问主内存 ≈ 100 ns(慢 300 倍以上

为缓解这个鸿沟,CPU 在每个核心(Core)旁配备了私有高速缓存(L1/L2 Cache),就像在你家楼下开了家「便利店」,而主内存是远在郊区的「大型超市」。

✅ 好处:读写极快(命中缓存时)
❌ 问题来了:

如果多个核心各自缓存了同一块内存地址(比如变量 int counter = 0),
核心 A 把它改成了 1,核心 B 还以为是 0 —— 数据就“不一致”了!
这会导致严重 bug(如竞态条件、计数错误、逻辑崩溃)。

➡️ 所以必须有一套硬件级协议,让所有缓存“心往一处想,劲往一处使”——这就是 MESI 协议诞生的使命

🧩 二、MESI 是什么?——四个状态,一个目标

MESI 是一种基于“失效(Invalidate)”的缓存一致性协议,名字来自缓存行(Cache Line,通常 64 字节)可能处于的 四种状态

状态缩写 全称 中文含义 关键特征(是否脏?能否写?能否共享?)
M Modified 已修改 ✅ 脏数据(与内存不一致);仅本核心持有;可自由写;必须回写(Write-Back)到内存后才能共享
E Exclusive 独占 ❌ 干净(与内存一致);仅本核心持有;可立即写(写即变 M,无需广播);无通信开销,性能最优的写起点
S Shared 共享 ❌ 干净(与内存一致);可能被多个核心同时持有只读(若要写,必须先失效其他副本 → 变成 E/M)
I Invalid 无效 ⚠️ 不可用(数据过时或不存在);任何访问(读/写)都触发缓存缺失(Cache Miss),需重新加载(从内存或其他核心)

📌 重要补充

  • 每个缓存行(Cache Line)独立维护这 2-bit 状态(M=10, E=01, S=00, I=11)。
  • 状态变化由事件驱动(如本核读/写、总线监听到其他核的读/写请求)。
  • 所有状态迁移都严格遵循规则,确保任意时刻对任一内存地址,最多只有一个核心能执行写操作(Write Exclusion),这是强一致性的基础。

📡 三、它是怎么工作的?——靠“总线嗅探(Bus Snooping)”

MESI 不依赖中央协调器,而是采用分布式监听机制

✅ 每个 CPU 核心都“竖着耳朵”,持续监听连接所有核心与内存的共享总线(Bus) 上的所有事务(广播)。

📡 四、工作流程

  • 读数据 先查自己缓存:

    • 有且 E/S → 直接读
    • 没有 → 去主存读取,标记为S 共享,其他核也能读到
  • 写数据(最关键) 要修改缓存里变量:

    1. 先发广播消息,通知所有其他 CPU 核心
    2. 别的核收到后,立刻把自己手里同一份缓存改成 I 失效
    3. 当前核心独占数据,状态改成M 修改,安心修改
    4. 后续时机合适,把 M 状态数据刷回主内存
  • 失效机制核心 只要有一个核修改共享变量,其他所有核的同变量缓存全部立刻失效 别人再用只能重新从主存读最新值 → 全员统一。

四种状态标记 + 总线广播失效通知 一个 CPU 改数据,立刻让其他 CPU 缓存作废,强制所有人拿最新主存数据,硬件层面搞定缓存一致性。

(2)从Java内存模型产生的可见性和有序性的问题

1. volatile 如何保证可见性?

在没有 volatile 修饰时,线程修改变量后,值可能只存在于 CPU 的 L1/L2 缓存中,而没有立即写回主内存。

volatile 的实现原理:

  • 写操作: 当线程对一个 volatile 变量进行写操作时,JVM 会向处理器发送一条 Lock 前缀指令。这条指令会强制将该变量所在缓存行的数据写回到系统主内存。
  • 读操作: 根据 MESI 缓存一致性协议,当某个 CPU 将数据写回主内存时,其他 CPU 会通过“总线嗅探”探测到自己的缓存行已失效。当其他线程再次读取该变量时,发现缓存失效,就会被迫从主内存重新加载最新值。

结果: 线程 A 的修改对线程 B 变成了“可见”的。

2. volatile 如何保证有序性?

为了提高性能,编译器和处理器会进行指令重排序。volatile 通过插入 内存屏障(Memory Barrier) 来禁止特定类型的重排序。

JMM 将内存屏障分为四种,volatile 在读写前后插入了这些屏障:

屏障类型 作用 volatile 中的应用
StoreStore 禁止上面的写和下面的写重排序 在 volatile 之前插入
StoreLoad 禁止上面的写和下面的读重排序 在 volatile 之后插入(开销最大)
LoadLoad 禁止上面的读和下面的读重排序 在 volatile 之后插入
LoadStore 禁止上面的读和下面的写重排序 在 volatile 之后插入

具体表现:

  1. 写 volatile 变量时: 屏障确保了在 volatile 写之前的任何普通写操作,都已经刷新到了主内存。
  2. 读 volatile 变量时: 屏障确保了在 volatile 读之后的任何普通读写操作,都必须在 volatile 读完成后再开始。

    更多推荐