"给字段加个 volatile 就线程安全了"是个很常见的误解。真实起 8 个线程一起做 i++,分别测 plain int、volatile int、AtomicInteger 三种写法,再单独验证 volatile 真正保证的那件事——两组真实数据放在一起,结论会很直接。
上一篇: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();
plain int (无任何同步) 最终结果: 120017 volatile int 最终结果: 111838 AtomicInteger 最终结果: 800000 期望值(每种都应该等于这个): 800000plain 和 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++ 变安全。
i++ 表面上是一步,字节码层面是三步:读取当前值、加一、写回。volatile 保证的是"每次读到的都是最新写入的值",但它管不住"读取-计算-写回"这三步之间会不会被别的线程插一脚。两个线程可能同时读到同一个旧值,各自加一,再各自写回同一个新值——两次自增,实际只生效了一次,这就是"丢失更新"(lost update)。volatile 字段确实每次都读到了最新值,只是"最新值"读完之后,在写回之前,已经被另一个线程抢先改过了。
AtomicInteger.incrementAndGet() 靠的不是 volatile,是 CAS(compare-and-swap):读取旧值、计算新值、再用一条硬件级原子指令"如果内存里的值还是刚才读到的那个旧值,就换成新值,否则重试"——把"读-算-写"这三步在硬件层面收成一个不可分割的操作,这才是 i++ 真正需要的保证,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 关掉了这种优化,并且规定每次写入都要立刻对其它线程可见——这正好是"停止标志位"这类场景需要的全部保证,也正好是"计数器自增"这类场景完全用不上的保证。
| 场景 | 需要的保证 | plain | volatile | AtomicInteger |
|---|---|---|---|---|
| 停止标志位(只读,只由一方写) | 可见性 | 真实卡住 3 秒 | 真实 0ms 退出 | 没必要用 |
| 多线程累加计数器(读-改-写) | 原子性 | 真实丢 85%+ | 真实同样丢 85%+ | 真实精确 |
判断一个共享变量该用哪种同步手段,先问一句:多个线程会不会同时对它做"读出来、算一下、写回去"这种复合操作?只是单纯地"一边写、多边读"(像停止标志、配置开关这类),volatile 足够;涉及"先读旧值再算新值"的复合操作(自增、if 判断后再赋值这类),volatile 不够,需要 Atomic* 类、synchronized 或显式锁。
每一行都是真实反射或真实运行这段代码得到的结果。