JMM 与 volatile

举报
yd_216253085 发表于 2026/10/23 21:45:11 2026/10/23
【摘要】 JMM 与 volatile很多人在写并发代码时遇到"变量明明改了,另一个线程却读不到"或者"指令顺序跟代码写的对不上",这两类问题都源于 Java 内存模型(Java Memory Model,JMM)的抽象。volatile 是最轻量的同步手段,但它保证什么、不保证什么,需要从 JMM 的规则和 CPU 内存屏障两个层面理解。 一、JMM 抽象了什么东西JMM 定义了一套线程与主内存之...

JMM 与 volatile

很多人在写并发代码时遇到"变量明明改了,另一个线程却读不到"或者"指令顺序跟代码写的对不上",这两类问题都源于 Java 内存模型(Java Memory Model,JMM)的抽象。volatile 是最轻量的同步手段,但它保证什么、不保证什么,需要从 JMM 的规则和 CPU 内存屏障两个层面理解。

一、JMM 抽象了什么东西

JMM 定义了一套线程与主内存之间的交互规则,屏蔽了不同 CPU 架构的缓存差异。每个线程有自己的工作内存(抽象概念,对应寄存器、CPU 缓存、写缓冲区),共享变量存放在主内存。

        主内存(共享变量)
        x = 0
       /          \
      /            \
 线程 A 工作内存   线程 B 工作内存
   x = 0 副本       x = 0 副本

线程 A 把 x 改为 1,可能只写在自己的工作内存或写缓冲区里,尚未刷回主内存;线程 B 读到的仍是旧值。这不是 JMM 的 bug,而是 JMM 允许的行为,除非你用同步机制建立 happens-before 关系。

JMM 围绕三个性质展开:

性质 含义 破坏后果
原子性 操作不可分割 i++ 丢更新
可见性 一个线程的写对另一个线程可见 读到旧值、循环不退出
有序性 程序顺序与执行顺序一致 对象未初始化完成就被发布

二、happens-before:JMM 的核心规则

happens-before 是判断可见性的唯一标准。如果 A happens-before B,那么 A 的结果对 B 可见。JMM 定义了八条规则,日常最常用的是这几条:

  1. 程序顺序规则:同一线程内,前面的操作 happens-before 后面的操作。
  2. 监视器锁规则:对同一个锁的 unlock happens-before 后续的 lock。
  3. volatile 规则:对 volatile 变量的写 happens-before 后续对该变量的读。
  4. 传递性:A hb B,B hb C,则 A hb C。
  5. 线程启动规则:Thread.start() happens-before 新线程中的任何操作。
  6. 线程终止规则:线程中的所有操作 happens-before 其他线程检测到该线程结束(join 返回)。
  7. 中断规则:对线程 interrupt() 的调用 happens-before 被中断线程检测到中断。
  8. 对象终结规则:构造函数结束 happens-before finalize() 开始。

注意 happens-before 不等于"时间上先发生"。它只约束可见性,只要结果等价,编译器和 CPU 可以重排。

三、volatile 的两条语义

volatile 提供两件事:

  • 可见性:写 volatile 变量时立即刷回主内存,读时从主内存重新加载。
  • 有序性:禁止 volatile 写与之前的普通读写重排,禁止 volatile 读与之后的普通读写重排。

它不保证原子性。count++ 是读、加、写三步,volatile 只能保证每一步读到最新值,不能保证三步整体不被打断。

public class VolatileCounter {
    private volatile int count = 0;

    public void increment() {
        count++;   // 非原子,并发下仍会丢更新
    }
}

用 javap -c 反编译可以看到 volatile 字段的访问会带上 ACC_VOLATILE 标志,JIT 和 CPU 据此插入内存屏障。

四、内存屏障与字节码

JMM 是规范,落地靠内存屏障(Memory Barrier)。HotSpot 在 volatile 读写前后插入四类屏障:

屏障 作用
LoadLoad 禁止上面的读与下面的读重排
StoreStore 禁止上面的写与下面的写重排
LoadStore 禁止上面的读与下面的写重排
StoreLoad 禁止上面的写与下面的读重排,开销最大

volatile 写的实现策略是:

普通写
StoreStore 屏障
volatile 写
StoreLoad 屏障      ← 在 x86 上通常编译为 lock 前缀指令

volatile 读则是:

volatile 读
LoadLoad 屏障
LoadStore 屏障
普通读

x86 的 lock addl $0, (%rsp) 是常见实现,它会把写缓冲区刷新到缓存,并让其他核心的缓存行失效,同时充当全屏障。这也是为什么 volatile 写比普通写慢,但比 synchronized 轻得多。

提示:StoreLoad 是四类屏障中唯一在 x86 上需要显式指令的,因为 x86 本身是强内存模型(TSO),LoadLoad、LoadStore、StoreStore 天然有序。ARM 等弱内存模型架构则需要更多屏障,这也是同一段并发代码在不同 CPU 上表现不同的原因。

五、指令重排的三个层次

理解 volatile 之前,需要知道重排来自哪里:

层次 是否受 JMM 约束 典型表现
编译器重排 是 JIT 调整指令顺序,把不相关读提前
CPU 乱序执行 是 超标量流水线并行执行无依赖指令
内存系统重排 是 写缓冲区导致其他核心看到乱序的写

JMM 允许这三种重排,只要不破坏单线程语义(as-if-serial)和 happens-before 关系。所以"代码里写在前面"不代表"执行时先发生"。volatile 的作用就是在这三个层次上都设置约束,让关键读写不被跨越。

一个经典的反例是 DCL 里没有 volatile 时,putstatic 被提升到构造方法之前。这不是 CPU 的随机行为,而是 JIT 认为两个写之间没有 happens-before 约束,可以自由排序。

六、双重检查锁为什么需要 volatile

单例的经典写法:

public class Singleton {
    private static volatile Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {                 // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) {         // 第二次检查
                    instance = new Singleton(); // 分三步:分配、初始化、赋值
                }
            }
        }
        return instance;
    }
}

instance = new Singleton() 在字节码层面是:

new           # 分配内存
dup
invokespecial # 调用构造方法
putstatic     # 赋值给 instance

没有 volatile 时,JIT 可能把 putstatic 提到构造方法之前。线程 A 执行到"引用已赋值但对象未初始化",线程 B 在第一次检查时看到 instance != null,直接返回一个字段全是默认值的半成品对象。volatile 禁止了这种重排,同时保证赋值对其他线程可见。

七、volatile 的典型用法

状态标志位,最安全的场景:

public class Worker implements Runnable {
    private volatile boolean running = true;

    @Override
    public void run() {
        while (running) {
            // 处理任务
        }
        System.out.println("stopped");
    }

    public void shutdown() {
        running = false;
    }
}

没有 volatile 时,JIT 可能把 running 提升到寄存器,循环永远看不到修改。

一次性安全发布:

public class ConfigHolder {
    private volatile Config config;

    public Config get() {
        Config c = config;
        if (c == null) {
            synchronized (this) {
                c = config;
                if (c == null) {
                    c = loadConfig();
                    config = c;
                }
            }
        }
        return c;
    }
}

开销较低的读-写锁策略:用 volatile 加锁保护读路径,写路径用锁。适合读远多于写的场景,但要注意读路径拿到的可能不是最新值,业务上需能接受。

八、并发场景分析

volatile 不能替代锁。下面这个场景看起来正确,实际上仍然丢数据:

public class Counter {
    private volatile int value;

    public void add() {
        value++;   // 读-改-写三步,并发下丢失更新
    }
}

正确做法是 AtomicInteger 或 synchronized:

private final AtomicInteger value = new AtomicInteger();
public void add() { value.incrementAndGet(); }

另一个常见误解是"volatile 数组的元素也是 volatile"。volatile int[] arr 只保证数组引用的可见性,arr[0] = 1 不具备 volatile 语义。需要元素级可见性时用 AtomicIntegerArray。

volatile 也不能保证复合条件的原子性。比如"先检查再更新":

if (flag) {        // 两个线程可能都判断为 true
    flag = false;  // 然后都执行
    doSomething();
}

这类 check-then-act 必须用 CAS 或锁。

九、常见坑 / 最佳实践

  1. volatile 不保证原子性。只适用于"单一变量的写入不依赖当前值"的场景,计数、累加一律用原子类。
  2. 双重检查锁必须加 volatile,否则可能发布半初始化对象。JDK 5 之后该写法才真正可靠。
  3. 不要用 volatile 保护多个相关变量。多个 volatile 字段之间没有整体原子性,需要一致性时用锁或不可变对象。
  4. volatile 数组只保护引用。元素读写要用 AtomicIntegerArray、AtomicReferenceArray 或加锁。
  5. volatile 有性能成本。高频写入的字段慎用,先用 LongAdder 之类的分片方案降低竞争。
  6. 优先用 java.util.concurrent 的现成工具。AtomicXxx、ConcurrentHashMap、BlockingQueue 已经处理好可见性与原子性,比手写 volatile 加循环更可靠。

十、小结

  • JMM 规定线程通过工作内存与主内存交互,可见性和有序性问题由此产生。
  • happens-before 是判断可见性的唯一标准,volatile 规则是其中一条。
  • volatile 保证可见性和有序性,通过 StoreStore、StoreLoad 等内存屏障落地,x86 上体现为 lock 前缀指令。
  • volatile 不保证原子性,count++、check-then-act 仍需 CAS 或锁。
  • 双重检查锁必须配合 volatile,否则可能读到未初始化完成的对象。
  • 能用 java.util.concurrent 工具就不要手写 volatile 循环,正确性更难保证。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。