【JUC】volatile 非原子性深入解析

深入剖析 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),强制将当前缓存行刷入主内存。但它不检查主内存的值是不是比我算出来的新,它只管把当前的值刷出去。

经典的三线程覆盖场景:

  1. 主内存初始 a = 0。T1, T2, T3 同时读取 a=0 到各自缓存。
  2. T1 算完,写回 1。主内存变为 1,通知 T2、T3 缓存失效。
  3. T2 算完(基于 0 算出的 1),准备写回。发现缓存失效,去主内存拉取最新值(1),但它依然把手里算好的 1 覆盖上去 。主内存依然是 1。
  4. T3 同理,最后覆盖上去的还是 1。
  5. 最终结果 :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 最准确的认知:

  1. 不保证原子性 :多线程的"读-改-写"复合操作,依然会互相覆盖导致数据丢失。要解决这个问题,必须使用 synchronized、ReentrantLock 或 AtomicInteger 等具备原子性的手段。
  2. 保证可见性,但不具备"实时打断性":它只保证"下一次读取是新鲜的",不保证"马上中断当前正在跑的逻辑"。如果代码只读取一次,后续逻辑全用本地变量,那么可见性就变成了"不可见"。在设计并发程序时,必须警惕这种"陈旧数据"带来的业务逻辑错误。
相关推荐
金銀銅鐵6 小时前
带您看懂字节码指令(二):空循环里有什么
java·后端·ai编程
吴名氏.8 小时前
Java转AI Agent教程(第十三周)混合检索与重排
java·数据库·人工智能·ai·ai agent
.痞老板.9 小时前
java ee--多线程(进阶)
java·java-ee
深入云栈10 小时前
Netty 4.2.x 源码深度解析 (二十三):ByteBuf 核心设计 —— 索引、类型与零拷贝
java·后端·架构
wang_shu_mo_ran11 小时前
MyBatis 从入门到进阶(一)
java·spring·mybatis
量化分析码农13 小时前
股票历史数据的复权参数对照:多级别返回的字段与数值差异
java·javascript·后端
猫猫不是喵喵.13 小时前
从0到1创建一个微服务项目
java·数据库·微服务·1024程序员节
瑞雪兆丰年兮13 小时前
JavaWeb-day13-SpringBootWeb事务管理&AOP
java·开发语言·springboot·javaweb·事务·web·aop
曲径海盗13 小时前
Try-Except怎么用,3个追问解答在自动化下的应用
java·jvm·oracle
Cosolar14 小时前
极简智库:快速搭建你的开源项目教程阅读平台,让 docs/ 目录被更多人看见
java·架构·github