2685 字
约 8 分钟
20
S05-A17 · Java 内存模型:可见性的三条规则够不够用
2024-12-31
2026-11-10

一个 volatile boolean running = true 的循环,在加了打印语句后就不再退出——而删掉打印又正常。现象诡异,原因却是确定的:没有同步手段时,读线程可以把 running 缓存在自己的寄存器里,于是它永远看到 true。加了 System.out.println 之后,因为那次调用隐含了同步,读到的是新值。

它是一份"契约":JVM 与硬件可以自由重排与缓存,只要不破坏这份契约。所以违反契约的代码并不会在编译时报错,它只是"在某些机器、某些负载下"出错——这也是并发 bug 难以复现的根本原因。
JMM(Java 内存模型)处理的不是"内存怎么分配",而是**"一个线程的写,另一个线程什么时候能看见"**。理解它不需要背全部规则,只需要三件事:happens-before、volatile 的边界、以及锁到底保证了什么。


1. 三个必须记住的关系

关系 保证
程序顺序 同一线程内,前面的操作先于后面的
监视器(锁) 释放锁先于后续获取同一把锁
volatile 写先于后续读同一变量

第三条常被误解为"volatile 保证原子性"——它不保证。volatile int count; count++ 仍然会丢更新,因为 ++ 是读—改—写三步。

前两条的用途不同:锁能保护"一组操作"的可见性与原子性第三条(volatile)还有一个"读写的代价"差别:volatile 写比普通写慢(需要内存屏障),而 volatile 读与普通读接近。所以"多读少写"的标志位适合 volatile,"多写"的计数器不适合。
,volatile 只能保护"单个变量"的可见性与有序性。

// 反例:volatile 不保证原子性
volatile int count = 0;
void inc() { count++; }             // 丢失更新

// 正例:需要原子性时用原子类或锁
AtomicInteger count = new AtomicInteger();
void inc() { count.incrementAndGet(); }

2. volatile 真正解决的两个问题

问题 volatile 的作用
可见性 写之后,读一定看到新值
有序性(重排) 禁止与相邻操作的重排

第二项(禁止重排)的直观理解是"在 volatile 写之前的所有操作,都不会被排到它之后"。这就是"发布"的语义:先把对象准备好,再让它可见。
第二项在实践中更重要:双重检查锁定(DCL)单例如果不加 volatile,可能拿到"构造未完成"的对象——因为 instance = new X() 包含"分配、构造、赋值"三步,重排可能让赋值先发生。

新一代写法的正解是"静态内部类"或"枚举",它们不依赖任何显式同步。

// 反例:DCL 缺 volatile(历史上的经典 bug)
private static Foo instance;
static Foo get() {
    if (instance == null) synchronized (Foo.class) {
        if (instance == null) instance = new Foo();   // 可能重排
    }
    return instance;
}

// 正解一:加 volatile
private static volatile Foo instance;
// 正解二:静态内部类(无需 volatile)
static class Holder { static final Foo INSTANCE = new Foo(); }
static Foo get() { return Holder.INSTANCE; }

3. 锁保证了什么、没保证什么

保证 说明
互斥 同一时刻一个线程进入
可见性 解锁前的写对下一个加锁者可见
不保证 跨不同锁的可见性

第三项是最常见的错误:两个线程用了不同的锁(或其中一个根本没加锁),可见性完全不成立。第三项还有一种"看起来是同一把锁"的情况:锁的是不同实例(如每个对象自己 synchronized(this)),于是保护的是各自的对象,共享数据毫无保护。
而代码里两处都写了 synchronized,看起来都在保护同一个数据。

// 反例:两把不同的锁,等于没同步
synchronized (lockA) { shared.x = 1; }
synchronized (lockB) { System.out.println(shared.x); }   // 可能看到旧值

// 正例:同一把锁(或用同一对象的 synchronized 方法)
synchronized (lock) { shared.x = 1; }
synchronized (lock) { System.out.println(shared.x); }

4. 不可变与安全发布

方式 安全性
final 字段 构造完成后对其他线程可见(安全)
静态初始化 类加载时完成,安全
普通字段 + 无同步 不安全(可能看到半构造对象)
volatile 引用 安全发布

第一项还有个细节:final 的保证只针对"构造器完成之前赋值的字段"。如果字段在构造器之后才通过方法赋值,那它没有这份保证。
第一项的价值被低估:final 字段在构造器完成后,其它线程一定能看到正确值,这是 JMM 给出的特例保证。所以"不可变对象"不只是一种风格,它同时是一种并发安全手段。

第四项是"发布"的标准手法:把对象放进 volatile 字段或 ConcurrentHashMap,等于安全发布。

// 安全发布:通过 volatile 或并发容器
private volatile Config config;
void reload() { config = load(); }       // 新对象整体可见
Config get() { return config; }          // 读到的不会是半构造对象

还有两个"日常会遇到"的场景值得记:

// 场景一:线程间停止信号(volatile 的典型用法)
private volatile boolean running = true;
void stop() { running = false; }                 // 写
void loop() { while (running) { work(); } }      // 读:一定看到 false

// 场景二:一次性初始化(用 volatile + 空判断,比 DCL 简单)
private volatile Map<String, Rule> rules;
Map<String, Rule> rules() {
    var r = rules;
    if (r == null) { r = loadRules(); rules = r; }   // 可能重复加载,但安全
    return r;
}
// 允许重复加载的代价,换取"无锁读"

5. 长路:从"加锁"到"不加锁"

阶段 手段 代价
粗锁 synchronized 方法 串行化
细锁 分段锁 复杂度
无锁 CAS / 原子类 ABA、重试
不可变 每次新建对象 分配开销

第四项是最被低估的解法:把"修改"换成"替换",于是读路径完全不需要同步。第四项的代价在"对象大小"上:一个 10KB 的配置对象每秒替换一次,就是每秒 10KB 的垃圾。高频场景下,它会把 GC 变成瓶颈——所以它适合"读多写少"。
上面 volatile Config 的例子就是这个思路。

但要接受代价:每次变更都产生新对象,在高频变更场景会造成 GC 压力。

观测层面还有一个"不可见"的问题:JMM 违例不会留下任何日志。它不抛异常、不产生错误码,唯一的表现是"值不对"。所以排查手段只能是"读代码找共享可变状态",而不是"看日志"。

// 排查清单(按顺序,五分钟内可做完)
1 找出所有"被多个线程访问"的字段(静态字段、单例字段、传入的对象)
2 对每个字段问:写发生在哪、读发生在哪、中间有什么同步
3 没有同步的 → 要么加 volatile,要么改为不可变,要么加锁
4 有同步但不一致的(不同锁对象)→ 统一锁对象
// 关键:先找"共享",再找"同步";反过来找不到

6. 排错:五种"并发问题查不出"

现象 成因 处理方向
加打印就正常 打印隐含同步 查真实同步点
单机对多机错 进程内锁 换分布式方案
偶发读到旧值 无可见性保证 volatile/锁
对象半初始化 无安全发布 final 或 volatile
加了锁仍错 锁对象不一致 统一锁对象

第四项(半初始化)的排查方式是"看这个对象是怎么被共享出去的":如果通过普通字段发布,那就是根因;如果通过 final 字段或并发容器,才可能是别的问题。
第一项是个好线索:"加日志就正常、删日志就复现"几乎总是可见性问题。因为日志调用的同步副作用掩盖了缺失的保证。

最后一个工程建议:优先用"库提供的并发结构",而不是自己写同步。AtomicXxx、ConcurrentHashMap、BlockingQueue、CompletableFuture 已经封装了 JMM 的细节,而自己写的 synchronized 组合极容易在边界上出错。

// 选择顺序(越靠前越安全)
1 不用共享状态(消息传递、局部变量、不可变对象)
2 用并发容器/原子类
3 用不可变 + volatile 替换
4 用锁(最后手段,需评审)

7. 边界:什么情况不需要考虑 JMM

场景 为什么 做法
单线程或纯局部变量 无共享 不必
不可变对象共享 无需同步 不必
用并发容器传递数据 容器已保证 不必
消息传递而非共享内存 无共享状态 不必

判断口径一句话:有没有"一个线程写、另一个线程读"的共享可变状态。最后一条与"测试"有关的边界:JMM 问题几乎无法用单元测试稳定复现,因为它依赖硬件与调度。可行的验证是压力测试(长时间多线程跑)+ 代码审查(找共享可变状态),而不是写一个"能测出竞态"的用例。
没有,JMM 与你无关;有,就必须有 happens-before 关系(锁、volatile、final、并发容器)。


8. 自测

  1. 三个必须记住的关系里,哪一个常被误解为"保证原子性"?为什么 count++ 会丢更新?
  2. volatile 解决的两个问题分别是什么?为什么 DCL 单例必须加 volatile?
  3. 锁保证了什么、没保证什么?"两把不同的锁"为什么等于没同步?
  4. final 字段为什么是一种并发安全手段?"安全发布"的标准手法是什么?
  5. 从粗锁到不可变的四阶段,代价分别是什么?为什么"不可变"最容易被低估?
  6. 判断"是否需要考虑 JMM"的那个问题是什么?
S05-A17 · Java 内存模型:可见性的三条规则够不够用
https://www.clxhxhhr.top/posts/6506/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。