JMM 与 volatile
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 定义了八条规则,日常最常用的是这几条:
- 程序顺序规则:同一线程内,前面的操作 happens-before 后面的操作。
- 监视器锁规则:对同一个锁的
unlockhappens-before 后续的lock。 - volatile 规则:对
volatile变量的写 happens-before 后续对该变量的读。 - 传递性:A hb B,B hb C,则 A hb C。
- 线程启动规则:
Thread.start()happens-before 新线程中的任何操作。 - 线程终止规则:线程中的所有操作 happens-before 其他线程检测到该线程结束(
join返回)。 - 中断规则:对线程
interrupt()的调用 happens-before 被中断线程检测到中断。 - 对象终结规则:构造函数结束 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 或锁。
九、常见坑 / 最佳实践
volatile不保证原子性。只适用于"单一变量的写入不依赖当前值"的场景,计数、累加一律用原子类。- 双重检查锁必须加
volatile,否则可能发布半初始化对象。JDK 5 之后该写法才真正可靠。 - 不要用
volatile保护多个相关变量。多个volatile字段之间没有整体原子性,需要一致性时用锁或不可变对象。 volatile数组只保护引用。元素读写要用AtomicIntegerArray、AtomicReferenceArray或加锁。volatile有性能成本。高频写入的字段慎用,先用LongAdder之类的分片方案降低竞争。- 优先用
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循环,正确性更难保证。
- 点赞
- 收藏
- 关注作者
评论(0)