Java · JMM 内存模型与 volatile

主内存 ↔ 工作内存的抽象: 三大性质 (原子/可见/有序) · happens-before 规则 · volatile 的能与不能

read / load — 拷主存副本 store / write — 写回主存 (时机不确定!) 没有同步 → 写可能永远停在本地缓冲 主内存 Main Memory 所有共享变量的权威副本 (堆中实例字段 / static 字段) shared x = 1 (最新值) JMM 是规范抽象, 不是硬件结构 对应到真实机器 = 内存 + 缓存一致性 + 编译器/CPU 重排的数学模型 定义"什么时候一个写对另一个线程可见" Thread-1 工作内存 寄存器 / 写缓冲 / L1 缓存里的本地副本 x = 0 (旧值, 迟迟没刷回) 编译器还可能重排/缓存它 (提升为寄存器变量) Thread-2 工作内存 同样持有自己的本地副本 x = 0 (读不到 T1 的修改) 经典现象: 死循环停不下来 并发三性质 — 每一性的"病"与"药" 原子性 Atomicity 病: i++ = 读+加+写可交错 → synchronized / Lock / atomic 包 可见性 Visibility 病: 写停在本地缓冲 → volatile / 锁 (解锁前刷回) / HB 边 有序性 Ordering 病: 指令重排跨线程可见乱序 → happens-before / volatile 屏障 happens-before 规则 (节选) 程序次序: 单线程内按代码顺序 监视器锁: unlock → 后续 lock volatile: 写 → 后续读 线程: start / join / 中断 传递性: A→B 且 B→C 则 A→C HB 是"可见性的法律条文": 无 HB 即无承诺 volatile = 可见性 + 有序性, 不保证原子性 (i++ 照样丢) 写后插 StoreLoad 屏障: 立即刷主存 + 使其他核缓存行失效; 读前插屏障: 每次读拿最新值; 两侧重排被禁止 DCL 单例必须 volatile: new 非原子 (分配→初始化→引用赋值), 无屏障时另一线程可能拿到"已赋值未初始化"的半成品 Legend 主内存 / 读写路径 工作内存 三性质 HB 规则 边界/警示

抽象模型

  • • 每线程一个工作内存, 共享变量在主内存
  • • JMM 规定同步动作何时把写"发布"出去
  • • 无同步 = 编译器/CPU 随意优化你的直觉

三性对号入座

  • • 原子性 → 锁 / atomic (i++ 问题)
  • • 可见性 → volatile / 解锁前刷回
  • • 有序性 → HB 规则 / 内存屏障

volatile 边界

  • • 单写多读的标志位: 完美适用
  • • 计数器/复合状态: 换 atomic 或锁
  • • DCL 的 volatile 不是优化, 是正确性

💡 一句话理解

JMM 是一份"契约": 它不描述硬件, 只承诺在哪些前提下, 一个线程的写保证对另一个线程可见 — 这个前提就是 happens-before。volatile/锁/线程 start·join 都是制造 HB 边的手段。没有 HB 边时, 你观察到的"一切正常"只是编译器与缓存恰好没动手 — 换个 CPU 或 JDK 版本就可能翻车。

🧠 必知必会 必考 & 必会

为什么需要 JMM
编译器指令重排 + CPU 乱序执行 + 每核缓存三级放大, "代码顺序"≠"执行顺序"≠"可见顺序"。JMM 用 HB 规则把"允许的优化"和"必须的可见性"划清界限, 让跨平台并发代码可推理。
a = 1; r1 = b;   // 线程 A
b = 1; r2 = a;   // 线程 B → r1==0 && r2==0 可能出现
// 关键: 乱序+缓存让"代码顺序"≠"可见顺序", JMM 划清界限
volatile 的底层
x86 上写 volatile ≈ 普通写 + StoreLoad 屏障(lock addl); 同时禁止编译器把它缓存在寄存器。读屏障保证拿最新值。开销远低于锁, 但每次读写都有屏障成本 — 不是免费的。
volatile boolean stop = false;
// 关键: 写 ≈ 普通写 + StoreLoad 屏障(x86: lock addl)
// 比锁便宜, 但每次读写都有屏障成本 — 不是免费的
volatile 不原子的直觉
count++ 是读-改-写三步, volatile 只保证每步读写都"新鲜", 两线程仍可同时读到相同值再各自+1。计数请用 AtomicLong/LongAdder。
volatile int count = 0;
count++;  // 读-改-写三步: 两线程同读 5 → 各+1 → 都写 6 → 丢一次
// 关键: volatile 只保鲜不原子 → AtomicLong / LongAdder
LongAdder vs AtomicLong
AtomicLong 是单点 CAS, 高竞争下自旋浪费; LongAdder 分散成 Cell 数组各自累加, 读时求和 — 写多读少(监控计数)场景吞吐高一个量级, 读到的值是弱一致瞬时和。
LongAdder n = new LongAdder();
n.increment();   // 写: 分散到 Cell, 高竞争几乎无自旋
n.sum();         // → 读: 弱一致瞬时和, 监控够用
CAS 与 ABA
compareAndSwap 是 atomic 与锁(AQS)的地基: "值是 A 才换成 B"。值从 A→B→A 的中间变化会被漏判 — 版本号(AtomicStampedReference)解决; 大多数业务场景 ABA 无害, 别过度设计。
// CAS: "值是 A 才换成 B" — A→B→A 的中间变化被漏判
AtomicStampedReference<Node> ref;   // 版本号解 ABA
ref.compareAndSet(a, b, s1, s2);    // 值 + 版本双判
long/double 特例
JLS 允许 64 位非 volatile 读写撕裂为两次 32 位(罕见架构); 商用 HotSpot 实际原子, 但写跨平台代码别依赖 — 需要就 volatile 或 atomic。
volatile long seq;   // 对: volatile 保证 64 位读写原子
// 错(示意): 裸 long 依赖平台 — 罕见架构可撕裂成两个 32 位
final 的发布语义
正确构造(不逃逸 this)下, final 字段无需同步即对其他线程可见且看到构造后的值 — 不可变对象天然线程安全的法律基础。
final class Point { final int x, y; }
// 关键: 不逃逸 this 的 final 字段, 他线程必见构造后的值
// → 不可变对象天然线程安全的法律基础

🏭 生产实战 real world

场景 1 · 优雅停机的开关

Quartz/轮询任务收到 SIGTERM 后停止拉新任务, 跑完手头的再退出 — 单写多读的教科书场景:

public class Worker {
    private volatile boolean running = true;      // 立即可见于所有线程

    public void run() {
        while (running) {                    // 无 volatile: JIT 可能把 running 缓存进寄存器 → 死循环
            processOneJob();
        }
        drainAndFlush();
    }
    public void shutdown() { running = false; }  // shutdown hook / 信号处理器调用
}

场景 2 · 双重检查锁单例 (volatile 是正确性必需)

public class Router {
    private static volatile Router instance;   // 少了 volatile = 半成品风险

    public static Router get() {
        if (instance == null) {                 // 一读: 无锁快路径
            synchronized (Router.class) {
                if (instance == null)             // 二读: 防重复创建
                    instance = new Router();     // 分配→初始化→赋值可能重排!
            }
        }
        return instance;
    }
}
// 无 volatile: 线程B 可能看到非空但未初始化完的 instance → NPE 玄学

场景 3 · 配置热更新: volatile 引用 + 不可变对象

public final class Config {                 // 不可变: 所有字段 final
    private final Map<String, Integer> limits;
    private Config(Map<String, Integer> l) { this.limits = Map.copyOf(l); }
}
private volatile Config cfg = load();
void reload() { cfg = new Config(parseFromApollo()); }   // 整体替换, 读侧永远拿到一致一代
// 套路与 Go atomic.Pointer 快照相同: 写时整体换, 读时无锁拿快照

场景 4 · LongAdder 高并发监控埋点

千万级 QPS 的计数, AtomicLong 自旋浪费, LongAdder 分散热点:

public final class QpsCounter {
    private final LongAdder count = new LongAdder();
    public void hit() { count.increment(); }          // 写路径: 分散到 Cell, 几乎无竞争
    public long snapshot() { return count.sum(); }  // 读路径: 弱一致求和, 监控够用
}
# 读多写少要精确一致时仍是 AtomicLong; 监控计数一律 LongAdder

场景 5 · AtomicReferenceFieldUpdater 轻量热更

只有一两个字段需要原子更新时, 不必把整对象包 AtomicReference:

private static final AtomicReferenceFieldUpdater<Router, Rules> RULES =
    AtomicReferenceFieldUpdater.newUpdater(Router.class, Rules.class, "rules");
private volatile Rules rules;                        // 字段本身必须 volatile

public void reload(Rules next) { RULES.set(this, next); }   // 整体换快照
public Route match(String path) { return rules.find(path); }  // 无锁读

场景 6 · BlockingQueue 的 HB 语义当交接协议

put/take 自带 happens-before — 生产者放入前的所有写, 对拿到它的消费者可见, 常被忽略的白送保证:

BlockingQueue<Task> queue = new LinkedBlockingQueue<>(1000);
# 生产者: 构建完整个对象图(含内部 list/dict)才 put → 消费者 take 后完整可见
queue.put(newTask(ids, context));
# 消费者无需再加锁/volatile: take() 返回即已建立 HB 边
Task t = queue.take(); t.execute();

场景 7 · 优雅停机: 双阶段等待

volatile 停止位 + CountDownLatch 等清理完成, 两阶段各司其职:

private volatile boolean running = true;
private final CountDownLatch done = new CountDownLatch(4);

public void worker() {
    try { while (running) process(); }
    finally { flush(); done.countDown(); }        // 退出前必收尾
}
public void shutdown() throws InterruptedException {
    running = false;                                // 阶段1: 广播停
    if (!done.await(15, TimeUnit.SECONDS))       // 阶段2: 等收尾, 超时强杀
        log.warn("graceful timeout, some workers may not flush");
}

场景 8 · ConcurrentHashMap 复合原子操作

"没有就创建"的检查-放入必须用原子方法, 手写 containsKey+put 必丢:

ConcurrentHashMap<String, List<Metric>> groups = new ConcurrentHashMap<>();

# 好: 原子复合 — 同 key 并发只有一个 lambda 生效
groups.computeIfAbsent(key, k -> new CopyOnWriteArrayList<>()).add(m);

# merge 累加同款: counts.merge(id, 1L, Long::sum)

场景 9 · jcstress 复现可见性 bug

"偶尔重现"的并发问题, 用 OpenJDK 的并发压测框架变成确定性结论:

@JCStressTest
@Outcome(id = "1, 1", expect = ACCEPTABLE, desc = "串行或正确同步")
@Outcome(             expect = FORBIDDEN,  desc = "可见性丢失")
public static class VisibilityRace {
    int x; // 非 volatile: 压测能打出 FORBIDDEN 结果即实锤
    @Actor void writer(I r) { x = 1; r.r1 = 1; }
    @Actor void reader(I r) { r.r2 = x; }
}

场景 10 · 不可变对象 + 安全发布

final 字段的构造保证 + volatile 引用 = 无锁共享的黄金组合:

public final class RateTable {                    // final 类 + 全 final 字段
    private final double[] rates;
    RateTable(double[] src) { this.rates = src.clone(); }   // 防御性拷贝
    public double get(int i) { return rates[i]; }        // 只读, 天生线程安全
}
private volatile RateTable table = load();       // volatile 引用整体替换
# 读侧零锁零拷贝, 写侧原子换表 — 配置/汇率/路由的万能形态

⚠️ 编码注意与常见坑 pitfalls

坑 1 · volatile 计数器 — volatile int count; count++ 照样丢更新。正解: AtomicLong(要精确) / LongAdder(高并发监控)。
volatile int count;  count++;          // 错: 读-改-写照样丢更新
LongAdder count = new LongAdder(); count.increment();  // 对
坑 2 · DCL 忘 volatile — 压测一亿次才复现一次的 NPE。正解: volatile 修饰引用; 或干脆静态内部类/枚举单例。
private static Router instance;           // 错: 重排→半成品 NPE
private static volatile Router instance;   // 对: 或改静态内部类
坑 3 · 用 if 等 volatile 条件 — if (!ready) wait() 式检查一次就睡 = 错过唤醒。正解: while 循环重检(防虚假唤醒), 配 wait/notify 或 Condition/BlockingQueue。
if (!ready) lock.wait();     // 错: 只查一次, 错过唤醒
while (!ready) lock.wait();  // 对: 循环重检防虚假唤醒
坑 4 · 复合状态只 volatile — "min+max 两个字段要一致"这种不变量, volatile 各标一个也不行(两个写之间可被观察)。正解: 锁保护复合写入, 或封装成不可变对象整体替换。
volatile int min, max; min=a; max=b;  // 错: 两写之间可被观察
stats = new Stats(a, b);              // 对: 不可变整体替换
坑 5 · 依赖 System.out.println "修复"并发 — println 内部 synchronized 意外建立了 HB 边, 掩盖竞态。正解: 删掉 print 后还正确才算正确。
System.out.println(x);  // 错: 内部 synchronized 建 HB 边, 掩盖竞态
// 对: 删掉 print 后仍正确, 才算真的正确
坑 6 · volatile 当性能优化到处撒 — 每次读写都有屏障成本, 且多数字段本就无跨线程共享。正解: 先定共享边界, 再在边界上加同步。
// 错: 字段一律标 volatile "求保险" — 屏障成本+复合状态照样错
// 对: 先圈定共享边界, 只在边界上加同步
坑 7 · "synchronized 只管互斥"误解 — 锁同样保证可见性(解锁前刷回、加锁后重读), 很多"为可见性"加的 volatile 在已有锁的场景是冗余的。正解: 同一锁保护的字段不需要再 volatile。
synchronized (lock) { x = 1; }  // 解锁即刷回, 可见性白送
// 对: 同一把锁保护的字段不必再 volatile(冗余)
坑 8 · volatile 数组元素不可见 — volatile int[] a 只保证引用可见, a[0]=1 的写对其他线程无同步保证。正解: AtomicIntegerArray / AtomicLongArray, 或 volatile 引用整体替换新数组。
volatile int[] a;  a[0] = 1;    // 错: 只保证引用可见
AtomicIntegerArray a;  a.set(0, 1);  // 对: 元素级原子
坑 9 · 复合赋值当原子 — count += n 是读-加-写三步, volatile/普通字段都不原子。正解: Adder/AtomicLong; 或锁。
count += n;      // 错: 读-加-写三步, 并发会丢
counter.add(n);  // 对: AtomicLong.add / LongAdder.add
坑 10 · HashMap 并发读"应该没事" — 写线程扩容期间读者可能死循环/读到脏链(JDK7)或丢数据(JDK8+)。正解: 并发结构一律 ConcurrentHashMap; 只读共享前必须安全发布(不可变或 HB 边)。
Map<String,Object> m = new HashMap<>();        // 错: 并发写扩容出事
Map<String,Object> m = new ConcurrentHashMap<>();  // 对
坑 11 · SimpleDateFormat 共享 — 非线程安全, 多线程 parse 出错乱日期。正解: DateTimeFormatter(不可变, 随便共享); 老 API 配 ThreadLocal。
static final SimpleDateFormat F; F.parse(s);  // 错: 错乱日期
DateTimeFormatter.ofPattern("yyyy-MM-dd");   // 对: 不可变
坑 12 · Random 的全局种子竞争 — 多线程共享 Random CAS 自旋浪费。正解: ThreadLocalRandom.current() 实例级隔离。
static final Random R = new Random(); R.nextInt();  // 错: 种子竞争
ThreadLocalRandom.current().nextInt();   // 对: 实例级隔离
坑 13 · 锁对象引用被换 — synchronized(lockRef) 期间别处 lockRef = new Object() → 后续线程锁的是新对象, 互斥失效。正解: 锁对象 final; 字段更新型同步用 AtomicReference 状态机。
Object lock;  lock = new Object();  // 错: 换锁后互斥失效
final Object lock = new Object();  // 对: 锁对象不可变
坑 14 · 可变对象未安全发布 — 构造后立刻交给别的线程(如直接丢进全局 map), 对方可能看到半初始化。正解: final 字段+构造完再发布 / volatile 引用 / 锁或并发容器交接。
globalMap.put(k, mutableCfg);            // 错: 可能半初始化
globalMap.put(k, ImmutableCfg.of(...));  // 对: 构造完再发布
坑 15 · wait 条件用 if — 虚假唤醒+唤醒后条件又变, if 只查一次。正解: while (!ready) lock.wait(); 永远 while。
if (!ready) monitor.wait();    // 错: 虚假唤醒只查一次
while (!ready) monitor.wait();  // 对: 永远 while
坑 16 · notify 随机叫醒错误条件的线程 — 一个锁多个等待条件, notify 可能唤醒等"另一个条件"的线程全员空转。正解: notifyAll(保守)或用 Lock 的多个 Condition 精准唤醒。
lock.notify();    // 错: 可能叫醒等另一条件的线程
lock.notifyAll(); // 对: 保守; 或多 Condition 精准唤醒
坑 17 · Thread.start 后重复 start — 状态机只允许一次, 二次抛 IllegalThreadStateException。正解: 需要重跑就建新线程或用线程池。
t.start(); t.start();  // 错: → IllegalThreadStateException
pool.submit(task);     // 对: 重跑建新线程或用池
坑 18 · wait/notify 与 Condition 混用 — synchronized 的 monitor 等 Lock 的 Condition(或反之)直接 IllegalMonitorStateException。正解: 一把锁配一套等待体系, 不混搭。
synchronized(m) { cond.await(); }   // 错: IllegalMonitorStateException
lock.lock(); try { cond.await(); } ...   // 对: 一锁一套等待体系
坑 19 · 把 HB 传递性当全局顺序 — HB 只保证"有边相连"的写可见, 无边的两线程间顺序完全任意。正解: 画依赖图确认每条共享数据都有边, 而不是"反正都同步了"。
// 错: "都加了 volatile = 全局有序" — 无边的两线程顺序任意
// 对: 画共享数据依赖图, 每条边都要有 HB 手段
坑 20 · 相信"多核看得见就是没问题" — x86 强内存模型掩盖了绝大多数可见性 bug, ARM 服务器(云上 graviton)上原样爆发。正解: jcstress 验证; 切 ARM 实例必跑并发压测回归。
# 错: x86 强内存模型上"看起来对"就发布
# 对: jcstress 压测验证; 切 ARM(graviton)必跑并发回归