`volatile` 到底保证了什么:可见性、有序性,和它管不到的原子性

发布时间:2026/7/27 17:03:55
`volatile` 到底保证了什么:可见性、有序性,和它管不到的原子性 前言有个 bug 特别典型一个线程把开关flag改成了true另一个线程里while (!flag)的循环却像没听见一样一直转个不停就是不退出。把flag加上volatile问题立刻消失。于是很多人得出一个模糊的结论“volatile就是个轻量级的synchronized加上就线程安全了。”这个理解害人不浅。因为紧接着他们会写出volatile int count; count;这种代码然后在多线程下发现count的最终结果总是对不上——明明加了volatile怎么还是不安全真相是volatile只保证两件事——可见性和有序性它从不保证原子性。上面第一个例子它能治第二个例子它治不了。这篇文章讲清楚volatile到底保证了什么、不保证什么底层靠什么实现以及什么场景该用、什么场景千万别用。环境说明本文基于 JDK 8HotSpot 虚拟机x86 架构。一、先复现复现 1可见性问题——不加volatile循环停不下来publicclassVisibilityDemo{// 注意这里没有 volatileprivatestaticbooleanflagfalse;publicstaticvoidmain(String[]args)throwsInterruptedException{ThreadworkernewThread(()-{System.out.println(worker: 开始循环);while(!flag){// 空转等待 flag 变成 true}System.out.println(worker: 检测到 flagtrue退出);});worker.start();Thread.sleep(1000);// 让 worker 先转起来flagtrue;// 主线程改标志位System.out.println(main: 已把 flag 设为 true);}}按直觉主线程 1 秒后把flag改成trueworker应该马上退出。但实际运行-server模式或生产环境下worker: 开始循环 main: 已把 flag 设为 true worker 永远不打印退出程序挂住主线程明明改了flagworker却始终看到旧值false。只要给flag加上volatileprivatestaticvolatilebooleanflagfalse;worker立刻就能退出。这说明**“一个线程的修改另一个线程不一定看得见”**这个问题真实存在而volatile能解决它。复现 2原子性缺失——加了volatilecount照样出错那volatile是不是万能的再看一个publicclassAtomicityDemo{privatestaticvolatileintcount0;// 这次加了 volatilepublicstaticvoidmain(String[]args)throwsInterruptedException{Runnabletask()-{for(inti0;i10000;i){count;// 自增 1 万次}};Threadt1newThread(task);Threadt2newThread(task);t1.start();t2.start();t1.join();t2.join();System.out.println(期望20000实际count);}}两个线程各自增 1 万次期望结果是20000。但实际跑出来期望20000实际13472每次运行结果还不一样但几乎永远小于 20000。加了volatile也没用。两个现象放一起问题就来了同样是volatile为什么它能解决第一个死循环却解决不了第二个计数丢失二、根因/底层JMM 与三大特性要讲清楚这两个现象得先认识 Java 内存模型JMM。2.1 为什么会有可见性问题现代 CPU 有多级缓存。为了性能线程操作变量时并不是直接读写主内存RAM而是先把变量拷贝一份到自己的工作内存对应 CPU 寄存器、L1/L2 缓存里之后的读写都在工作内存进行什么时候刷回主内存不确定。JMM 把这个结构抽象成主内存所有线程共享 每个线程私有的工作内存。于是复现 1 的死循环就说得通了主线程把flag true写进了自己的工作内存还没来得及或没必要刷回主内存更关键的是worker线程在while (!flag)里反复读的是它自己工作内存里的副本那份副本一直是falseJIT 编译器甚至会把while (!flag)优化成if (!flag) { while (true) {} }——因为它认为循环体里没人改flag。结果就是主线程的修改worker永远看不见。这就是可见性问题。2.2volatile的可见性volatile对可见性的保证靠两条规则写一个volatile变量时JMM 会立即把工作内存的值刷回主内存读一个volatile变量时JMM 会强制从主内存重新读取让工作内存里的旧副本作废。所以flag加上volatile后主线程一改值马上进主内存worker每次循环都从主内存读自然就读到了true。可见性问题解决。2.3 有序性与指令重排volatile保证的第二件事是有序性。为了优化性能编译器和 CPU 会在不影响单线程结果的前提下对指令重新排序instruction reordering。单线程里这没问题但多线程下重排可能让另一个线程观察到诡异的执行顺序。volatile通过在变量读写前后插入内存屏障memory barrier来禁止这种重排屏障就像一道栏杆指令不允许越过它调换顺序。这保证了对 volatile 变量的写一定发生在后续对它的读之前也就是 JMM 里的happens-before规则。这一点在单例模式的双重检查锁定DCL里至关重要——不加volatile会读到半初始化的对象。这个话题留到下一篇专门讲。2.4 为什么原子性管不到现在回到复现 2为什么volatile int count的count还是错的关键在于count不是一个原子操作它实际是三步read从主内存读取count的当前值modify把值加 1write把结果写回主内存。volatile只保证每一步的读、写是可见的但它拦不住这三步在多线程下被切开、交错执行。看这个时序线程 A 读到count 100read时间片切换线程 B 也读到count 100read线程 A 算出 101写回write线程 B 也算出 101写回write。两个线程各自增了一次本该变成 102结果只有 101——一次自增被丢了。这就是为什么最终结果总是小于 20000。volatile的可见性在这里甚至帮了倒忙的忙它保证 B 每次读的都是最新值但当 A、B 几乎同时读时那个最新值对两人来说都是同一个旧值该丢还是丢。可见性 ≠ 原子性。三、正解volatile该用在哪、不该用在哪理解了三大特性用法就清晰了volatile适合一个线程写、多个线程读的状态标志不适合任何读-改-写的复合操作。适用场景状态标志位privatevolatilebooleanshutdownfalse;publicvoidshutdown(){shutdowntrue;}// 一处写publicvoidrun(){while(!shutdown){// 多处读doWork();}}写操作只是简单赋值不依赖旧值这正是volatile的主场轻量、无锁、保证可见。不适用场景读-改-写count、i i * 2、if (x null) x new X()这类依赖旧值的操作volatile都保证不了。该用// 方案 1加锁保证复合操作原子privateintcount0;publicsynchronizedvoidinc(){count;}// 方案 2CAS 原子类推荐无锁性能更好privateAtomicIntegercountnewAtomicInteger(0);publicvoidinc(){count.incrementAndGet();}// 方案 3高并发计数LongAdder 比 AtomicLong 更快privateLongAddercountnewLongAdder();publicvoidinc(){count.increment();}一个简单的决策原则只需要改了能被看见→volatile需要读-改-写整体不可分割→synchronized/AtomicXxxCAS/LongAdder。四、常见误区与面试高频问答Qvolatile是不是轻量级的synchronized不是。两者根本不是一个维度volatile只保证可见性 有序性不保证原子性也不提供互斥synchronized既保证可见性、有序性又保证原子性和互斥同一时刻只有一个线程进临界区。volatile是无锁的、更轻但能力也更弱。Qvolatile int i; i线程安全吗不安全。i是 read-modify-write 三步复合操作volatile只保证每步的读写可见拦不住三步被交错执行所以照样丢更新。要安全得用AtomicInteger或加锁。Qvolatile能保证a 1; b 2;这两行的原子性吗不能。volatile只作用于单个变量的单次读或写无法保证多个变量、多条语句的组合原子性。跨变量的原子操作只能靠锁。Qhappens-before 和内存屏障是什么关系happens-before 是 JMM 层面的规则/承诺volatile 写 happens-before 后续的 volatile 读内存屏障是 CPU/编译器层面实现这个承诺的手段。屏障禁止重排、强制刷缓存从而让 happens-before 成立。前者是是什么后者是怎么做到。Q64 位的long/double不加volatile会有什么问题JMM 规范允许 JVM 把 64 位变量的读写拆成两次 32 位操作。极端情况下一个线程可能读到写了一半的值高 32 位是新的、低 32 位是旧的即写撕裂。加volatile后JMM 保证long/double的读写是原子的。虽然主流 64 位 JVM 实际上已经原子处理但规范上不加volatile是有隐患的。总结volatile保证线程安全是个危险的误解。它的能力边界很清楚可见性✅写立即刷主内存、读强制读主内存一个线程的修改对其他线程立即可见——解决了复现 1 的死循环。有序性✅通过内存屏障禁止指令重排保证 happens-before——这是 DCL 单例必须加volatile的原因。原子性❌只保证单次读/写可见保证不了count这种读-改-写复合操作——这是复现 2 计数丢失的根因。一句话记忆volatile 可见性 有序性不含原子性。标志位一写多读放心用count读-改-写千万别用那是AtomicInteger和锁的活。