跳到主要内容 Java 并发:volatile、内存屏障与 CPU 缓存 | 极客日志
Java java 算法
Java 并发:volatile、内存屏障与 CPU 缓存 讲解 Java 并发编程中 CPU 缓存导致的可见性与有序性问题。介绍了 volatile 关键字如何通过内存屏障解决这些问题,包括保证可见性、禁止指令重排序,但无法保证原子性。同时阐述了内存屏障的四种类型及 JVM 插入规则,并总结了三者关联及常见问题解答。
日志猎手 发布于 2026/3/30 更新于 2026/4/18 8 浏览本文深入探讨 CPU 缓存、内存屏障与 volatile 关键字在并发编程中的应用。
这三者都是为了解决 '多线程下,数据不一致、执行顺序乱' 的问题。
一、CPU 缓存
在聊 volatile 和内存屏障之前,我们先来说一说 CPU 缓存 —— 因为 volatile 和内存屏障的所有设计,都是为了解决 'CPU 缓存带来的问题'。
多线程修改共享变量(比如 int count = 0),count 会先被加载到 CPU 缓存,CPU 修改缓存里的 count 后,不会立刻写回主内存;
其他线程读取 count 时,会从自己的 CPU 缓存里读,而不是主内存 —— 这就会导致:一个线程改了 count,另一个线程看不到(可见性问题);
甚至 CPU 会为了提升效率,打乱 '读缓存、写缓存、写主内存' 的顺序(指令重排序),导致多线程执行顺序混乱(有序性问题)。
2. CPU 缓存的核心问题(并发 bug 的根源) 这两个问题,就是 volatile 和内存屏障要解决的:
可见性问题 :CPU 缓存里的数据,不会实时同步到主内存;其他 CPU(其他线程)不会实时读取主内存的最新数据,导致多个线程看到的 '同一个变量' 不一样;
有序性问题 :CPU 会打乱指令执行顺序(重排序),单线程下没问题,但多线程下会导致逻辑错乱。
private static int count = 0 ;
public static void main (String[] args) {
new Thread (() -> {
count = 1 ;
System.out.println("线程 1 修改 count 为 1" );
}).start();
new Thread (() -> {
while (count == 0 ) {
}
System.out.println("线程 2 读取到 count = " + count);
}).start();
}
实际运行中,可能出现:线程 1 已经修改 count 为 1(修改的是自己 CPU 缓存里的 count),但没写回主内存;线程 2 一直从自己的 CPU 缓存里读 count=0,陷入死循环 —— 这就是 CPU 缓存导致的可见性问题。
3. 缓存命中 / 缺失
缓存命中:CPU 要读 / 写的数据,正好在缓存里,速度极快;
缓存缺失:CPU 要读 / 写的数据,不在缓存里,速度变慢,会把主内存的数据加载到缓存(缓存填充)。
二、volatile 关键字 volatile(中文:易变的),是 Java 里的一个关键字,修饰共享变量时,能解决上面说的可见性问题和有序性问题 ,但不能解决原子性问题 。
类比一下:volatile,就是给 '冰箱里的菜' 贴了一张 '通知'——
你修改了冰箱里的菜(修改缓存数据),必须立刻把修改后的菜放回超市(主内存);
其他人家的冰箱(其他 CPU 缓存)里,只要有这道菜(该变量),立刻失效,必须去超市(主内存)重新拿最新的;
同时,不准打乱 '拿菜、做菜、放菜' 的顺序(禁止指令重排序)。
1. volatile 的作用
作用 1:保证可见性,解决缓存同步问题
当一个线程修改了 volatile 修饰的共享变量,会强制将缓存里的修改后的数据,立刻写回主内存 ;
同时,会使其他线程缓存里的该变量副本失效 ,其他线程读取该变量时,必须从主内存重新读取(相当于 '强制刷新缓存')。
还是上面的 count 例子,给 count 加 volatile 修饰:
private static volatile int count = 0 ;
此时,线程 1 修改 count=1 后,会立刻写回主内存,同时使线程 2 缓存里的 count 失效;线程 2 读取 count 时,会从主内存读到最新的 1,跳出循环 —— 可见性问题解决。
作用 2:保证有序性,禁止指令重排序
CPU 为了提升效率,会对 '没有依赖关系' 的指令进行重排序(比如:先读 a,再读 b,可能被重排为 '先读 b,再读 a');
volatile 会禁止这种重排序,保证 'volatile 修饰的变量' 的读写指令,按我们写的顺序执行(通过插入内存屏障实现)。
public class Singleton {
private static Singleton instance;
public static Singleton getInstance () {
if (instance == null ) {
synchronized (Singleton.class) {
if (instance == null ) {
instance = new Singleton ();
}
}
}
return instance;
}
}
instance = new Singleton() 实际被 CPU 拆分为 3 步:
分配内存空间;
初始化 Singleton 实例(调用构造方法);
把 instance 指向分配的内存空间(此时 instance != null);
CPU 可能重排为:1→3→2—— 当线程 1 执行到 3(instance != null),但还没执行 2(未初始化),线程 2 进来,第一次检查 instance != null,直接返回 instance(未初始化),调用方法时出现空指针。
给 instance 加 volatile 修饰,会禁止这种重排序,保证 1→2→3 的顺序执行,避免空指针 —— 这就是 volatile 保证有序性的实际用途。
作用 3:不保证原子性 原子性,就是 '操作不可中断'—— 要么全部执行成功,要么全部不执行,中间不会被其他线程打断。
volatile不能保证原子性 ,比如 count++ 这种复合操作(拆分为:读 count、count+1、写 count),即使 count 被 volatile 修饰,多线程下依然会出现线程安全问题。
例子:10 个线程,每个线程执行 1000 次 count++,count 被 volatile 修饰:
private static volatile int count = 0 ;
public static void main (String[] args) throws InterruptedException {
for (int i = 0 ; i < 10 ; i++) {
new Thread (() -> {
for (int j = 0 ; j < 1000 ; j++) {
count++;
}
}).start();
}
Thread.sleep(2000 );
System.out.println("count 最终值:" + count);
}
因为 count++ 是 3 步操作,线程 1 读 count=0,还没执行 + 1 和写回,线程 2 也读 count=0,两者都执行 + 1,写回主内存都是 1—— 相当于少加了 1 次。
解决办法:用 synchronized、Lock,或者原子类(AtomicInteger),保证原子性。
2. volatile 的使用场景
修饰 '状态标记变量':比如 boolean flag = false; 一个线程修改 flag,另一个线程根据 flag 判断是否执行,用 volatile 保证可见性;
单例模式双重检查锁(DCL):修饰单例对象 instance,禁止指令重排序,避免空指针;
修饰 '高频读取、低频修改' 的共享变量:比如配置信息,修改后所有线程能立刻看到最新配置。
三、内存屏障 前面说过,volatile 的可见性和有序性,都是通过内存屏障 实现的 —— 内存屏障是 '底层 CPU 指令',相当于 '指令之间的隔离带',管住指令的执行顺序和缓存的同步。
禁止屏障前后的指令重排序(前面的指令必须执行完,后面的才能执行);
强制刷新缓存(把缓存里的修改写回主内存,或从主内存重新读取缓存)。
1. 内存屏障的 4 种类型 CPU 层面的内存屏障有 4 种,Java 里 JVM 会根据不同的 CPU 架构,自动插入对应的内存屏障
屏障类型 作用 对应 volatile 的操作 读屏障(LoadLoad) 禁止读屏障后面的 '读指令',重排到读屏障前面;强制从主内存读取数据 volatile 变量读取前,插入读屏障 写屏障(StoreStore) 禁止写屏障前面的 '写指令',重排到写屏障后面;强制把缓存里的修改,写回主内存 volatile 变量修改后,插入写屏障 读写屏障(LoadStore) 禁止读屏障前面的 '读指令',重排到写屏障后面 较少用,保证读完成后再写 写读屏障(StoreLoad) 禁止写屏障后面的 '读指令',重排到写屏障前面;强制写回主内存,同时使其他 CPU 缓存失效 volatile 变量修改后,插入写读屏障(最核心,保证可见性)
3. volatile 插入内存屏障的规则 Java 中,volatile 变量的 '读写操作前后',JVM 会自动插入 3 种内存屏障,保证可见性和有序性:
volatile 变量写操作后 :插入 StoreStore 屏障 + StoreLoad 屏障;
StoreStore 屏障:保证 volatile 写操作,先于后面的所有写操作执行;
StoreLoad 屏障:保证 volatile 写操作完成后,立刻把数据写回主内存,同时使其他 CPU 缓存失效;
volatile 变量读操作前 :插入 LoadLoad 屏障 + LoadStore 屏障;
LoadLoad 屏障:保证 volatile 读操作,读取的是主内存的最新数据;
LoadStore 屏障:保证 volatile 读操作完成后,再执行后面的写操作。
'volatile 底层是怎么实现可见性和有序性的?
volatile 的底层是通过插入内存屏障实现的。JVM 会在 volatile 变量的写操作后,插入 StoreStore 和 StoreLoad 屏障,强制将修改后的数据写回主内存,并使其他线程的缓存副本失效;在 volatile 变量的读操作前,插入 LoadLoad 和 LoadStore 屏障,强制从主内存读取最新数据。同时,内存屏障会禁止屏障前后的指令重排序,从而保证 volatile 的可见性和有序性。
四、三者关联总结
CPU 缓存导致了 '可见性和有序性问题';volatile 关键字用来 '解决这两个问题';而内存屏障是 'volatile 的底层实现手段',通过禁止指令重排序、强制刷新缓存,帮 volatile 实现可见性和有序性。
五、常见问题
1. volatile 关键字的作用是什么?能保证原子性吗?为什么?
volatile 的作用是保证共享变量的可见性和有序性,不能保证原子性。可见性:修改 volatile 变量后,会立刻写回主内存,同时使其他线程的缓存副本失效,其他线程必须从主内存读取最新数据;有序性:通过插入内存屏障,禁止指令重排序,保证变量读写顺序和代码一致;不保证原子性:因为 volatile 无法解决 '复合操作(比如 count++)的中断问题',复合操作拆分为多步,中间可能被其他线程打断,导致数据错乱。
2. volatile 底层是怎么实现的?和内存屏障有什么关系?
volatile 的底层是通过插入内存屏障实现的。JVM 会在 volatile 变量的写操作后,插入 StoreStore 和 StoreLoad 屏障,强制将修改后的数据写回主内存,使其他线程缓存失效;在 volatile 变量的读操作前,插入 LoadLoad 和 LoadStore 屏障,强制从主内存读取最新数据。内存屏障的核心作用是禁止指令重排序、强制刷新缓存,这正是 volatile 实现可见性和有序性的关键,相当于 'volatile 是上层关键字,内存屏障是底层实现手段'。
3. CPU 缓存为什么会导致可见性问题?怎么解决?
因为 CPU 运算速度远快于主内存,CPU 会优先操作缓存里的数据,不会实时将缓存数据同步到主内存;其他线程读取数据时,会从自己的 CPU 缓存读取,而不是主内存,导致多个线程看到的同一个变量不一致,引发可见性问题。解决办法:用 volatile 修饰共享变量,通过内存屏障强制缓存同步和禁止重排序;或者用 synchronized、Lock,保证同一时刻只有一个线程修改变量,同时释放锁时会刷新缓存。
微信扫一扫,关注极客日志 微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具 Keycode 信息 查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
Escape 与 Native 编解码 JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
JavaScript / HTML 格式化 使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
JavaScript 压缩与混淆 Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
加密/解密文本 使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online