volatile 到底保证了什么可见性、有序性与 DCL目录从一个无法停止的线程说起JMM 与 happens-beforevolatile 的两项保证DCL 单例为什么需要 volatilevolatile 保证不了原子性适用场景与边界小结从一个无法停止的线程说起privatestaticbooleanrunningtrue;publicstaticvoidmain(String[]args){newThread(()-{while(running){// 做点什么}System.out.println(停止了);}).start();Thread.sleep(1000);runningfalse;}主线程等一秒后把running设为false子线程的 while 循环应该退出。但实际运行时子线程可能永远不会停下来。running是一个普通共享变量两个线程之间没有建立任何 happens-before 关系因此子线程不保证能观察到主线程写入的false。编译器可能复用已经读取的值CPU 也可能通过缓存和写缓冲区延迟写入结果的传播。对程序来说最终表现就是主线程已经写了false子线程却仍然继续读到true。JMM 与 happens-beforeJava 内存模型JMM定义了线程之间共享变量的读写规则。它使用主内存和工作内存描述线程之间的数据交互但这两个概念是规范层面的抽象不等同于 JVM 堆、CPU 缓存或寄存器。实际运行时变量值可能存在于堆、寄存器、CPU 缓存或写缓冲区中JMM 只规定不同线程能够观察到哪些读写结果。普通变量的读写不强制跨线程可见性。JMM 没有规定一个线程写入后其他线程必须立即看到新值也没有规定写入何时传播到其他线程。要建立跨线程的可见性保证需要依赖happens-before规则。JMM 定义了一系列 happens-before 关系如果操作 A happens-before 操作 B则 A 的结果对 B 可见。与 volatile 相关的规则有两条volatile 写 happens-before 后续对同一变量的 volatile 读线程 A 写入 volatile 变量后线程 B 读取同一变量时一定能看到线程 A 的写入volatile 写之前的普通写对读取方可见线程 A 在 volatile 写之前对普通变量的修改对线程 B 在 volatile 读之后的操作可见第二条规则的意义更大它不只是保证 volatile 变量本身可见还把 volatile 写之前的普通写携带了出去。volatile 的两项保证给变量加上 volatile 后JMM 对它的读写施加了两项约束可见性一个线程写入 volatile 变量后其他线程后续读取这个变量时能够观察到该写入以及写入之前已经完成的普通写操作。有序性禁止特定的指令重排序。volatile 写之前的普通写不会被重排到 volatile 写之后volatile 读之后的普通读写不会被重排到 volatile 读之前。具体实现依赖编译器约束、CPU 内存屏障和缓存一致性协议。在 JIT 编译生成机器码时volatile 读写会受到屏障约束volatile 写之前需要阻止前面的普通写越过它写之后需要限制后续读写提前volatile 读之后则要阻止后续读写越过本次读取。具体生成什么指令取决于 CPU 架构。来看一个重排序的例子inta0;intb0;booleanflagfalse;// 线程 Aa1;b2;flagtrue;// flag 是 volatile 变量// 线程 Bif(flag){intresultab;}a 1、b 2和flag true之间没有数据依赖编译器和 CPU 可能对它们重排序。但如果flag是 volatilehappens-before 规则保证线程 B 看到flag true时a和b的值一定已经确定为 1 和 2。DCL 单例为什么需要 volatile双重检查锁定DCL在懒加载和性能之间做了折中只在第一次创建时加锁之后直接返回实例。publicclassSingleton{privatestaticvolatileSingletoninstance;privateSingleton(){}publicstaticSingletongetInstance(){if(instancenull){// 第一次检查不加锁synchronized(Singleton.class){if(instancenull){// 第二次检查加锁instancenewSingleton();}}}returninstance;}}为什么instance必须是 volatile从内存语义上对象创建可以概括为三个阶段分配内存、执行初始化、发布对象引用将引用赋值给变量。这里的三步是为了说明并发可见性不代表它们固定对应三条字节码或机器指令。如果没有安全发布机制其他线程可能先观察到对象引用再观察到构造过程对对象字段的写入。也就是说线程 B 在第一次检查时看到instance ! null拿到的却是一个构造尚未完成的对象。volatile 保证了发布引用volatile 写之前的初始化操作对其他线程可见。线程 B 看到instance ! null时对象一定已经构造完成。DCL 中的 volatile 解决的核心问题不是互斥而是实例的安全发布。volatile 保证不了原子性volatile 读写本身具有原子性但counter不是一次 volatile 读写而是一个读取—计算—写回的复合操作。privatestaticvolatileintcounter0;counter实际上是三步1. 读取 counter 的当前值 2. 值加 1 3. 写回新值volatile 保证了步骤 1 能读到最新值步骤 3 能立即对其他线程可见。但它不能保证这三步是一个不可分割的整体。两个线程可能同时读到 counter 100各自加 1各自写回 101。两次自增只增加了 1。正确的做法是使用原子类privatestaticAtomicIntegercounternewAtomicInteger(0);for(inti0;i10;i){newThread(()-{for(intj0;j10000;j){counter.incrementAndGet();}}).start();}// 等待所有线程结束后// counter.get() 一定是 100000AtomicInteger底层使用 CAS 操作在 CPU 指令层面保证读-改-写的原子性。如果用AtomicInteger替换 volatile int还需要确保所有线程执行完毕后再读取结果比如通过Thread.join()或CountDownLatch否则示例本身就不严谨。三者的适用范围机制保证开销适用场景volatile可见性、有序性最低无锁状态标志、一次性发布AtomicInteger可见性、有序性、原子性低CAS 自旋计数器、累加器synchronized可见性、有序性、原子性、互斥较高涉及阻塞复合操作、临界区保护适用场景与边界volatile 适合用于独立的状态读写每次写入都是一个完整的新值不需要基于旧值计算也不要求多个变量同步变化。状态标志。一个线程设置标志位其他线程根据标志决定行为volatilebooleanshutdownfalse;// 某个线程设置关闭信号shutdowntrue;// 其他线程检查while(!shutdown){doWork();}一次性安全发布。DCL 单例中的instance就是这个模式。对象构造完成后通过 volatile 引用发布读取方一定能看到完整的对象。独立观察值。某个变量会被周期性更新比如温度传感器的读数读取方不要求观察到每一次变化只需要获取某次已经完成的写入结果。volatile 不适合的场景复合操作读-改-写、先检查再操作这些需要原子性保证用 AtomicInteger 或锁多个变量之间有关联约束比如low highvolatile 不能保证两个变量同时更新的一致性高频写入且竞争激烈多个线程频繁写同一个 volatile 变量时会产生明显的缓存一致性流量和缓存行竞争性能可能迅速下降。如果不同线程修改的是不同变量但这些变量位于同一缓存行还可能产生伪共享问题小结volatile 适合状态通知和对象的安全发布。它能够建立跨线程的可见性顺序但不能把counter、先检查再修改等复合操作变成原子操作。判断是否适合使用 volatile关键不是线程数量而是共享状态能否通过独立读写完成更新以及是否存在需要同时维护的状态约束。