这是《JVM 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 Java 内存模型(JMM)定义多线程读写共享变量时的可见性、有序性和原子性。很多并发 bug 只在特定调度、缓存和编译器优化下出现,理解 JMM 才能判断代码是否正确。
9.1 问题来源
Thread A
-> write shared variable
-> store buffer / cache
-> memory
Thread B
-> read shared variable
-> cache
-> 可能读到旧值
编译器、CPU 和内存系统都可能重排指令。单线程语义不变时,重排对单线程不可见,但多线程之间可能产生不同结果。
三类问题:
| 问题 | 示例 |
|---|---|
| 可见性 | 一个线程修改,另一个线程看不到 |
| 原子性 | count++ 非原子 |
| 有序性 | 语句编写顺序与观察顺序不同 |
9.2 happens-before
JMM 用 happens-before 表达可见性规则。若 A happens-before B,则 A 的结果对 B 可见。
常见规则:
- 单线程程序顺序规则;
- 监视器锁解锁 happens-before 后续锁定;
- volatile 写 happens-before 后续读;
- 线程 start happens-before 该线程内操作;
- 线程内操作 happens-before join 返回;
- 传递性;
- 线程中断调用 happens-before 检测中断;
- 对象默认初始化 happens-before 其他线程看到引用。
错误理解:happens-before 不是时间先后,而是内存可见性约束。
9.3 数据竞争
public class Counter {
private int count;
public void increment() {
count++;
}
public int get() {
return count;
}
}
问题:
- 多线程写共享字段;
- 没有同步;
count++分为读、加、写;- 存在数据竞争。
正确方案:
private final AtomicInteger count = new AtomicInteger();
public void increment() {
count.incrementAndGet();
}
或:
public synchronized void increment() {
count++;
}
9.4 重排示例
public class ReorderDemo {
int a = 0;
boolean ready = false;
public void writer() {
a = 1;
ready = true;
}
public void reader() {
if (ready) {
System.out.println(a);
}
}
}
多线程下 reader 可能观察到 ready == true 但 a == 0。
修复:
volatile boolean ready = false;
volatile 写之前的写,对后续 volatile 读可见,并限制相关重排。
9.5 final 语义
正确构造对象后,final 字段具有安全发布语义。
public class Config {
private final int version;
private final List<String> nodes;
public Config(int version, List<String> nodes) {
this.version = version;
this.nodes = List.copyOf(nodes);
}
}
注意:
- 构造函数中不要泄漏 this;
- final 引用不可变不代表对象内部可变状态线程安全;
- 集合应使用不可变集合;
- 静态工厂返回不可变对象更清晰。
9.6 安全发布
不安全:
public mutable Object instance;
其他线程可能看到部分构造对象。
安全方式:
| 方式 | 机制 |
|---|---|
| volatile 字段 | 可见性和禁止重排 |
| final 字段 | 正确构造后的语义 |
| 锁 | 互斥和内存语义 |
| 并发容器 | 内部同步 |
| Atomic 类 | CAS 和 volatile |
| 线程 start / join | happens-before |
9.7 原子性
| 操作 | 是否原子 |
|---|---|
| 引用赋值 | 通常原子,但可见性需另行保证 |
| long / double 写 | Java 规范允许特殊处理,volatile 可保证 |
i++ |
否 |
AtomicLong.incrementAndGet |
是 |
ConcurrentHashMap.put |
单个映射操作原子 |
ConcurrentHashMap 保证单个操作的原子性,复合操作仍需 compute 等原子方法:
map.compute(key, (k, old) -> old == null ? 1 : old + 1);
9.8 内存屏障
常见屏障:
| 屏障 | 作用 |
|---|---|
| LoadLoad | 前面读与后面读不重排 |
| StoreStore | 前面写与后面写不重排 |
| LoadStore | 前面读与后面写不重排 |
| StoreLoad | 前面写与后面读不重排 |
volatile 在不同 CPU 架构上可能生成不同指令。JMM 屏蔽硬件差异,编写代码时关注 Java 语义即可。
9.9 常见误区
| 误区 | 事实 |
|---|---|
volatile 让 ++ 原子 |
不会 |
| synchronized 只做互斥 | 还有内存语义 |
| sleep 会释放锁 | 不释放 |
| 双重检查不需要 volatile | 需要正确写法 |
| HashMap 加锁读就一定安全 | 也要看读写路径 |
| ConcurrentHashMap 复合操作天然原子 | 不一定 |
双重检查锁:
public class Singleton {
private static volatile Singleton instance;
public static Singleton getInstance() {
Singleton result = instance;
if (result == null) {
synchronized (Singleton.class) {
result = instance;
if (result == null) {
result = new Singleton();
instance = result;
}
}
}
return result;
}
}
更简单的方式是使用静态 holder 或 enum。
9.10 JMM 与 GC
JMM 主要讨论线程可见性,GC 主要讨论对象生命周期,但它们在引用发布上存在交互:
- 安全发布让 GC 能一致地看到引用;
- weak / soft / phantom 引用读取需要同步;
- ReferenceQueue 清理与业务线程并发;
- finalizer 语义复杂,不推荐资源清理;
- Cleaner 也有时序限制。
堆外资源应使用 try-with-resources 明确释放。
本章小结
JMM 用 happens-before 定义多线程可见性。解决数据竞争要选择锁、volatile、Atomic、并发容器或不可变对象。volatile 提供可见性和有序性,不提供复合操作原子性。并发正确性先于性能优化。
思考题
- happens-before 和时间先后有什么区别?
- 为什么
count++不是原子操作? - volatile 能解决哪些问题,不能解决哪些问题?
- final 的安全发布语义是什么?
- 如何证明一段代码存在数据竞争?