1. 为什么需要理解JMM?
当你在多线程环境下编写Java程序时,是否遇到过这样的场景:明明变量已经修改了,但其他线程却看不到最新值?或者两个线程看似按顺序执行的操作,最终结果却不符合预期?这些"诡异"现象的背后,往往与Java内存模型(JMM)密切相关。
JMM是Java多线程编程的基石规范,它定义了线程如何与内存交互,以及线程之间如何通过内存进行通信。不同于物理内存架构,JMM是一个抽象概念,它规定了:
- 线程对共享变量的写入何时对其他线程可见
- 指令重排序的规则和限制
- happens-before关系的保证
理解JMM能帮助你:
- 编写正确且高效的多线程代码
- 诊断难以复现的并发bug
- 合理使用volatile、synchronized等关键字
- 深入理解并发工具类的实现原理
注意:JMM与JVM内存结构(堆、栈、方法区等)是不同的概念。前者关注线程间通信规则,后者关注运行时数据区域的物理划分。
2. JMM的核心概念解析
2.1 主内存与工作内存
JMM将内存抽象为两类存储:
- 主内存(Main Memory):所有共享变量的存储位置
- 工作内存(Working Memory):每个线程私有的内存空间,保存该线程使用到的变量副本
线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,不能直接读写主内存的变量。这种设计带来了性能提升,但也引入了可见性问题。
2.2 内存间交互操作
JMM定义了8种原子操作来控制主内存与工作内存之间的交互:
| 操作 | 作用 |
|---|---|
| lock | 锁定主内存变量 |
| unlock | 解锁主内存变量 |
| read | 从主内存读取到工作内存 |
| load | 将read得到的值放入工作内存变量副本 |
| use | 将工作内存值传递给执行引擎 |
| assign | 将执行引擎返回值赋给工作内存变量 |
| store | 将工作内存值传送到主内存 |
| write | 将store得到的值写入主内存变量 |
这些操作必须满足特定规则,例如:
- 不允许read/load或store/write单独出现
- 不允许线程丢弃最近的assign操作
- 新变量只能在主内存"诞生"
2.3 happens-before原则
这是判断线程操作是否有序、可见的核心规则。如果操作A happens-before 操作B,那么:
- A对共享变量的修改对B可见
- A的执行顺序排在B之前
JMM天然保证的happens-before关系包括:
- 程序顺序规则:同一线程中的操作按程序顺序
- 锁规则:解锁操作happens-before后续的加锁
- volatile规则:写happens-before后续的读
- 线程启动规则:线程A启动线程B,那么A的操作对B可见
- 线程终止规则:线程B终止前能看到线程A的修改
- 传递性:如果A hb B,B hb C,那么A hb C
3. 指令重排序与内存屏障
3.1 为什么需要重排序?
现代处理器会通过指令级并行(ILP)来优化性能。编译器、运行时和CPU都可能对指令进行重排序,只要不影响单线程执行结果。例如:
java复制// 原始代码
int a = 1;
int b = 2;
// 可能被重排序为
int b = 2;
int a = 1;
但在多线程环境下,重排序可能导致意想不到的结果。JMM通过内存屏障(Memory Barrier)限制特定类型的重排序。
3.2 内存屏障类型
| 屏障类型 | 作用 |
|---|---|
| LoadLoad | 确保Load1在Load2之前执行 |
| StoreStore | 确保Store1在Store2之前执行 |
| LoadStore | 确保Load在Store之前执行 |
| StoreLoad | 确保Store在Load之前执行(全能屏障) |
在Java中,这些屏障通过以下方式插入:
- volatile读写
- synchronized块进入/退出
- final字段的写
- 线程启动/终止操作
4. volatile关键字的深度解析
4.1 volatile的语义
声明为volatile的变量具有两大特性:
- 可见性:写操作会立即刷新到主内存,读操作会从主内存重新加载
- 禁止指令重排序:编译器不会对volatile操作与其他内存操作重排序
java复制class VolatileExample {
volatile boolean flag = false;
public void writer() {
flag = true; // 写操作
}
public void reader() {
if (flag) { // 读操作
// do something
}
}
}
4.2 volatile的实现机制
当写一个volatile变量时:
- JVM会插入StoreStore屏障,确保前面的普通写操作先完成
- 执行写操作
- 插入StoreLoad屏障,确保写操作对其他处理器立即可见
当读一个volatile变量时:
- 插入LoadLoad屏障,确保前面的读操作先完成
- 执行读操作
- 插入LoadStore屏障,确保后续的写操作不会重排序到前面
4.3 volatile的适用场景
适合使用volatile的情况:
- 状态标志位(如开关控制)
- 一次性安全发布(如双重检查锁定)
- 独立观察(定期发布的观察结果)
不适合的场景:
- 需要原子性的复合操作(如i++)
- 多个变量之间存在约束条件
实际经验:在x86架构下,volatile读的性能损失很小,但写操作由于需要StoreLoad屏障(对应mfence指令),开销较大。
5. synchronized的内存语义
5.1 锁的获取与释放
synchronized块的进入(加锁)和退出(解锁)具有特定的内存语义:
- 获取锁时:清空工作内存,从主内存重新加载共享变量
- 释放锁时:将工作内存中的修改刷新到主内存
这相当于:
- 进入同步块:acquire操作(包含LoadLoad+LoadStore屏障)
- 退出同步块:release操作(包含StoreStore+LoadStore屏障)
5.2 锁与happens-before
根据锁规则:
- 同一个锁的解锁happens-before后续的加锁
- 这保证了临界区内的修改对下一个进入临界区的线程可见
java复制class SynchronizedExample {
int sharedValue = 0;
public synchronized void increment() {
sharedValue++; // 临界区操作
}
}
5.3 锁的性能考量
现代JVM对synchronized做了大量优化:
- 偏向锁:无竞争时减少开销
- 轻量级锁:通过CAS避免阻塞
- 锁消除:逃逸分析后去除不必要的锁
- 锁粗化:合并相邻的同步块
但在高竞争场景下,仍可能退化为重量级锁(涉及操作系统互斥量)。
6. final字段的特殊规则
6.1 final字段的初始化安全
正确构造的对象,其final字段的初始化值对所有线程可见,无需同步。这是因为:
- JVM会禁止对final字段写操作的重排序
- 构造函数返回前,确保final字段写入完成
java复制class FinalExample {
final int x;
int y;
public FinalExample() {
x = 42; // final字段
y = 1; // 普通字段
}
}
6.2 final字段的重排序规则
编译器/处理器不能:
- 将final字段的写重排序到构造函数之外
- 将普通字段的读重排序到final字段写之前
这保证了其他线程看到final字段时,构造函数已完成。
6.3 final字段的注意事项
- 不要在构造函数完成前"泄漏"this引用
- 对于引用类型的final字段,只能保证引用不变,对象内容仍可能改变
- 反射可以修改final字段,但会破坏内存可见性保证
7. 双重检查锁定模式剖析
7.1 经典实现的问题
java复制class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // 问题所在
}
}
}
return instance;
}
}
问题在于new Singleton()不是原子操作,可能被重排序为:
- 分配内存空间
- 将引用赋给instance(此时instance!=null)
- 执行构造函数
这可能导致其他线程看到未完全初始化的对象。
7.2 正确的实现方式
解决方案1:使用volatile
java复制private static volatile Singleton instance;
解决方案2:使用静态内部类
java复制class Singleton {
private static class Holder {
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE;
}
}
解决方案3:直接加锁(简单但性能较差)
java复制public synchronized static Singleton getInstance()
7.3 现代JVM的改进
在Java 5+的内存模型中,通过正确使用volatile可以修复这个问题。但在实际编码中,更推荐使用静态内部类方式,它:
- 无需同步
- 延迟初始化
- 保证线程安全
- 代码更简洁
8. 常见JMM相关面试题解析
8.1 volatile和synchronized的区别
| 特性 | volatile | synchronized |
|---|---|---|
| 原子性 | 仅保证单次读/写的原子性 | 保证代码块/方法的原子性 |
| 可见性 | 直接保证 | 通过锁的获取/释放间接保证 |
| 有序性 | 禁止指令重排序 | 通过互斥保证顺序 |
| 阻塞 | 不会导致阻塞 | 可能引发线程阻塞 |
| 适用场景 | 简单状态标志 | 复杂同步需求 |
8.2 happens-before的实际案例
java复制class HappensBeforeExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42; // 1
v = true; // 2
}
public void reader() {
if (v) { // 3
System.out.println(x); // 4
}
}
}
根据happens-before规则:
- 1 happens-before 2(程序顺序规则)
- 2 happens-before 3(volatile规则)
- 3 happens-before 4(程序顺序规则)
- 通过传递性,1 happens-before 4
因此,如果reader方法看到v为true,那么x的值必定是42。
8.3 JMM与缓存一致性协议
JMM是高级抽象,而缓存一致性协议(如MESI)是硬件实现。它们的关系:
- JMM不关心具体硬件如何实现可见性
- MESI协议通过缓存行状态维护一致性
- volatile等语义可能通过缓存刷新或内存屏障实现
- 不同CPU架构对内存屏障的支持不同(x86较强,ARM较弱)
9. JMM在实际项目中的应用
9.1 并发容器的设计
以ConcurrentHashMap为例,它通过以下方式利用JMM:
- 分段锁减少竞争
- volatile变量保证可见性
- final字段保证安全发布
- CAS操作实现无锁编程
java复制// ConcurrentHashMap的Node定义
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val; // 保证可见性
volatile Node<K,V> next; // 保证可见性
// ...
}
9.2 高性能计数器实现
使用@Contended避免伪共享:
java复制@sun.misc.Contended
class CounterCell {
volatile long value;
// ...
}
9.3 无锁算法设计
如非阻塞栈的实现:
java复制class ConcurrentStack<E> {
static class Node<E> {
final E item;
Node<E> next;
// ...
}
AtomicReference<Node<E>> top = new AtomicReference<>();
public void push(E item) {
Node<E> newHead = new Node<>(item);
Node<E> oldHead;
do {
oldHead = top.get();
newHead.next = oldHead;
} while (!top.compareAndSet(oldHead, newHead));
}
}
10. JMM常见误区与陷阱
10.1 "volatile能保证原子性"
错误认知:认为volatile可以替代synchronized实现原子操作。
实际情况:
- volatile只能保证单次读/写的原子性
- 复合操作(如i++)仍需要同步
- 解决方案:使用AtomicInteger等原子类
10.2 "final字段在构造函数完成后可见"
陷阱案例:
java复制class FinalFieldExample {
final int x;
static FinalFieldExample instance;
public FinalFieldExample() {
x = 42;
instance = this; // 泄漏this引用
}
}
其他线程可能通过instance访问到未完全初始化的对象。
10.3 "synchronized块内的操作不会重排序"
实际上:
- synchronized块内部的操作可能重排序
- 但不能跨越monitor enter/exit边界
- 其他线程看到的执行顺序仍符合happens-before
11. JMM性能优化建议
11.1 减少共享变量
- 尽量使用线程局部变量(ThreadLocal)
- 将共享数据拆分为独立部分
- 使用不可变对象(Immutable)
11.2 合理使用volatile
- 避免过度使用volatile写操作
- 将多个volatile变量合并为原子引用
- 考虑使用VarHandle(Java 9+)精细控制
11.3 锁优化技巧
- 减小同步块范围
- 使用读写锁(ReentrantReadWriteLock)
- 尝试无锁数据结构(如ConcurrentLinkedQueue)
- 考虑StampedLock的乐观读
12. JMM调试与验证工具
12.1 jcstress并发测试工具
用于验证并发代码的正确性:
java复制@JCStressTest
@Outcome(id = "1, 0", expect = Expect.ACCEPTABLE, desc = "Expected")
@State
public class APISample_01_Simple {
int x;
volatile int y;
@Actor
public void actor1() {
x = 1;
y = 1;
}
@Actor
public void actor2(II_Result r) {
r.r1 = y;
r.r2 = x;
}
}
12.2 JITWatch分析工具
查看JIT编译后的汇编代码,验证内存屏障:
bash复制java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly YourClass
12.3 Java内存模型验证工具(JMM验证器)
形式化验证并发程序是否符合JMM规范。
13. Java各版本对JMM的改进
13.1 Java 5的增强
- 修正了volatile和final的语义
- 引入java.util.concurrent包
- 明确happens-before规则
13.2 Java 9的VarHandle
提供更精细的内存访问控制:
java复制class Point {
int x;
private static final VarHandle X;
static {
try {
X = MethodHandles.lookup()
.findVarHandle(Point.class, "x", int.class);
} catch (ReflectiveOperationException e) {
throw new Error(e);
}
}
}
13.3 Java 21的虚拟线程影响
虽然虚拟线程改变了线程调度方式,但JMM规则保持不变:
- 内存可见性规则同样适用
- 锁的语义不变
- happens-before关系依然有效
14. 其他语言的类似模型
14.1 C++内存模型
C++11引入了类似的内存模型:
- atomic类型对应Java的volatile
- memory_order参数提供更细粒度控制
- 没有直接的happens-before术语,但有相似概念
14.2 Go的内存模型
通过channel和sync包提供同步原语:
- channel通信保证happens-before
- sync.Mutex类似于synchronized
- sync/atomic包提供原子操作
14.3 JavaScript的内存模型
WebWorker间通信通过postMessage:
- 消息传递保证happens-before
- SharedArrayBuffer允许真正共享内存
- Atomics API提供同步操作
15. 深入学习资源推荐
15.1 经典书籍
- 《Java并发编程实战》
- 《深入理解Java虚拟机》
- 《The Java Memory Model》
15.2 在线资源
- JSR-133规范文档
- Doug Lea的并发编程指南
- Oracle官方Java教程并发章节
15.3 实践项目
- 实现自己的并发容器
- 用jcstress测试现有代码
- 研究JDK并发工具类源码
理解JMM不是一蹴而就的过程。我在实际项目中发现,最好的学习方式是:
- 先掌握基本规则
- 然后通过调试实际问题加深理解
- 最后通过阅读JDK源码验证理论
当遇到难以解释的多线程现象时,回归JMM的基本原理往往能找到答案。记住,在并发编程中,"看起来正确"不等于"真正正确",只有符合内存模型规范的代码才能保证在所有环境下正确工作。
