一个 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. 自测
- 三个必须记住的关系里,哪一个常被误解为"保证原子性"?为什么
count++会丢更新? - volatile 解决的两个问题分别是什么?为什么 DCL 单例必须加 volatile?
- 锁保证了什么、没保证什么?"两把不同的锁"为什么等于没同步?
final字段为什么是一种并发安全手段?"安全发布"的标准手法是什么?- 从粗锁到不可变的四阶段,代价分别是什么?为什么"不可变"最容易被低估?
- 判断"是否需要考虑 JMM"的那个问题是什么?