volatile 与可见性
本教程共 100 篇 · 第 98 篇 · 更新于 2026-08-05 · 约 5 分钟阅读
98. volatile 与可见性
本节目标:理解可见性问题的成因,掌握 volatile 的作用与边界,知道什么时候该换成原子类。
一个停不下来的循环
先看一段诡异的代码:
public class VisibilityProblem {
private static boolean running = true; // 注意:没加 volatile
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
long count = 0;
while (running) { // 等待标志位变化
count++;
}
System.out.println("循环结束,共执行 " + count + " 次");
});
worker.start();
Thread.sleep(1000);
running = false; // 主线程改标志位
System.out.println("已设置 running = false");
worker.join();
System.out.println("程序结束");
}
}
```bash
按常理,主线程把 `running` 改成 `false`,子线程的循环就该退出。
但实际运行(用 `java -server` 或者稍等一会儿)大概率是这样:
```text
已设置 running = false
(然后程序一直卡着,永远打印不出"程序结束")
主线程明明改了,子线程为什么看不见?
Java 内存模型
答案藏在 JMM(Java 内存模型)里。
JMM 规定:所有共享变量存放在主内存中,但每个线程都有自己的工作内存。
线程操作变量时,先把主内存的值拷贝一份到自己的工作内存,之后所有读写都在这个副本上进行,最后某个时刻再刷回主内存。
问题就出在这里:主线程改的是主内存(或它自己的副本),子线程读的是自己的副本。副本没更新,子线程就一直看到旧值 true。
这个「一个线程的修改,另一个线程看不到」的现象,叫可见性问题。
Note工作内存不是凭空发明的概念,它对应真实硬件里的 CPU 寄存器和各级缓存。CPU 访问主存很慢,所以把数据缓存在离自己近的地方,多核之间的缓存不会自动实时同步。JMM 只是把这套硬件行为抽象成了统一的语言规范。
除了缓存,JIT 编译器的优化也会加剧问题。它看到循环里没人改 running,可能直接把代码优化成 while (true),连读都不读了。
volatile 保证可见性
给变量加上 volatile 就能解决:
private static volatile boolean running = true;
```text
只改这一个词,重新运行:
```text
已设置 running = false
循环结束,共执行 1873492013 次
程序结束
立刻正常了。
volatile 对 JVM 提出了两条硬性要求:
- 写操作:修改后立即刷回主内存
- 读操作:每次都从主内存重新读,不用工作内存的副本
同时它会阻止 JIT 做那种「把变量提到循环外」的优化。
这样一来,一个线程的修改,别的线程马上就能看到。
volatile 禁止指令重排
volatile 还有第二个作用,容易被忽略。
为了提升执行效率,编译器和 CPU 会在不改变单线程执行结果的前提下,调整指令的执行顺序。这叫指令重排序。
单线程下重排完全安全,多线程下就可能出乱子。经典例子是双重检查锁定的单例:
public class Singleton {
private static volatile Singleton instance; // volatile 不能省
private Singleton() { }
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 这一行有三步
}
}
}
return instance;
}
}
```bash
`instance = new Singleton()` 实际分三步:
1. 分配内存空间
2. 执行构造方法初始化对象
3. 把 `instance` 指向这块内存
第 2 步和第 3 步可能被重排成 3、2。一旦这样,另一个线程在外层看到 `instance != null` 就直接返回了,但对象其实还没初始化完,拿到的是个半成品,用起来必然出错。
加上 `volatile` 后,JVM 会插入内存屏障禁止这段重排,问题消失。
## volatile 不保证原子性
这是最容易踩的坑:**`volatile` 只管可见性和有序性,不管原子性**。
```java
public class VolatileNotAtomic {
private static volatile int count = 0; // 加了 volatile 也没用
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 100_000; i++) {
count++;
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("结果:" + count); // 仍然小于 200000
}
}
输出仍然不足 20 万。
原因上一节讲过:count++ 是「读-改-写」三步。volatile 只能保证每一步读到的是最新值,管不了三步之间会不会被打断。
线程 A 读到最新值 100,还没写回,线程 B 也读到 100。两边都算出 101,写回两次,还是丢了一次。
Warning一句话判断:
volatile适用于「一个线程写、多个线程读」的场景。只要写操作依赖变量的旧值(比如自增、累加、判断后修改),volatile就不够用。
正确的计数方案
需要原子的自增,有两个选择。
方案一:synchronized。可靠,但每次都要抢锁,竞争激烈时开销大。
方案二:原子类。java.util.concurrent.atomic 包提供了一批,底层用 CPU 的 CAS 指令实现,性能更好:
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicDemo {
private static final AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 100_000; i++) {
count.incrementAndGet(); // 原子自增
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("结果:" + count.get());
}
}
```text
输出稳定:
```text
结果:200000
常用的原子类和方法:
| 类 | 用途 |
|---|---|
AtomicInteger | 原子的 int |
AtomicLong | 原子的 long |
AtomicBoolean | 原子的 boolean |
AtomicReference<T> | 原子的对象引用 |
AtomicInteger 的常用方法有 incrementAndGet()(先加后取)、getAndIncrement()(先取后加)、addAndGet(n)、compareAndSet(期望值, 新值)。
CAS 的思路是「比较并交换」:先看当前值是不是我以为的那个,是就改,不是就说明被别人抢先了,重新读一遍再试。整个过程由 CPU 指令保证原子性,不需要加锁。
Tip高并发计数(比如统计接口调用量)用
LongAdder比AtomicLong更快。它把计数拆到多个单元上分散竞争,求和时再合并。代价是sum()拿到的可能不是某个精确瞬间的值,做统计完全够用。
三者怎么选
| 需求 | 方案 |
|---|---|
| 状态标志位,一写多读 | volatile |
| 计数器、累加器 | 原子类 |
| 一段复合逻辑要整体互斥 | synchronized 或 Lock |
volatile 是最轻量的,没有加锁开销,但能力也最弱。原子类适合单个变量的原子操作。涉及多个变量的一致性,就只能上锁。
顺便说一句,synchronized 其实同时保证了可见性——线程释放锁时会把工作内存刷回主内存,获取锁时会清空工作内存重新读。所以用了 synchronized 保护的变量,不需要再加 volatile。
本节小结
- JMM 规定共享变量在主内存,每个线程有自己的工作内存副本。
- 副本不同步导致可见性问题,典型症状是标志位改了循环却不退出。
volatile保证写立即刷回主内存、读每次从主内存获取。volatile还能禁止指令重排,双重检查单例必须加它。volatile不保证原子性,count++加了也照样出错。- 计数场景用
AtomicInteger,底层 CAS 无锁,性能优于加锁。 - 高并发计数可以考虑
LongAdder。 synchronized自带可见性保证,无需重复加volatile。