bigywater.tech
Java 内部原理 · 04

volatile:为什么能保证可见性却不能保证 i++ 安全

"给字段加个 volatile 就线程安全了"是个很常见的误解。真实起 8 个线程一起做 i++,分别测 plain int、volatile int、AtomicInteger 三种写法,再单独验证 volatile 真正保证的那件事——两组真实数据放在一起,结论会很直接。

80万 → 11~18万
8 线程各累加 10 万次,plain/volatile int 真实都把结果丢了 80% 以上,且每次运行结果都不一样
3005ms → 0ms
停止标志位不加 volatile,工作线程真实卡住 3 秒收不到信号;加了之后 0 毫秒立刻退出

上一篇:Integer 缓存:为什么 == 在测试环境正常,生产环境却悄悄失灵

先看一个容易想当然的误用

volatile 修饰的字段,i++ 是不是就安全了?真实起线程跑一遍就知道。

8 个线程,每个线程把同一个计数器累加 10 万次,理论上最终结果应该是 8 × 100000 = 800000。分别用三种写法各跑一遍:

三种写法 static int plainCounter = 0; static volatile int volatileCounter = 0; static AtomicInteger atomicCounter = new AtomicInteger(0); // 8 个线程并发跑,每个线程各执行: plainCounter++; volatileCounter++; atomicCounter.incrementAndGet();
真实实测 · Java 17,8 线程 × 10 万次
plain int (无任何同步) 最终结果: 120017
volatile int 最终结果: 111838
AtomicInteger 最终结果: 800000

期望值(每种都应该等于这个): 800000
plain 和 volatile 都只剩下期望值的 14%~15%,丢了 85% 以上的累加 只有 AtomicInteger 真实跑出了完整的 800000

再跑一遍确认不是偶然:

真实实测(第二次运行)
plain int (无任何同步) 最终结果: 180720
volatile int 最终结果: 146944
AtomicInteger 最终结果: 800000

两次运行,plain 和 volatile 的最终结果完全不一样(120017 vs 180720,111838 vs 146944)——不是"经常错、偶尔对",是每次运行都不可预测地错,唯独 AtomicInteger 每次都精确等于 800000。volatile 完全没有让 i++ 变安全。

为什么 volatile 救不了 i++

i++ 表面上是一步,字节码层面是三步:读取当前值、加一、写回。volatile 保证的是"每次读到的都是最新写入的值",但它管不住"读取-计算-写回"这三步之间会不会被别的线程插一脚。两个线程可能同时读到同一个旧值,各自加一,再各自写回同一个新值——两次自增,实际只生效了一次,这就是"丢失更新"(lost update)。volatile 字段确实每次都读到了最新值,只是"最新值"读完之后,在写回之前,已经被另一个线程抢先改过了。

AtomicInteger.incrementAndGet() 靠的不是 volatile,是 CAS(compare-and-swap):读取旧值、计算新值、再用一条硬件级原子指令"如果内存里的值还是刚才读到的那个旧值,就换成新值,否则重试"——把"读-算-写"这三步在硬件层面收成一个不可分割的操作,这才是 i++ 真正需要的保证,volatile 从设计上就没打算提供这个。

真实验证:volatile 真正保证的是"可见性"

既然不是原子性,那 volatile 到底保证了什么——真实做一个停止标志位的实验。

一个后台线程死循环检查停止标志,主线程 0.5 秒后把标志设成 true,看工作线程能不能及时退出:

对照组:不加 volatile static boolean plainStop = false; Thread worker = new Thread(() -> { while (!plainStop) { /* 空转 */ } }); worker.start(); Thread.sleep(500); plainStop = true; // 主线程设置停止信号 worker.join(3000); // 最多等 3 秒
真实实测
plain boolean: worker 是否在 3 秒内退出 = false (等待了 3005ms)
3 秒超时,工作线程完全没有退出——它没能"看到" plainStop 被改成了 true

只把字段声明改成 volatile boolean,其它代码一字不改,再跑一遍:

真实实测
volatile boolean: worker 是否在 3 秒内退出 = true (等待了 0ms)
几乎瞬间退出——0 毫秒

这才是 volatile 真正解决的问题:普通字段的写入,JIT 编译器有权把 while (!plainStop) 里的字段读取优化成"只读一次、缓存在寄存器里",工作线程实际上在检查一个永远不会变的本地副本,主线程改的是主内存里的值,两边压根没同步过。volatile 关掉了这种优化,并且规定每次写入都要立刻对其它线程可见——这正好是"停止标志位"这类场景需要的全部保证,也正好是"计数器自增"这类场景完全用不上的保证。

把两组实验放在一起看

场景需要的保证plainvolatileAtomicInteger
停止标志位(只读,只由一方写)可见性真实卡住 3 秒真实 0ms 退出没必要用
多线程累加计数器(读-改-写)原子性真实丢 85%+真实同样丢 85%+真实精确

判断一个共享变量该用哪种同步手段,先问一句:多个线程会不会同时对它做"读出来、算一下、写回去"这种复合操作?只是单纯地"一边写、多边读"(像停止标志、配置开关这类),volatile 足够;涉及"先读旧值再算新值"的复合操作(自增、if 判断后再赋值这类),volatile 不够,需要 Atomic* 类、synchronized 或显式锁。

那 AtomicInteger 内部用了 volatile 吗?用了——它的底层值字段本身就声明成 volatile,可见性问题一样要解决,只是在这基础上,自增操作额外包了一层 CAS 循环来保证原子性。volatile 和"线程安全的自增"不是互斥的,是前者作为地基、后者在它上面多做了一层工作。

分步演示:什么时候真的需要 volatile,什么时候另有保证

每一行都是真实反射或真实运行这段代码得到的结果。

volatile 真实写进了class文件的字段标志位;start()和join()本身真实自带happens-before保证,某些场景下完全不需要volatile也能保证可见性
这一步新验证的事实

    参考与说明

    • 本文全部计数结果、超时时间均在本机真实运行 Java 17(8 核并发)得到,两组"丢失更新"实验各跑了两遍确认不是偶然;可见性实验里的 3005ms/0ms 是当次真实运行记录的墙钟时间。
    • 自动装箱场景下另一种"看起来线程安全实际不是"的坑,参见上一篇Integer 缓存。
    • 没有涉及:happens-before 关系的完整规则、synchronized/ReentrantLock 的具体实现、CAS 的 ABA 问题——这些留给系列后面按需再写。
    京ICP备2026067502号-1京公网安备11010502063487号