同事问我:既然有 volatile,为什么还要加锁
上周 code review,同事看到我写的一个状态标记用了 volatile,问我:"这东西不是能保证线程安全吗,那 synchronized 还有什么用?"
我说不能这么理解,但当时没能讲清楚。下来自己看了几天 JMM 的资料,又写了几个例子验证,才算是理顺了。
先看一段跑不完的代码
这是最能说明问题的例子。一个线程改标记,另一个线程等标记变了就退出:
public class VolatileDemo {
private static boolean stop = false;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
int i = 0;
while (!stop) {
i++;
}
System.out.println("子线程退出,i = " + i);
}).start();
Thread.sleep(1000);
stop = true;
System.out.println("主线程已把 stop 置为 true");
}
}
按常识,1 秒后 stop 变 true,子线程应该退出。但实际运行结果是:主线程打印了"已把 stop 置为 true",然后程序永远不结束。
我第一次跑出来的时候以为是偶发,又跑了十几次,在 JDK 8 + macOS 上有大概 7 成概率复现。加上 volatile 之后,20 次全部正常退出。
为什么会看不见:JMM 的可见性
根子在 Java 内存模型(JMM)上。JMM 规定,每个线程有自己的工作内存,变量的主副本放在主内存里。线程读写变量时,是先把主内存的值拷到工作内存,改完再写回去。
主内存 stop = false
|
+------+------+
| |
工作内存(线程A) 工作内存(线程B)
stop=false stop=false
线程 B 把 stop 改成 true 之后,这个新值可能还躺在 B 的工作内存里,没来得及刷回主内存;也可能刷回了主内存,但线程 A 的工作内存里还是旧副本,一直没去重新读。
更要命的是,JIT 编译器看到 while (!stop) 这个循环里没有任何写 stop 的操作,会认为"stop 在这个线程里不会变",然后把它优化成:
if (!stop) {
while (true) { // 循环展开,不再每次读变量
i++;
}
}
这下彻底没救了,连工作内存都不查了,直接死循环。
volatile 的作用就是打断这两个优化:
- 写 volatile 变量时,JMM 会立即把该线程工作内存中的新值刷新到主内存;
- 读 volatile 变量时,JMM 会把该线程工作内存中的副本置为无效,强制从主内存重新读;
- volatile 修饰的变量不会被编译器做上述激进优化。
第二个作用:禁止指令重排
volatile 还有个作用我一开始完全没意识到——禁止指令重排序。
编译器和 CPU 为了跑得快,会在不改变单线程语义的前提下打乱指令顺序。下面这段双重检查锁(DCL)单例就是经典受害者:
public class Singleton {
private static Singleton instance; // 没有 volatile!
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
instance = new Singleton() 这一句在字节码层面大致分三步:
- 分配一块内存空间;
- 在这块内存上初始化对象;
- 把 instance 引用指向这块内存。
第 2 步和第 3 步之间没有数据依赖,所以可能被重排成 1 → 3 → 2。如果线程 A 执行完 3(instance 已经非 null 了)但还没执行 2(对象还是个空壳),此时线程 B 进来判断 instance != null,直接 return 了一个还没初始化完的对象。后续调用它的方法就是各种诡异的空指针。
加上 volatile 之后,JVM 会在写操作前后插入内存屏障,禁止把 3 排到 2 前面:
private static volatile Singleton instance;
这就是 volatile 在 DCL 里不可或缺的原因,不是为了可见性,是为了有序性。
happens-before:JMM 给开发者的承诺
上面说的"可见"和"有序",JMM 用一套更严格的方式定义,叫 happens-before 规则。如果操作 A happens-before 操作 B,那么 A 的执行结果对 B 可见。JMM 定义了 8 条天然规则,我常用的是这几条:
- 程序次序规则:同一个线程内,前面的操作 happens-before 后面的。(注意,这只保证单线程语义,不保证不被重排。)
- volatile 变量规则:对一个 volatile 变量的写,happens-before 后续对这个变量的读。
- 传递性:A happens-before B,B happens-before C,则 A happens-before C。
- 监视器锁规则:unlock happens-before 后续的 lock。
volatile 规则 + 传递性组合起来有个很有用的推论:线程 A 先写了一个普通变量 x = 1,再写一个 volatile 变量 flag = true;线程 B 读到 flag == true,那么 B 一定能看到 x == 1。这个技巧在JDK 的 ConcurrentHashMap 等源码里很常见。
volatile 保证不了原子性
现在回到同事那个问题。volatile 能保证可见性和有序性,但它不保证原子性。
写段代码验证:
public class VolatileAtomicDemo {
private static volatile int count = 0;
private static final int THREADS = 10;
private static final int LOOPS = 10000;
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(THREADS);
for (int i = 0; i < THREADS; i++) {
new Thread(() -> {
for (int j = 0; j < LOOPS; j++) {
count++;
}
latch.countDown();
}).start();
}
latch.await();
System.out.println("期望: " + (THREADS * LOOPS) + ",实际: " + count);
}
}
跑 5 次的结果:
期望: 100000,实际: 43217
期望: 100000,实际: 38902
期望: 100000,实际: 51063
期望: 100000,实际: 44780
期望: 100000,实际: 40155
一次都没对过。因为 count++ 不是一步操作,它拆成"读 count → 加 1 → 写回"三步。volatile 只能保证读的时候拿到最新值、写完立刻刷回主内存,但管不了中间:线程 A 读到 count=10,还没来得及加 1,线程 B 也读到 10,俩人都算出 11 写回去,两次自增只增加了 1。
想要原子性有三条路:
- synchronized 或 Lock:最通用,有性能开销。
- AtomicInteger:CAS 自旋,无锁,这种简单计数场景首选:
private static AtomicInteger count = new AtomicInteger(0);
// ...
count.incrementAndGet();
实测同样的 10 万次自增,AtomicInteger 耗时约 8ms,synchronized 约 23ms。
- LongAdder:JDK 8 新增,高并发下比 AtomicInteger 更快,它内部维护多个 Cell 分散竞争。1000 个线程竞争的场景下我测过,LongAdder 比 AtomicInteger 快 4 倍多。但它求和时不保证强一致性,适合做监控计数这种允许略有偏差的场景。
所以到底什么时候用 volatile
我现在判断的标准是看这个操作本身是不是原子的:
| 场景 | 能否用 volatile |
|---|---|
| 一个线程写、多个线程读的状态标记(如 shutdown 标志) | 适合 |
| 变量的新值不依赖旧值(如 flag = true) | 适合 |
| DCL 单例的实例引用 | 必须加 |
| count++ 这种读改写复合操作 | 不行,用 AtomicInteger |
| 需要多个变量一起保持一致的(如 i 和 j 的约束) | 不行,用锁 |
简单说,volatile 是轻量级的可见性保障,不是锁的替代品。它比 synchronized 便宜(没有上下文切换和阻塞),但能力也弱得多,只能在"写操作不依赖当前值"这个前提下安全使用。
那天 review 之后我把这段话发给了同事,他回了句"懂了,就是能读不能算"。虽然粗糙,倒是挺准确。