摘要:
volatile 是 Java 并发编程中高频出现的关键字,但很多人对它的理解停留在“保证可见性”这一层面,甚至混淆“赋值操作的原子性”与“复合操作的原子性”。本文从 JMM 抽象规范到底层 CPU 缓存一致性协议,逐层拆解 volatile 的真实行为,并针对两个经典追问进行精解,帮你彻底避开认知误区,写出更稳健的并发代码。

一、可见性问题的本质:有 volatile 和无 volatile 的天壤之别

1.1 无 volatile 的普通变量:为什么线程 B 永远看不到新值?

不加 volatile 的共享变量,没有强制刷新和强制重读的约束,线程操作的是各自工作内存中的“副本”。

线程 A 修改普通变量的流程:

  1. 从主内存拷贝变量到工作内存(CPU 缓存/本地副本);
  2. 在本地修改副本;
  3. 修改后的值不会立刻刷回主内存,何时刷入由 JVM、CPU 缓存策略决定——可能延迟很久,甚至一直不刷。

线程 B 读取时的行为:

  • 不会主动去主内存拉最新值,直接复用自己工作内存中的旧副本;
  • 哪怕线程 A 已经将新值刷新到主内存,线程 B 依然感知不到。

典型“死循环”Bug:

public class VisibilityTest {
static boolean flag = false; // 普通变量,无 volatile

public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (!flag) {
// 线程 B 一直读本地缓存中的 false,死循环
}
System.out.println("循环退出");
}).start();

Thread.sleep(1000);
flag = true; // 主线程修改为 true,但子线程看不到
}
}

程序永远不会输出“循环退出”,因为子线程始终抱着本地的 flag = false 不放。这就是典型的可见性问题

1.2 加 volatile:强制写后刷主存,读前拉主存

volatile 通过插入内存屏障,严格约束读写顺序:

  • 写屏障:写完 volatile 变量后,强制立即将新值刷新到主内存,不允许滞留在 CPU 写缓冲区。
  • 读屏障:读取 volatile 变量前,强制清空工作内存中的旧副本,必须从主内存重新加载最新值。

这样一来,线程 A 写 → 刷主存,线程 B 读 → 拉主存,形成一套闭环,保证线程 B 一定读到线程 A 最新写入的值。这也正是那个核心结论:

不加 volatile:不强制刷新,不强制重读,另一个线程会继续用旧副本;
加 volatile:写必刷主存,读必拉主存,保证读到最新数据。

顺便一提synchronized 也有同样的内存刷新效果:进入同步块会清空缓存从主存加载,退出同步块会刷回主存。区别在于 synchronized 还同时保证了原子性。

二、核心误区:“修改变量 + 刷新主内存”是松散的两步吗?

很多开发者把 volatile 的写操作脑补成两步:

  1. 在工作内存中修改变量的值;
  2. 稍后(或异步)将新值刷新到主内存。

于是担心:线程 B 会不会在步骤 1 之后、步骤 2 之前读到“改了但还没刷”的中间值?
答案是:不会。这种担心本身就是错误的。

2.1 纯赋值操作:JMM 用内存屏障把“改”和“刷”焊死了

volatile int a = 0; a = 1; 为例,线程 A 的写操作实质上是:

  1. 在自己的工作内存中把 a 改为 1;
  2. JVM 立即触发写内存屏障,强制将 a=1 刷到主内存。

只有完成步骤 2,这条 a = 1 赋值指令才算彻底执行完毕。两个动作被绑定成一个不可拆分的整体。

通俗比喻:就像给手机充电——“插上充电线”和“开始充电”是一个整体。你要么没插线(没改变量),要么插上线一定在充电(改了必定刷)。不存在“插了线但没开始充电”的中间状态。

2.2 线程 B 的视角:要么旧值,要么新值,绝无中间态

线程 B 执行 int b = a; 时,会触发读屏障,清空自身工作内存中 a 的副本,然后去主内存拉取值。

  • 如果此时线程 A 的写操作还没完成(改+刷未全部做完),主内存里仍然是旧值 0,线程 B 读到 0;
  • 如果此时线程 A 的写操作已经完成,主内存已刷成 1,线程 B 读到 1;
  • 绝不可能出现线程 A 修改了工作内存、还没来得及刷主内存,线程 B 恰好插进来读到“半成品”的情况——因为没刷就等于没改完,JMM 不允许其他线程看到未完成的写。

所以结论非常明确:
对 volatile 变量的纯赋值操作,“改+刷”是 JMM 保证的原子动作,不存在中间态,线程 B 要么看到旧值,要么看到新值。

2.3 误解的根源:把代码层面的一行与底层动作混为一谈

代码中 a = 1 看起来是一步,底层确实有“改”和“刷”两个细微动作,但 JMM 通过内存屏障把它们死死绑定成一个不可分割的写操作,相当于用屏障把这两个步骤“焊死”成原子的,不允许其他线程插队。认识到这一点,就不会再担忧中间态了。

三、复合操作:volatile 原子性失效的典型场景

volatile 只能保证纯赋值的“改+刷”原子性,对于复合操作完全无能为力。

3.1 i++ 为什么线程不安全?

即使 i 是 volatile 变量,i++ 也是分三步执行的:

  1. 从主内存读取 i 的当前值(假设读到 0);
  2. 在工作内存中将值加 1(得到 1);
  3. 将新值 1 写回主内存(此时触发写屏障,1 被刷入主存)。

多线程下可能发生:

时间 线程 A 线程 B
t1 读 i=0
t2 读 i=0
t3 加 1 → 1,刷主存
t4 加 1 → 1,刷主存

两个线程各执行了一次 i++,最终主内存中的 i 却是 1,而不是期望的 2。原因在于,整个“读-改-写”是一个复合操作,volatile 只能保证最后的“写”这一步是原子的,却无法保证前面“读”到“写”之间的整体原子性。

3.2 正确做法

需要原子类的 CAS 或者锁来保证整个复合操作的原子性:

// 使用 AtomicInteger
AtomicInteger atomicI = new AtomicInteger(0);
// 多线程安全自增
atomicI.incrementAndGet();

// 或者使用 synchronized
synchronized(lock) {
i++;
}

记住:volatile 的核心价值是可见性有序性,而非原子性。纯赋值天然具有单次内存写入的原子性,volatile 通过内存屏障保证它不被拆散;复合操作需要更强的原子性保证,必须引入锁或原子类。

四、往下钻一层:JMM 抽象与 CPU 硬件真实行为

上面讲的是 JMM 抽象规范,那在真实 CPU 上,volatile 读是不是每次都要傻傻地访问物理主内存呢?也不尽然。

4.1 JMM 规范层面的约束

volatile 读的规范是:读取前必须获取主内存当前最新快照,不能复用过期缓存副本

  • 如果全程没有任何线程修改过该 volatile 变量,本地副本的值与主内存一致,规范不需要你每次都重走一遍主存拷贝流程;
  • 一旦有别的线程发生过写操作,本地副本立即失效,下一次读必须重新加载主内存数据。

4.2 硬件层面的 MESI 缓存一致性

volatile 读写会插入相应的内存屏障,底层靠 CPU 的缓存一致性协议(如 MESI)来实现高效同步。

场景 A:连续多次读,无任何线程修改变量

  • 该缓存行在多个核心上处于 Shared(共享)状态,始终有效;
  • 每次 volatile 读,读屏障校验缓存有效后,直接读取 CPU L1/L2/L3 高速缓存,根本不需要访问物理内存条
  • 性能和普通变量读取几乎一样。

示例:

volatile int flag = 1;
while(true) {
int temp = flag; // 线程一直读,没人修改,一直在 CPU 缓存中命中
}

场景 B:有其他线程修改过 volatile 变量

  • 写 volatile 时,通过总线广播,将其他核心中对应缓存行标记为 Invalid(失效);
  • 下一次 volatile 读时,读屏障检测到缓存失效,触发跨核心同步,从持有最新数据的核心或主内存加载最新值,然后填入本地缓存。

这一过程会产生跨核心通信开销,但保证了数据绝对是最新的。

4.3 普通变量 vs volatile 在“无修改”场景下的区别

  • 普通变量:JVM/CPU 可以无限期复用本地缓存副本,哪怕主存中的值变了也感知不到;
  • volatile 变量:在没有修改时也能复用 CPU 缓存,但缓存行受 MESI 协议监控;一旦其他核心修改,本地缓存立即失效,下一次读必须同步最新数据,永远不会出现“永久不可见”的问题。

五、总结与最佳实践

  1. 可见性:volatile 保证写后立即刷主存、读前立即拉主存,解决多线程间的数据不可见问题。
  2. 原子性认知:纯赋值操作(如 a = 1)的“改+刷”被 JMM 绑定为一个原子动作,不存在中间态;线程要么看到旧值,要么看到新值。
  3. 复合操作不原子i++ 这类“读-改-写”操作,volatile 无法保证整体原子性,需使用 AtomicIntegersynchronizedLock
  4. 性能与硬件:在没有写竞争的情况下,volatile 读和普通读性能接近,都是读 CPU 缓存;一旦有写,会触发缓存同步,开销可控但需注意。
  5. 适用场景:volatile 最适合一写多读的场景(如状态标志位),多线程写则需配合更重的同步手段。

六、常见疑问精解

疑问一:不管有没有 volatile,线程 A 将共享变量刷到主内存后,线程 B 就一定会重新去读主内存中的数据吗?还是继续用本地内存中的数据?

明确回答:不一定,这完全取决于是否使用了 volatile(或同步机制)。

  • 没有 volatile 的普通变量
    即使线程 A 已经把修改后的值刷新到了主内存,线程 B 也不会主动去主内存重新读取。它会继续使用自己工作内存(本地缓存)中的旧副本,完全不知道主内存已经更新。这正是一开始死循环例子的症结所在——普通变量没有“强制重读”的约束。
  • 加了 volatile 的变量
    volatile 强制插入了读内存屏障。线程 B 每次读取 volatile 变量前,都必须清空自己的工作内存副本,重新从主内存加载最新值。因此,只要线程 A 的写操作已完成(刷入主存),线程 B 接下来再读,就一定能看到新值。

简单记:
普通变量——你刷你的,我读我的(旧值);
volatile 变量——你刷完,我下次读必须去主存拉取,绝不沿用旧值。

疑问二:volatile 变量不管有没有被更新过,读的时候都一定会从物理主内存中读取吗?

明确回答:不一定会直接读物理内存条,但语义上一定是“最新值”。

在 JMM 抽象规范层面,volatile 读要求获取主内存的最新值。但在实际 CPU 执行时:

  • 如果变量一直没有被任何线程修改过,那么当前 CPU 缓存中的副本就是有效的,读屏障检测到缓存有效,就会直接读取高速缓存(L1/L2/L3),不会每次都傻傻地去访问物理主内存,性能接近普通变量。
  • 一旦有其他线程修改过该变量,写操作会通过 MESI 等缓存一致性协议,将其他核心的对应缓存行标记为失效(Invalid)。下一次 volatile 读时,读屏障检测到缓存失效,就会触发缓存同步,从修改过的核心或主内存中拉取最新数据,然后载入本地缓存。

因此,准确的说法是:
volatile 读在无竞争时会复用高速缓存,但一旦有过修改,必然会通过缓存一致性机制同步到最新值,效果上等价于“每次都从主内存读”。 而普通变量即便主内存已变,也不会触发这种同步,导致永久读到旧副本。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐