Skip to content

JMM:happens-before / 安全发布 ​

在总览中的位置:Java 并发模型总览 §5
相关主题:synchronized / Lock / volatile · Atomic* / CAS / LongAdder

一句话定位 ​

JMM(Java Memory Model)定义的是:在编译器优化、CPU 重排序、缓存层次都存在时,Java 多线程之间哪些写入对哪些读取一定可见、哪些执行顺序一定成立;happens-before 是推理这件事的核心语言,安全发布则是它在工程里的高频落点。

代码索引 ​

主题Lab 说明源码
volatile 可见性、非原子自增反例volatile-vs-atomic-vs-lockVolatileVsAtomicVsLock.java
DCL 单例与安全发布singleton-dclSingletonDcl.java
submit() / Future.get() 结果回收链路thread-pool-basicsThreadPoolBasics.java

为什么需要 ​

很多并发 bug 代码看起来并不“复杂”,却会在线上偶发:

  • 一个配置对象明明已经初始化了,别的线程偶尔还是读到旧值
  • 单例实例非空了,里面某些字段却是默认值
  • 主线程改了停止开关,工作线程却像没看见一样继续跑
  • 某个 Future 已经完成,调用方却不知道什么时候可以安全读取结果

这些问题如果只靠“我觉得应该按这个顺序执行”是解释不通的。JMM 的作用就是给出一条更硬的规则:

如果线程 A 的写入和线程 B 的读取之间没有建立明确的 happens-before,就不要假设 B 一定能及时、完整、按你想象的顺序看到 A 的结果。

底层机制 ​

1. JMM 关心的三件事:可见性、原子性、有序性 ​

可见性(Visibility) ​

一个线程写入共享变量后,另一个线程什么时候能看到?

  • 没有同步边界时,读线程可能长期读到旧值
  • volatile、锁、线程启动/结束、Future 完成等同步点都能建立可见性边界

对应 Lab:volatile-vs-atomic-vs-lock

用一个最小反例直观感受"读不到"是什么样:

java
// 反例:running 不加 volatile,工作线程可能永远看不到主线程的修改
static boolean running = true;   // ← 改成 volatile 才能稳定退出

public static void main(String[] args) throws Exception {
    Thread worker = new Thread(() -> {
        long i = 0;
        while (running) i++;          // 主线程改了 running,这里可能看不到
        System.out.println("worker stopped, i=" + i);
    });
    worker.start();
    Thread.sleep(500);                // 给 worker 时间进入循环
    running = false;                  // 主线程关闭开关
    worker.join(2000);
    System.out.println("main: done, running=" + running);
}
  • 可能执行顺序:主线程 sleep 后把 running 写为 false;工作线程的循环条件在 JIT 优化后可能被缓存到寄存器(或 CPU 缓存),永远读不到新值。
  • 可能输出:加 volatile 时打印 worker stopped, i=...;不加时可能一直不打印、join 超时,甚至循环永不退出。
  • 预期现象:同一份代码,本地和线上表现不同(是否被 JIT 热点编译、是否跑在多核)都会影响复现概率——这正是"并发 bug 偶发"的来源。
  • 观察重点:这里缺的正是 happens-before 边界:主线程的写与工作线程的读之间没有同步关系,所以"应该看得见"只是直觉,不是保证。

原子性(Atomicity) ​

一个操作是否会被观察到中间状态?

  • count++ 不是原子
  • 多字段一起更新更不是原子
  • 原子性通常靠锁或 CAS 获得

有序性(Ordering) ​

代码写在前面,不代表别的线程一定按这个顺序观察到它。

  • 编译器和 CPU 会重排序
  • 单线程语义没变不代表多线程语义安全
  • happens-before 的存在,才让“顺序”变得可推理

2. 必须记住的 happens-before 规则 ​

规则你可以怎么记
线程内程序顺序规则同一线程里,前面的操作先于后面的操作
监视器锁规则对同一把锁,先解锁 happens-before 后加锁
volatile 变量规则对同一变量,先写 happens-before 后读
线程启动规则Thread.start() 之前的写,对新线程可见
线程终止规则线程内操作 happens-before 其他线程从 join() 成功返回
Future 完成规则异步任务完成的结果,在 Future.get() 成功返回后对调用方可见
类初始化规则类初始化完成后,静态初始化结果对后续线程可见

这张表真正有用的地方在于排障:

  • “我 sleep 了 100ms,按理说另一个线程应该看到了吧?” —— 不算同步规则。
  • “我把对象丢进并发容器了,别人再取出来是否算安全发布?” —— 通常可以,因为容器内部建立了同步边界。

3. 安全发布:别人不能看到半初始化对象 ​

“安全发布”关心的不是对象有没有 new 出来,而是:

  • 这个对象的引用是否已经跨线程可见
  • 别的线程拿到引用时,对象内部状态是否也已经初始化完整

常见安全发布方式:

方式典型场景备注
静态初始化 / 静态内部类 / enum单例、全局常量JVM 类初始化天然带同步语义
volatile 引用整体替换配置快照、DCL 单例适合“整体替换,不在发布后乱改内部字段”
锁保护后发布复杂对象初始化后放入共享区发布和读取都要遵守同一同步边界
并发容器发布放入 ConcurrentHashMap / 阻塞队列再读取容器内部同步语义帮你建立边界
不可变对象 + 无 this 逃逸配置对象、值对象前提是构造期间别把自己提前暴露出去

4. DCL 为什么依赖 volatile ​

对应 Lab:singleton-dcl

错误核心不在“可能创建多次”这么简单,而在于:

java
class Singleton {
    private static volatile Singleton instance;

    static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}
  • 可能执行顺序:有 volatile 时,创建线程先完成对象初始化,再把引用安全发布给其他线程;后续线程读到非空 instance 后直接复用。
  • 预期现象:实例只创建一次,且其他线程不应读到半初始化对象。
  • 观察重点:DCL 真正要防的是“引用先可见、构造后完成”的重排序,不只是避免重复创建。

如果没有 volatile,new Singleton() 相关动作可能被重排成:

  1. 分配内存
  2. 把引用写到 instance
  3. 再慢慢执行构造初始化

这样别的线程就可能在第 2 步后看见一个“非空但未初始化完成”的对象。

5. final 字段、不可变对象与 this 逃逸 ​

JMM 里常见的第二个误区是:

  • “字段加了 final,对象一定安全发布” —— 不完整。

更准确地说:

  • final 字段有更强的初始化安全语义
  • 但前提是对象在构造期间没有 this 逃逸

危险反例:

java
class BadConfig {
    static BadConfig latest;
    final String token;

    BadConfig() {
        latest = this;   // ❌ 构造期间提前发布
        token = loadToken();
    }
}
  • 可能执行顺序:对象构造还没完成时,latest 已经先把 this 暴露给其他线程;别的线程此时可能读到 token 仍是默认值或未准备好的对象状态。
  • 预期现象:这类 bug 往往偶发、难复现,但本质上已经破坏了 final 初始化安全语义。
  • 观察重点:final 不是护身符;构造期间绝不能让 this 提前逃逸。

这种写法会破坏你以为的安全边界。

Android / Flutter / Web / Backend 对照 ​

维度JMM / happens-beforeAndroidFlutterWebBackend
核心问题共享内存下何时可见、何时有序主线程与后台线程共享状态常见isolate 默认不共享内存,少直接面对 JMMJS 主线程单线程,少显式讨论共享缓存、线程池、并发容器天天遇到
高频场景标志位、单例、配置快照、结果回收页面退出取消、后台配置刷新、单例管理器主要通过消息传递规避主要通过事件循环规避单例、缓存、异步聚合、连接池状态
最大误区把“感觉应该可见”当成同步保证用 sleep 替代同步把 isolate 思维强套到 Java把 Promise 顺序当共享内存可见性忽视安全发布导致偶发脏读

常见场景 ​

1. Android 全局配置快照热更新 ​

java
final class AppConfig {
    final String baseUrl;
    final int timeoutMs;

    AppConfig(String baseUrl, int timeoutMs) {
        this.baseUrl = baseUrl;
        this.timeoutMs = timeoutMs;
    }
}

private volatile AppConfig currentConfig = new AppConfig("https://a.example", 3000);

void reload(AppConfig fresh) {
    currentConfig = fresh;
}
  • 可能执行顺序:重载线程先构造完整的 fresh 快照,再一次性写入 currentConfig;读线程之后要么看到旧快照,要么看到完整新快照。
  • 预期现象:配置热更新边界清晰,不容易出现“字段 A 已新、字段 B 还旧”的半更新状态。
  • 观察重点:这里依赖的是“不可变对象 + volatile 引用替换”的组合;如果发布后还继续改 fresh 内部字段,安全边界又会被破坏。

为什么这类写法比“共享一个可变对象到处改字段”更稳:

  • 发布边界清晰:整体替换引用
  • 读线程只读快照
  • 更容易推理 happens-before

2. 后台任务结果回收 ​

java
Future<User> future = pool.submit(() -> userRepository.load(uid));
User user = future.get();
  • 可能执行顺序:任务线程先完成 load(uid) 并构造结果;调用方在线程外部阻塞等待;get() 返回后再读取 user。
  • 预期现象:调用方在 get() 成功返回后,应能看到任务线程对结果对象所做的初始化写入。
  • 观察重点:Future.get() 不只是取值 API,也是 happens-before 的结果可见性边界;别把它只当“语法糖”。

这里除了“拿到返回值”之外,还建立了一个重要事实:

  • 任务线程里对结果对象的初始化,在 get() 成功返回后对调用方可见

这就是为什么 Future 不只是“值的容器”。

3. 单例 / SDK 管理器初始化 ​

  • 需要懒加载时:DCL + volatile 或静态内部类
  • 不需要复杂延迟加载时:优先静态初始化 / enum / DI 容器
  • Android 上单例若持有 Context,必须转成 applicationContext;这和 JMM 是两层问题,别混在一起

4. 并发容器中的安全发布 ​

当对象放进 ConcurrentHashMap 或阻塞队列再被其他线程取出时,通常可以把这次“进容器 -> 出容器”当作一次同步边界,但前提仍然是:

  • 对象自身初始化已经完成
  • 不是发布后又在其他线程偷偷改内部字段

常见坑 ​

坑现象修法
用 Thread.sleep() 当同步手段本地偶尔“好像好了”,线上仍偶发脏读用锁、volatile、Future.get()、并发容器等真实同步边界
DCL 漏掉 volatile单例偶发半初始化DCL 必须配 volatile,或换更稳的单例方式
发布的是可变对象,发布后还继续改内部字段别的线程读到时序不一致状态优先不可变对象或在同一同步边界内修改
构造函数里让 this 提前逃逸final 字段语义也救不了构造完成前不要把 this 暴露出去
以为“放到线程池里执行了”就自动线程安全结果、状态、发布边界仍可能有洞继续明确状态归属与同步规则

与相近概念对比 ​

概念解决什么不解决什么
volatile某个变量的可见性与重排序约束复合操作原子性、多字段一致性
final构造完成后的初始化安全语义构造期间 this 逃逸、发布后继续乱改
synchronized / Lock建立互斥与 happens-before自动让锁外的共享状态都安全
并发容器发布 / 获取边界、单次操作线程安全对象内部后续修改的整体一致性
安全发布让“别人拿到引用时对象已经完整”代替所有业务并发控制

对应实验 ​

Lab说明源码
volatile-vs-atomic-vs-lockvolatile-vs-atomic-vs-lockVolatileVsAtomicVsLock.java
singleton-dclsingleton-dclSingletonDcl.java
thread-pool-basicsthread-pool-basicsThreadPoolBasics.java

当前仍缺:happens-before 规则可视化专属实验、构造期间 this 逃逸反例、Future 结果可见性专门演示。

复习检查题 ​

  1. 为什么“我让线程 A 先 sleep(100),线程 B 再读”不能算可靠同步?

    答:因为时间延迟不是 JMM 认可的同步规则。sleep() 只是在调度层面让线程暂停,不会自动建立写入和读取之间的 happens-before 关系,所以线上仍可能读到旧值或不完整状态。

  2. happens-before 最值得背下来的两条规则是什么?

    答:第一,对同一把锁的“先解锁后加锁”;第二,对同一 volatile 变量的“先写后读”。这两条是日常解释可见性和有序性的最常用工具。

  3. 为什么 DCL 单例不是“只要双重 if 就完事”?

    答:因为它真正要防的是对象创建时的重排序。没有 volatile,别的线程可能在对象尚未初始化完成时就看到非空引用,所以双重 if 本身不够,必须配合 volatile 做安全发布。

  4. 安全发布和线程安全是同一个概念吗?

    答:不是。安全发布关注“第一次把对象交给别人时是否完整可见”;线程安全还要继续关心对象发布之后被多个线程并发访问、修改时是否仍然正确。

  5. Future.get() 在 JMM 视角下除了“拿结果”还有什么意义?

    答:它还建立了一个结果可见性边界:异步任务线程对结果对象的写入,在 get() 成功返回后对调用方可见,所以它不仅是 API 语法点,也是 happens-before 的落地点。

速记 ​

  • 没有 happens-before,就别脑补“应该看得见”。
  • JMM 复习先抓三件事:可见性、原子性、有序性。
  • 安全发布关心的是:别人拿到引用时,对象是不是已经完整。
  • DCL 的关键不是双重 if,而是 volatile。

站点构建时间:2026/8/24 23:43:17