在Java多线程开发里,可见性是指一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型把内存分为主内存和线程工作内存,线程执行时先把变量从主内存拷贝到工作内存,操作完再写回。普通变量在这种机制下,某个线程的修改可能滞留在自己的工作内存中,导致别的线程读到旧数据。volatile是JVM提供的最轻量级同步手段之一,用来专门解决这类可见性问题。
一、volatile实现可见性的底层原理
当一个变量被声明为volatile后,编译器和处理器会遵循一些特殊规则。写volatile变量时,JVM会插入写内存屏障,强制把当前线程工作内存中的值刷写到主内存;读volatile变量时,会插入读内存屏障,使当前线程工作内存中该变量的副本失效,必须到主内存重新加载。这种机制保证了不同线程对同一变量的读写操作具备跨线程的可见性。
除了可见性,volatile还能禁止指令重排序,这依托于 happens-before 原则中的 volatile 规则:对一个volatile变量的写操作 happens-before 后续对这个变量的读操作。比如双重检查单例里,把实例引用用volatile修饰,可以防止对象构造未完成就被其他线程看到。下面是一段说明性代码:
public class VisibilityDemo {
// 普通变量,其他线程可能看不到修改
private boolean runningNormal = true;
// volatile变量,修改对所有线程立即可见
private volatile boolean runningVolatile = true;
public void stopNormal() {
runningNormal = false; // 不一定能被其他线程及时看到
}
public void stopVolatile() {
runningVolatile = false; // 立刻刷回主内存,其他线程下次读会拿到false
}
public void worker() {
// 若用runningNormal,可能永远退不出循环
while (runningVolatile) {
// 执行任务
}
}
}
二、volatile的典型应用场景
最合适使用volatile的场景是状态标志位。例如后台任务通过布尔变量控制是否继续运行,主线程修改该标志,工作线程循环检查。如果不用volatile,工作线程可能一直从寄存器或缓存读旧值,造成任务无法停止。把标志声明为volatile,修改后工作线程马上就能感知。
另一个常见场景是一次性安全发布。如果某个字段在初始化后不再改变,且希望被其他线程看到完整构造状态,可以用volatile修饰引用。相对地,volatile不适合做依赖当前值的复合操作,比如计数器自增。下面示例展示错误用法和正确替代:
import java.util.concurrent.atomic.AtomicInteger;
public class Counter {
// 错误:volatile不保证i++原子性
private volatile int count = 0;
public void badIncrement() {
count++; // 读-改-写三步,多线程下会丢更新
}
// 正确:用原子类保证原子性与可见性
private AtomicInteger safeCount = new AtomicInteger(0);
public void goodIncrement() {
safeCount.incrementAndGet();
}
}
三、volatile与锁、原子类的取舍
使用synchronized或Lock也能实现可见性,因为解锁前线程会把工作内存刷回主内存,加锁后会使副本失效。但锁开销大,且会阻塞线程。volatile没有阻塞,性能更好,但只能保证单个变量读写的可见性与有序性,无法把多个操作打包成原子事务。如果业务逻辑只是简单共享状态通知,volatile足够;若涉及资金扣减、库存减少等复合更新,必须选锁或原子变量。
实际项目中可以组合使用:用volatile做开关,用原子类做统计,用锁保护复杂临界区。理解它们的边界,才能写出既正确又高效的并发程序。下面的表格简要对比了三者的差异:
| 机制 | 可见性 | 原子性 | 是否阻塞 |
|---|---|---|---|
| volatile | 保证 | 不保证复合操作 | 否 |
| synchronized | 保证 | 保证代码块原子性 | 是 |
| Atomic类 | 保证 | 保证单个变量原子更新 | 否(基于CAS) |
四、编写volatile代码的注意事项
第一,不要对volatile变量做依赖旧值的运算。如前面所说,i++在字节码层面是多条指令,volatile管不了中间状态。第二,volatile不能替代线程安全容器,如果共享的是对象,对象内部字段仍需自己处理并发。第三,在32位JVM上,long和double的非volatile读写可能分两次,而volatile能强制单次原子读写,这也是它的附加好处。
最后建议,在代码评审时把所有的共享变量声明都过一遍:如果它会被多个线程读写且不需要复合原子性,优先标成volatile并加注释说明意图;如果职责不清,就缩小共享范围或用不可变对象替代。这样才能把可见性bug消灭在编码阶段。