摘要: 本文深入探讨Java并发编程中的内存可见性问题,从CPU缓存与指令重排序的硬件原理出发,解析Java内存模型(JMM)的核心机制。通过具体代码示例展示volatile关键字如何解决可见性问题,对比volatile与synchronized的适用场景,并详细解释原子性、可见性、有序性三大并发概念的区别。最后提供Happens-Before规则解析和常见误区澄清,帮助开发者编写正确高效的并发代码。
关键词: Java内存模型, volatile关键字, 内存可见性, 并发编程, Happens-Before规则
在前面的几篇文章中,我们一直在讨论"线程安全"的问题。我们用 synchronized 和 Lock 解决了多个线程同时修改共享数据的冲突。你可能会有一种感觉:只要我把所有共享数据的访问都用 synchronized 包起来,就万事大吉了。
但事实并没有那么简单。即使你用了 synchronized,有些诡异的问题还是会出现------比如,一个线程修改了一个变量的值,另一个线程却"看不见"这个修改,仍然在使用旧值。这种"看不见"的问题,就是今天我们要聊的核心:可见性(Visibility)。
要理解可见性,我们就不能只停留在"线程"和"锁"的层面了,而是要再往下走一层,看看 Java 程序在 JVM 和 CPU 层面到底是怎么执行的。这就是 Java 内存模型(Java Memory Model, JMM)。
1. 一个"看不见"的例子
我们先来看一个令人困惑的程序:
java
public class VisibilityProblem {
private static boolean running = true;
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
System.out.println("工作线程启动...");
while (running) {
// 忙等待
}
System.out.println("工作线程退出!");
});
worker.start();
// 主线程休眠 1 秒,让工作线程先跑起来
Thread.sleep(1000);
System.out.println("主线程准备停止工作线程...");
running = false; // 修改 running 为 false
System.out.println("主线程已将 running 设为 false");
worker.join();
System.out.println("主线程结束");
}
}
你可能会觉得:主线程把 running 设成了 false,工作线程的 while (running) 循环条件变成 false,它就应该退出了。
但实际运行结果是------工作线程可能永远不会退出 !即使主线程已经把 running 设成了 false,工作线程仍然在死循环里转。
为什么会这样?难道 running = false 这个修改没有生效吗?
不,它生效了,只是工作线程看不到这个修改。
2. 为什么看不见?------ CPU 缓存与指令重排序
要解释这个问题,我们需要了解计算机的硬件架构。
2.1 CPU 与主存的速度差距
CPU 执行指令的速度极快,而主内存(RAM)的读写速度相对较慢,两者之间差了若干个数量级。为了弥补这个差距,现代 CPU 在 CPU 核心和主存之间加入了多级高速缓存(Cache):
CPU 核心 1 → L1 Cache → L2 Cache → L3 Cache → 主内存
CPU 核心 2 → L1 Cache → L2 Cache → L3 Cache → 主内存
当一个 CPU 核心需要读取一个变量时,它会先从缓存中找,如果缓存中没有,再从主存加载到缓存中。当它修改变量时,修改会先写在缓存里,然后再"同步"回主存。
问题就在这里:不同的 CPU 核心有自己的缓存,它们之间的数据可能不一致。
在我们的例子中:
- 主线程运行在 CPU 核心 1 上,它把
running从true改成了false。但这个修改可能只写到了核心 1 的缓存中,还没有同步回主存。 - 工作线程运行在 CPU 核心 2 上,它一直从自己的缓存中读取
running。由于缓存没有及时同步,它看到的仍然是旧的true。
这就是 可见性问题:一个线程对共享变量的修改,另一个线程可能看不到。
2.2 指令重排序
除了缓存的问题,编译器和 CPU 还可能会对指令进行重排序(Reordering)------在不改变单线程执行结果的前提下,调整指令的执行顺序,以提高性能。
在单线程环境下,重排序不会产生问题,因为最终结果是一样的。但在多线程环境下,重排序可能会导致意料之外的结果。
java
// 示例:假设 a 和 b 是共享变量
a = 1; // 语句1
b = 2; // 语句2
编译器和 CPU 可能会把语句2 放在语句1 之前执行(如果它们没有依赖关系)。在单线程中,这没问题。但在多线程中,如果另一个线程观察到了 b = 2 但还没有观察到 a = 1,就可能产生逻辑错误。
3. Java 内存模型(JMM)------ 一套"约定"
为了在不同的硬件平台上保证 Java 程序的正确性,Java 定义了一套抽象的内存模型------Java 内存模型(JMM)。
JMM 的核心思想是:它规定了在多线程环境下,一个线程对共享变量的修改何时对另一个线程可见。它定义了一套规则(Happens-Before 规则),只要遵循这些规则,JVM 就会保证内存可见性。
JMM 的抽象结构如下:
Java 线程
|
工作内存(线程私有)
/ | \
读取 使用 赋值
\ | /
主内存(所有线程共享)
- 主内存:所有线程共享的内存区域,存储所有的共享变量(实例字段、静态字段、数组元素)。
- 工作内存 :每个线程私有的内存区域,存储该线程使用的共享变量的本地副本。线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,不能直接读写主内存。工作内存中的变量是主内存的一个"快照"。
线程 A 修改变量后,需要把新值"写回"主内存;线程 B 读取变量时,需要从主内存中"获取"最新值。JMM 定义了什么时候必须"写回"和"刷新"。
4. volatile 关键字 ------ "轻量级同步"
volatile 是 Java 提供的一个轻量级同步机制。它可以保证两件事:
- 可见性 :对一个
volatile变量的写操作,会立刻刷新到主内存;对一个volatile变量的读操作,会从主内存中读取最新的值。 - 禁止指令重排序 :编译器和 CPU 不会对
volatile变量的读写操作进行重排序。
我们把之前的例子加上 volatile,问题就解决了:
java
public class VisibilitySolution {
// 加上 volatile,保证可见性
private static volatile boolean running = true;
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
System.out.println("工作线程启动...");
while (running) {
// 每次读取 running,都会从主内存获取最新值
}
System.out.println("工作线程退出!");
});
worker.start();
Thread.sleep(1000);
System.out.println("主线程准备停止工作线程...");
running = false;
System.out.println("主线程已将 running 设为 false");
worker.join();
System.out.println("主线程结束");
}
}
加上 volatile 后,工作线程的 while (running) 每次都会从主内存重新读取 running 的值,因此能立即看到主线程的修改,正常退出。
volatile 的典型使用场景
-
状态标志:用于控制线程的启动、停止(如上例)。
-
双重检查锁(DCL)中的单例模式 :
javapublic class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }这里的
volatile保证了instance在构造完成之前,其他线程不会看到它。 -
volatile不适合用于"计数"或"累加"等需要原子性的场景 :因为count++不是原子操作,volatile只保证可见性,不保证原子性。计数场景请使用AtomicInteger或synchronized。
5. 原子性 vs 可见性 vs 有序性
这三个概念是并发编程的核心:
| 概念 | 说明 | 解决方案 |
|---|---|---|
| 原子性 | 一个操作或多个操作要么全部执行且不被中断,要么全不执行 | synchronized、Lock、Atomic* 类 |
| 可见性 | 一个线程对共享变量的修改,另一个线程能立即看到 | volatile、synchronized、Lock(同步块退出时会刷新到主内存) |
| 有序性 | 代码的执行顺序与编写顺序一致(不被重排序) | volatile、synchronized(同步块内代码不会与外部重排序) |
volatile 只保证可见性 和有序性 ,不保证原子性。
6. Happens-Before 规则 ------ JMM 的"法律条文"
JMM 通过 Happens-Before 规则来定义何时一个操作对另一个操作可见。如果操作 A happens-before 操作 B,那么 A 的结果对 B 是可见的。
以下是 Happens-Before 的关键规则:
- 程序顺序规则:在一个线程内,按照代码顺序,前面的操作 happens-before 后面的操作。
volatile变量规则 :对一个volatile变量的写操作,happens-before 对同一个变量的读操作。- 锁规则 :对一个锁的
unlock()操作,happens-before 对同一个锁的lock()操作(即释放锁之前的所有操作,在获取锁之后可见)。 - 线程启动规则 :
Thread.start()happens-before 该线程内的所有操作。 - 线程终止规则 :线程内的所有操作 happens-before
Thread.join()返回。 - 传递性:如果 A happens-before B,且 B happens-before C,则 A happens-before C。
这些规则决定了哪些内存操作需要"刷新"和"可见"。
7. volatile vs synchronized ------ 什么时候用哪个?
| 维度 | volatile |
synchronized |
|---|---|---|
| 保证原子性 | ❌ 不保证 | ✅ 保证 |
| 保证可见性 | ✅ 保证 | ✅ 保证(释放锁时刷新到主存) |
| 保证有序性 | ✅ 禁止重排序 | ✅ 保证(同步块内代码不会与外部分析排序) |
| 性能开销 | 很小(轻量级) | 较大(涉及锁获取和释放) |
| 适用场景 | 简单的状态标志、一写多读的场景 | 复杂的读写操作、需要原子性的场景 |
选择原则:
- 如果只是需要一个简单的"开关"变量来控制线程的运行状态,用
volatile。 - 如果需要对多个变量进行复合操作(如
count++),或者需要保证一组操作的原子性,用synchronized或Lock。
8. 更深入的可见性:synchronized 的可见性保证
你可能已经注意到了:synchronized 也保证可见性。当一个线程退出 synchronized 代码块时,它会将工作内存中的所有修改刷新到主内存 ;当另一个线程进入 synchronized 代码块时,它会从主内存中刷新工作内存。
所以,即使不用 volatile,用 synchronized 包裹读写操作也能保证可见性:
java
public class SynchronizedVisibility {
private static boolean running = true;
public static synchronized boolean isRunning() {
return running;
}
public static synchronized void setRunning(boolean value) {
running = value;
}
public static void main(String[] args) throws InterruptedException {
// 工作线程通过同步方法读取
Thread worker = new Thread(() -> {
System.out.println("工作线程启动...");
while (isRunning()) { }
System.out.println("工作线程退出!");
});
worker.start();
Thread.sleep(1000);
setRunning(false);
worker.join();
}
}
但用 synchronized 来实现一个简单的状态标志,明显是"杀鸡用牛刀"了------性能开销大,代码也繁琐。这就是为什么 volatile 存在的意义。
9. 综合示例 ------ 一个"限时任务"的实现
我们来写一个实用的小例子:一个任务最多运行 5 秒,超时后自动取消。
java
public class TimedTask {
// 用 volatile 控制任务是否继续执行
private static volatile boolean cancelled = false;
public static void main(String[] args) throws InterruptedException {
// 任务线程
Thread task = new Thread(() -> {
System.out.println("任务开始执行...");
int count = 0;
// 每秒输出一次进度,直到被取消
while (!cancelled && count < 10) {
System.out.println("任务执行中..." + (count + 1) + "/10");
count++;
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
if (cancelled) {
System.out.println("任务被取消!已执行 " + count + " 步");
} else {
System.out.println("任务正常完成!");
}
});
task.start();
// 主线程等待 3 秒后取消任务
Thread.sleep(3000);
System.out.println("超时,取消任务...");
cancelled = true; // volatile 写,对任务线程立即可见
task.join();
System.out.println("主线程结束");
}
}
这个例子里,volatile boolean cancelled 是典型的"状态标志"用法。任务线程在每次循环中检查 cancelled,主线程修改它后,任务线程立刻就能看到。
10. 常见的误解与陷阱
误解①:volatile 可以保证原子性
❌ 错误。volatile 不保证原子性。volatile int count; count++ 仍然不是线程安全的。count++ 是"读-改-写"三步,这三步之间可能被其他线程打断。
误解②:没有 volatile 就一定看不见
❌ 不一定。在极端情况下,缓存同步可能刚好发生,程序偶尔会正确运行。但"偶尔正确"比"总是错误"更危险------它让 bug 难以复现和调试。必须用正确的方式保证可见性。
误解③:volatile 会导致性能大幅下降
大多数情况下,volatile 的性能开销很小,和普通变量访问的差别在可接受范围内。只有在极其高频率(每秒百万次以上)的访问中,才需要考虑优化。不要为了微小的性能顾虑而牺牲正确性。
陷阱:volatile 变量写操作不能依赖当前值
因为 volatile 不保证原子性,所以 volatile 变量的写操作不应该依赖于当前值(即不能写 x = x + 1 这种代码)。如果有这种需求,使用 AtomicInteger 或 synchronized。
11. 今天的总结
今天我们深入了 Java 内存模型(JMM):
- 可见性问题:一个线程对共享变量的修改,另一个线程可能因为 CPU 缓存而看不到。
- Java 内存模型:抽象了主内存和工作内存,定义了线程如何与内存交互。
volatile关键字:保证可见性和禁止指令重排序。适合做状态标志、双重检查锁等场景。- 原子性 vs 可见性 vs 有序性:三个不同的概念,各自有不同的解决方案。
- Happens-Before 规则:JMM 定义的一套规则,规定了哪些操作之间具有可见性保证。
volatilevssynchronized:volatile轻量,只保证可见性和有序性;synchronized重量,同时保证原子性、可见性、有序性。
Java 内存模型是并发编程的"底层逻辑"。理解了它,你就能看懂为什么有些代码会出问题,为什么 volatile 能解决,为什么 synchronized 也能解决但代价更高。
动手试试
-
把第一个"可见性问题"的例子中的
volatile去掉,在while (running)中加上System.out.println,观察是否工作线程会退出(提示:System.out.println内部有synchronized,会触发内存刷新)。思考为什么加上打印后就能看见了。 -
写一个简单的
volatile计数器,启动 10 个线程,每个线程对volatile int count执行 1000 次count++。观察结果是否为 10000,并解释为什么。 -
用
AtomicInteger替代上面的volatile int,观察结果是否正确。 -
写一个程序,演示指令重排序导致的问题(提示:用两个线程分别执行两段代码,一个线程观察另一个线程对两个变量的赋值顺序)。
-
阅读
java.util.concurrent.atomic包下的AtomicInteger源码,看看它是如何在不使用synchronized的情况下保证原子性的(提示:Unsafe.compareAndSwapInt,即 CAS 操作)。
通过这一篇,你已经触及了 Java 并发编程的底层原理。理解了 JMM 和 volatile 的机制,你就能写出更正确、更高效的并发代码。
我们下一篇会学习 并发容器与原子类,看看 JUC 包里那些线程安全的集合和原子类是如何在 JMM 的基础上构建的。
我们下一篇见。😃
📌 获取本系列示例代码请访问 GitCode。