volatile的读写屏障

135 阅读5分钟

1) JMM 赋予volatile的三件事

  1. 可见性:对同一 volatile 变量的,对随后(happens-after)立即可见的。

  2. 有序性(禁止重排):

    • 写-release:volatile 写之前的普通读写,不能被重排到写之后。
    • 读-acquire:volatile 读之后的普通读写,不能被重排到读之前。
  3. 与普通变量的“安全发布” :一个线程在 volatile 之前对普通变量做的写入,对另一个线程在 volatile 之后的代码可见(happens-before 边)。

这条“写-读”形成 synchronizes-with

Thread-A:普通写… → volatile write ——hb→ Thread-B:volatile read → 普通读…


2) JMM 屏障模型(编译器插入的抽象栅栏)

JMM 让编译器在 volatile 读/写附近插入“内存屏障(fence/barrier) ”,用来限制指令重排缓存可见性

  • LoadLoad:前一个不能与后续重排

  • LoadStore:前一个不能与后续重排

  • StoreStore:前一个不能与后续重排

  • StoreLoad:前一个不能与后续重排(最重)

直觉:

  • volatile 写扮演 release:常见做法≈ 在写前插入 StoreStore(有的平台还需确保写对其他核可见)。

  • volatile 读扮演 acquire:常见做法≈ 在读后插入 LoadLoad + LoadStore。

  • 两端合起来,等价于把写端之前的一切“发布”,读端之后的一切“获取”,从而建立 happens-before

(具体插入点由编译器/平台决定,下面给平台映射)


3) 为什么 DCL 里必须volatile?(屏障如何解决“先发布后构造”)

new Singleton() 可能被 JIT/CPU拆成:

① 分配内存 → ② 把引用写进 INSTANCE → ③ 调构造器初始化字段。

如果发生重排(② 先于 ③),另一个线程读到非空 INSTANCE 却拿到半初始化对象。

volatile 写-release 会禁止“② 跑到 ③ 之前被其他线程可见”,volatile 读-acquire 会禁止“读取到旧值后把随后代码上提”。这个写-读对形成的 hb 保证“构造的所有写对读者可见”,DCL 才正确。


4) 不同 CPU 上怎么落地(HotSpot 的常见映射)

目标:实现前述的 acquire/release 语义和可见性;不同架构需要的硬件指令不同。

x86/x64(TSO,内存模型较强)

  • TSO 天然提供:

    • Load-Load / Load-Store / Store-Store 有序;
    • 允许 Store→Load 乱序(通过写缓冲),这是唯一需要担心的方向。
  • 在 HotSpot 上(JDK8+):

    • volatile 读:通常是普通 mov(利用 TSO 满足 acquire),编译器禁止在其前面上提后续内存访问。

    • volatile 写:通常是普通 mov(满足 release),配合编译器禁止将前序访问下沉到写之后;必要时用带 lock 的指令为 Store→Load 建 fence(在 CAS/原子操作处一定有 full fence)。

结论:x86 上 volatile get/set 很便宜(多数情况下无显式 mfence),但仍然具备 release/acquire 语义。

ARMv7(较弱内存序)

  • 需要显式栅栏:

    • volatile 写:常见是 dmb ish(起到 StoreStore),再执行 str;有时写后也要 dmb 以确保对其他核可见。

    • volatile 读:ldr 后接 dmb ish(起到 LoadLoad/LoadStore)。

ARMv8 / AArch64(有 acquire/release 指令)

  • 更高效的成对指令:

    • volatile 写(release) :stlr(store-release)

    • volatile 读(acquire) :ldar(load-acquire)

不再需要显式的 dmb;ldar/stlr 就是“自带屏障”的读/写。

小结:JMM 只定义语义,JVM 依据平台最小化地用 fence/指令达成;同一 JDK 在不同 CPU 上生成的指令会不同


5) 代码层面你能“看见/使用”的屏障 API

  • java.lang.invoke.VarHandle(JDK9+):

    • getOpaque/getAcquire、setRelease/setOpaque、compareAndSet… —— 细粒度控制内存序;
  • sun.misc.Unsafe(或 jdk.internal.misc.Unsafe):

    • loadFence()、storeFence()、fullFence() —— 手动插 fence(业务几乎不用)。
  • Kotlin 的 @Volatile 只是映射到 Java 的 volatile 语义。


6) 读写屏障到底“禁止”了哪些重排(直观表)

以 X 为 volatile 变量:

代码片段 volatile 时可能发生的重排 volatile 屏障后的约束
普通写 A; volatile 写 X; 普通写 B;A/B 可能互换,甚至 B 下沉到 volatile 写之前StoreStore:A 不能下沉到 X 之后;(编译器约束)B 也不能上提到 X 之前
普通读/写 C; volatile 读 X; 普通读/写 D;D 可能被上提到 volatile 读之前LoadLoad/LoadStore:D 不能上提;读取 X 后再读 D 一定看到发布端的写

注意:JMM 的“程序次序规则”也限制同线程内 volatile 与其他访问的相对顺序,JIT 会配合不做违反 hb 的重排。


7) 面试高频问答(要点速记)

  • volatile 能保证原子性吗?

    只能保证单次读/写的原子性(对 64 位 long/double 也原子),保证 x++ 这类复合操作;需要 synchronized 或 Atomic*。

  • 和 synchronized 区别?

    synchronized = 互斥 + 可见性 + 有序性;volatile = 可见性 + 有序性(非互斥)。

  • 为什么 DCL 必须 volatile?

    防止“先发布后构造”的重排,配合读端 acquire 建立安全发布(见第 3 节)。

  • 是否等同“全栅栏”

    不是。volatile 实现的是 release / acquire全栅栏(fullFence / StoreLoad)更重,一般只在 CAS/原子复合操作等地方用。


8) 极简示例:安全发布 vs 不安全发布

// 不安全:可能看到 default 值
class Holder { int a = 1, b = 2; }
Holder h;
void writer() {
  Holder x = new Holder();      // 写 a,b
  h = x;                        // 可能被重排到初始化之前被别的线程看见
}
void reader() {
  Holder r = h;                 // 可能非 null,但 r.a/r.b 还是默认 0
}

// 安全发布:volatile
volatile Holder hv;
void writer2() {
  Holder x = new Holder();      // 普通写
  hv = x;                       // volatile 写:release,发布前所有写对外可见
}
void reader2() {
  Holder r = hv;                // volatile 读:acquire,之后读到 r.a/r.b 一定是构造后的值
}

一句话记忆

volatile 写 = release(前面的读写不能越过它)volatile 读 = acquire(后面的读写不能越过它) ;两者配对,建立 happens-before,从而既可见有序。JVM 在不同 CPU 上用最小代价的 fence/指令实现这个语义。