深入剖析 Java volatile:为什么多线程 a++ 结果永远不到 10000?
引言
在 Java 并发编程中,volatile 关键字常常被初学者误解。很多人知道它能"保证可见性",但一遇到 a++ 这种复合操作,它往往就会"失灵"。
最近我在复习并发知识时,写了一段非常经典的测试代码:
java
class Num {
volatile int a = 0;
public void add() {
a++;
}
}
public class Study {
public static void main(String[] args) {
Num n = new Num();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
n.add();
}
}).start();
}
// 假设主线程等待所有子线程执行完毕
System.out.println(n.a); // 结果必然小于 10000,且大概率远小于
}
}
理论上,10 个线程各执行 1000 次 a++,结果应该是 10000。但因为 volatile 不具备原子性,最终结果必然小于 10000。
为了彻底搞懂这背后的机制,我深入研究了 CPU 缓存一致性协议(MESI)、JMM(Java 内存模型)以及指令重排等底层原理,并整理出了以下几个核心问答,希望能帮到同样有疑惑的你。
核心问题一:volatile 的通知机制为什么不能"打断"线程?
现象 :线程 1 修改了 volatile 变量 a 并写回主内存,发出了通知。但线程 2 依然会拿着自己工作内存中的旧值计算完,导致覆盖线程 1 的结果。既然不能立刻打断线程 2,这个通知有什么意义?
解答 :
这涉及现代 CPU 的底层设计。CPU 为了追求极致性能,采用流水线和乱序执行。如果因为其他核心修改了内存就立刻通过硬件中断去"打断"当前核心正在进行的计算,CPU 开销将极其巨大,性能会断崖式下跌。因此硬件层面(如 MESI 协议)的设计原则是:我可以立刻让你手里的数据副本失效,但我绝不干涉你当前正在执行的这条指令。
这个通知的真正意义在于"阻止下一次读取" :
假设线程 2 算完当前值准备进入下一次循环,准备执行下一次 a++。由于它所在 CPU 核心的缓存行已经被标记为失效(Invalid),它被迫必须去主内存重新拉取最新值。
- 如果没有通知:线程 2 可能永远活在自己的"信息孤岛"里,一直对着缓存里的旧值疯狂加加加,导致其他线程的修改对它永远不可见。
- 有了通知:虽然这次更新丢失了,但至少在下一次计算时,线程 2 能看到别人做出的最新修改。这保证了程序不会彻底陷入死循环。
核心问题二:写回主内存时会被拦截吗?多线程覆盖的最终结果是什么?
现象:线程 2 拿着旧值算出的结果去写回主内存,发现缓存失效了,它会放弃写入吗?如果此时线程 3 也插进来写回,最终结果会怎样?
解答 :
结论:会直接覆盖,不会被拦截。
在底层,当线程 2 准备写回时,发现缓存行失效,它会向总线发送信号,把主内存中最新的值拉到自己的缓存里,然后立刻将自己算好的(基于旧值的)结果覆盖上去 。
volatile 写操作会插入写屏障(Memory Barrier),强制将当前缓存行刷入主内存。但它不检查主内存的值是不是比我算出来的新,它只管把当前的值刷出去。
经典的三线程覆盖场景:
- 主内存初始
a = 0。T1, T2, T3 同时读取a=0到各自缓存。 - T1 算完,写回
1。主内存变为1,通知 T2、T3 缓存失效。 - T2 算完(基于 0 算出的 1),准备写回。发现缓存失效,去主内存拉取最新值(1),但它依然把手里算好的 1 覆盖上去 。主内存依然是
1。 - T3 同理,最后覆盖上去的还是
1。 - 最终结果 :
a = 1。三人努力全部白费,这就是典型的"丢失更新"。
核心问题三:如果去掉 volatile,结果会一样吗?
现象 :如果把 volatile 去掉,变成普通变量,最终结果和原来会有区别吗?
解答 :
结论:最终结果大概率一样(都远小于 10000),但出错的机制和表现有巨大差异。
- 有
volatile时:因为强制高频去主内存同步,线程拿到相对较新的值。虽然依然会互相覆盖,但结果通常是在几千左右(比如 3000-6000),每次运行波动范围相对可控。 - 无
volatile时(普通变量) :由于没有缓存一致性协议通知机制,线程可能会完全依赖自己 CPU 寄存器或本地缓存的值疯狂计算。比如在 100 次循环期间完全不看主内存,最后才把结果写回。这种"批量丢失"和"长时间不可见"会导致最终结果波动极大。
最极端的理论情况(极限值等于 1000) :
如果你去掉 volatile,JIT 编译器(即时编译器)发现循环里只是修改局部变量,它可能会做"寄存器分配优化",把 1000 次加法全在 CPU 寄存器里做完,最后只写回主内存一次。如果 10 个线程都这么干,最后写回的数值可能就是 1000。
甚至,JVM 的**"循环不变量外提"**优化,可能会把变量直接提到循环外,导致某些线程彻底读不到最新值,直接变成死循环。
核心问题四:只读一次 volatile 变量,后续逻辑全用旧值?
现象 :如果业务代码中,对一个 volatile 变量只读取了一次,后续所有复杂的业务逻辑都基于这次读到的值,是不是意味着通知机制彻底失效了?
解答 :
完全正确,你的理解非常精准。
volatile 的"通知"绝不会立刻打断当前线程的执行逻辑。它的作用仅仅是:在下次读取该变量时,强制你去主内存拿最新值。
来看这段代码:
java
volatile int flag = 0;
void doSomething() {
int localFlag = flag; // 只读了一次,读到 0
if (localFlag == 0) {
// 假设这里执行耗时 10 秒的业务逻辑
// 期间其他线程把 flag 修改为了 1
// 但当前线程根本不管,手里的 localFlag 依然是 0
System.out.println("我在执行旧逻辑");
}
}
在这个场景下,通知对当前 if 块毫无作用。线程会拿着旧值傻傻执行完所有逻辑。
如何让通知立刻生效?
必须在循环中或关键判断点重新去读取该 volatile 变量:
java
while (true) {
if (flag == 1) { // 每次循环都重新读,一旦修改立刻 break
break;
}
// 执行业务逻辑...
}
总结:对 volatile 的两个核心认知
经过上述推敲,我们可以得出对 volatile 最准确的认知:
- 不保证原子性 :多线程的"读-改-写"复合操作,依然会互相覆盖导致数据丢失。要解决这个问题,必须使用
synchronized、ReentrantLock或AtomicInteger等具备原子性的手段。 - 保证可见性,但不具备"实时打断性":它只保证"下一次读取是新鲜的",不保证"马上中断当前正在跑的逻辑"。如果代码只读取一次,后续逻辑全用本地变量,那么可见性就变成了"不可见"。在设计并发程序时,必须警惕这种"陈旧数据"带来的业务逻辑错误。