java 技术随笔

Java 内存模型(JMM)与 volatile 详解:可见性、原子性与有序性

提到 Java 并发,绕不开两个概念:JMM(Java 内存模型)volatile。很多人把 JMM 和 JVM 运行时内存区(堆、栈、方法区)搞混;也有人知道 volatile 能保证可见性,却说不清它到底怎么“禁重排”、为什么不保证原子性。本文从内存模型的角度,把并发三大特性讲透。

一、先分清两个“内存”:JMM 与 JVM 内存结构

  • JVM 内存结构:堆、栈、方法区、程序计数器——回答的是“数据存在哪里”;
  • JMM(Java 内存模型):主内存与工作内存之间如何交互、何时可见、重排到什么程度——回答的是“并发时数据怎么同步”。

JMM 是一套抽象规范(对应 JSR-133),它规定:所有变量存在主内存;每个线程有自己的工作内存(可理解为 CPU 缓存 + 寄存器 + 本地内存的组合抽象),线程读写变量必须先拷贝到自己的工作内存,操作完再写回主内存。正是这层“拷贝-写回”引入了不可见问题。

二、并发三大特性

特性问题解决手段
原子性复合操作被打断(如 i++ 不是一条 CPU 指令)synchronized、Lock、CAS、AtomicXxx
可见性一个线程改了值,另一个线程读不到最新值volatile、synchronized、Lock、final
有序性编译器/CPU 指令重排导致执行顺序“看着乱”volatile(禁重排)、synchronized、内存屏障

三、一个经典的可见性问题

public class VisibilityDemo {
    private static boolean flag = false;   // 改成 volatile 后线程 2 才能退出

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (!flag) { /* 忙等,可能永远不退出 */ }
            System.out.println("线程2发现 flag=true,退出");
        }).start();

        Thread.sleep(100);
        new Thread(() -> { flag = true; }).start(); // 线程1修改主内存的 flag
    }
}

不写 volatile 时,线程 2 的工作内存里缓存了 flag = false,即使线程 1 把主内存的值改成 true,线程 2 也感知不到(除非恰好发生上下文切换等强制刷新),导致忙等循环永远不退出。volatile 修饰的变量,读时强制从主内存拿,写时强制刷回主内存,从而保证跨线程可见。

四、volatile 的两个保证与一个不保证

1. 保证可见性:如上例,写 volatile 变量的操作会插入内存屏障(StoreStore / StoreLoad),确保本线程之前的所有写操作先落主内存;读 volatile 变量会插入 LoadLoad / LoadStore 屏障,确保读到的是最新值。

2. 保证有序性(禁止指令重排):volatile 读写的两侧禁止重排序,这就是双重检查锁单例(DCL)里 instance 必须加 volatile 的原因——防止 new 对象时的“分配内存 → 赋引用 → 执行构造”被重排成“先赋引用再构造”,让别的线程拿到一个“半初始化”的对象。

public class Singleton {
    private static volatile Singleton instance; // volatile 必不可少!

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {                     // 第一次检查(不加锁)
            synchronized (Singleton.class) {        // 只有第一次创建才竞争锁
                if (instance == null) {             // 第二次检查(防止重复创建)
                    instance = new Singleton();     // 可能被重排的“问题现场”
                }
            }
        }
        return instance;
    }
}

3. 不保证原子性:volatile 只对单个读/写原子,对 count++ 这种“读-改-写”复合操作无能为力。两个线程同时读到 0,各自 +1 写回,结果可能还是 1 而不是 2。

public class Counter {
    private volatile int count = 0;  // volatile 救不了 count++

    public void increment() {
        count++;  // 读、加、写三步,非原子
    }
    // 正确做法一:AtomicInteger
    // 正确做法二:increment 加 synchronized
}

五、synchronized 与 volatile 的区别(面试必问)

对比项volatilesynchronized
保证内容可见性 + 有序性原子性 + 可见性 + 有序性
是否阻塞线程不阻塞,无锁会阻塞/竞争锁(可升级为偏向/轻量级/重量级锁)
适用场景状态标志、DCL 单例、单写多读复合操作的临界区互斥
开销较小较大(JDK 6 后已大量优化,竞争不激烈时可忽略)

六、Happens-Before 原则(JMM 的核心秩序)

与其死记“内存屏障”,不如记 JMM 定义的 happens-before 规则——只要两操作满足这些规则,前面的写对后面的读可见:

  1. 程序次序规则:一个线程内,写在前面的代码先于后面的执行结果可见;
  2. 管程锁定规则:一个 unlock 先于后面对同一锁的 lock;
  3. volatile 变量规则:对 volatile 的写先于之后对它的读;
  4. 线程启动规则:Thread.start() 先于该线程的任何动作;
  5. 线程终止规则:线程内所有操作先于 join() 返回;
  6. 线程中断规则:interrupt() 调用先于被中断线程检测到中断;
  7. 对象终结规则:对象构造完成先于 finalize() 开始;
  8. 传递性:A 先于 B、B 先于 C,则 A 先于 C。

第 2、3 条正是 synchronized 和 volatile 能保证可见性的理论依据。日常写代码,只要“写方与读方之间能建立 happens-before 关系”,就不需要额外加锁——这也是无锁并发编程的基础。

七、常见误区小结

  • “volatile 修饰基本类型就能当锁用” → 错,它不保证复合操作原子性;
  • “final 字段一定能安全发布” → final 确实提供初始化安全保证,但引用类型的内部可变状态仍需自行同步;
  • “单线程不需要关心重排” → 单线程内 JMM 保证 as-if-serial(结果不变即可重排),所以无需关心;多线程共享数据的场景才需要规则约束;
  • “CPU 缓存一致性就够用了” → 现代 CPU 用 MESI 等协议 + 内存屏障配合,语言层面的 JMM 才是我们写 Java 要遵守的契约。

一句话收尾:并发 Bug 大多数不是逻辑写错,而是“看不见、乱序、被打断”。能清楚说出 JMM 三条特性和 happens-before,再结合 volatile/synchronized 的边界,就掌握了 Java 并发最底层的地基。

标签
JMMvolatileJava并发内存模型