1. 完整回答
Java 内存模型,简称 JMM(Java Memory Model),是 Java 语言规范定义的一套多线程内存访问规则,主要解决线程之间共享变量的可见性、有序性和原子性问题。
在多线程环境下,编译器和处理器为了提高性能,可能进行指令重排序,线程也可能因为缓存、寄存器等因素观察到不同的数据。如果缺少正确的同步机制,一个线程修改了变量,另一个线程可能一直读到旧值,甚至观察到与代码顺序不同的结果。
JMM 的核心是 happens-before,也就是先行发生关系。它规定了不同线程之间哪些操作必须保持可见性和顺序关系。例如,对同一个 volatile 变量的写操作,先行发生于后续的读操作;释放一把锁,先行发生于其他线程后续获取同一把锁。
Java 主要通过 volatile、synchronized、final 以及并发工具类提供相应的内存语义。
其中,volatile 主要保证可见性和有序性,适合状态标志、配置发布等场景,但无法保证 count++ 这种复合操作的原子性。synchronized 在提供可见性和有序性保证的同时,还通过互斥实现临界区操作的原子性。
实际开发中,JMM 是理解线程安全、锁机制、原子类和并发容器的基础。判断一个并发操作是否安全,关键是分析线程之间有没有建立正确的 happens-before 关系,以及涉及的复合操作是否具备原子性。
2. 核心原理与关键概念
2.1 核心原理
JMM 通过约束共享变量的读取结果和操作顺序,为不同平台上的 Java 并发程序提供一致的内存语义。
- 多线程访问共享变量
线程之间可能因为编译器优化、处理器缓存和指令重排序,观察到不同的变量值。
- 通过同步操作建立 happens-before 关系
例如,线程 A 初始化普通变量 data,然后通过 volatile 标志通知线程 B:
线程 A:
data = 42 → ready = true
线程 B:
读取 ready == true → 读取 data
其中,ready 声明为 volatile。
- 通过传递性保证数据可见
整个过程是:
线程 A 写入 data → 写入 volatile ready → 线程 B 读取到 ready == true → 读取 data
由于程序顺序规则、volatile 同步规则以及 happens-before 的传递性,线程 B 在读取到 ready == true 后,能够看到发布前写入的 data = 42。
这里假设没有其他线程对 data 进行无同步的并发修改。
- JVM 落实内存语义
JVM 根据 JMM 的约束,通过限制编译器重排序,并在需要时使用处理器提供的内存屏障等机制,保证线程观察到的结果符合规范。
JMM 不要求每次访问都直接读写物理主内存,具体实现可以根据硬件架构优化。
2.2 关键概念
| 概念 | 核心含义 |
|---|---|
| 可见性 | 一个线程修改共享变量后,其他线程能否观察到该修改 |
| 原子性 | 操作整体执行时,其他线程无法观察到其中间状态 |
| 有序性 | 约束编译器和处理器重排序对程序可观察结果的影响 |
| happens-before | 定义操作之间的可见性与顺序保证 |
| 数据竞争(Data Race) | 不同线程对同一变量存在冲突访问,且没有 happens-before 关系约束这些访问 |
| 安全发布 | 通过正确的发布方式,使其他线程能观察到对象已经初始化的状态 |
2.3 常见 happens-before 规则
| 规则 | 具体保证 |
|---|---|
| 程序顺序规则 | 同一线程内,前面的操作 happens-before 后续操作 |
| volatile 规则 | 对同一 volatile 变量的写,happens-before 后续的读 |
| 监视器锁规则 | 解锁 happens-before 后续对同一监视器锁的加锁 |
| 线程启动规则 | 调用 Thread.start() happens-before 新线程中的操作 |
| 线程终止规则 | 线程内的操作 happens-before 其他线程从对应 join() 成功返回 |
| 传递性规则 | A happens-before B,B happens-before C,则 A happens-before C |
这些规则也延伸到并发工具类。例如,任务提交到线程池之前的操作 happens-before 对应任务的执行;异步任务中的操作 happens-before 其他线程通过 Future.get() 成功获取结果之后的操作。
3. 高频面试追问
3.1 什么是 happens-before?为什么需要它?
happens-before 是 JMM 判断线程间可见性和有序性的核心规则。
如果操作 A happens-before 操作 B,那么 A 的内存影响必须对 B 可见,并且两者满足相应的顺序约束。
例如,线程 A 在释放锁之前修改了共享变量,线程 B 随后获取同一把锁,就能够看到这些修改。
它解决的是不同线程之间缺乏明确数据可见性保证的问题。
需要注意,happens-before 是内存语义上的顺序关系,不等同于物理执行时间的先后。即使存在这个关系,只要不影响规范要求的可观察结果,JVM 仍然可以进行优化。
另外,单纯依靠操作执行时间相近、线程休眠或者程序运行顺序,无法建立可靠的 happens-before 关系。
3.2 volatile 是如何保证可见性和有序性的?
volatile 通过特殊的读写内存语义,保证共享变量的可见性,并约束相关操作的重排序。
其核心可以分成两个方面:
-
可见性: 对同一个
volatile变量的写操作,happens-before 后续读操作;配合传递性,写入前的普通变量修改也可以对读取线程可见。 -
有序性:
volatile写具有释放(Release)语义,读具有获取(Acquire)语义,JVM 会限制破坏这些语义的重排序。
例如,一个线程先初始化数据,再将 volatile ready 设置为 true,另一个线程读取到 true 后,就可以安全地使用发布之前完成初始化的数据。
但 volatile 不能保证多个操作作为一个整体原子执行,也不能保证线程一定在某个时间内完成读取。
继续追问:volatile 会完全禁止指令重排序吗?
不会。volatile 只约束可能破坏其内存语义的重排序。无关操作仍然可以被优化,前提是最终可观察结果符合 JMM。
3.3 为什么 volatile 不能保证 count++ 的线程安全?
因为 count++ 是复合操作,volatile 无法保证整个读改写过程的原子性。
count++ 实际包含三个步骤:
读取 count → 加 1 → 写回 count
假设两个线程同时读取到 count = 5,分别计算出 6,再写回去,最终结果可能是 6,而预期是 7。
即使 count 使用 volatile 修饰,仍然可能发生这种丢失更新的问题。
通常有两种解决方法:
-
使用
synchronized或Lock,把整个读改写过程放入互斥临界区。 -
使用
AtomicInteger.incrementAndGet()等原子操作,保证更新的原子性。
继续追问:AtomicInteger 为什么能够保证原子性?
AtomicInteger 的自增等操作通常利用底层原子指令实现,常见机制包括 CAS,也就是比较并交换。
CAS 会比较当前值和预期值,如果一致就原子更新,否则重新读取并尝试。
这样可以避免普通 count++ 中多个线程覆盖更新的问题。不过,CAS 本身也存在竞争重试成本,且多个变量之间的一致性通常还需要其他同步设计。
3.4 synchronized 和 volatile 有什么区别?
两者都提供可见性和有序性保证,但 synchronized 还能通过互斥保护复合操作。
volatile 不需要获取互斥锁,适合独立状态变量的读写,例如停止标志、初始化完成标志。
synchronized 通过监视器锁保证同一时刻只有一个线程进入相同锁保护的临界区。线程释放锁之前的写入,对后续获取同一把锁的线程可见。
例如,通知线程停止可以使用 volatile boolean;多个线程共同修改库存、余额等涉及复合操作和业务约束的数据,更适合使用锁或其他具备相应原子性保证的并发方案。
需要注意,synchronized 的可见性保证要求线程通过同一把锁建立同步关系。
3.5 什么是指令重排序?为什么单线程通常没有问题?
指令重排序是编译器或处理器为了提高执行效率,对操作顺序进行调整的行为。
重排序主要来自编译器优化和处理器执行机制,也可能表现为不同内存操作对其他线程的可见顺序发生变化。
Java 遵循 as-if-serial 语义,也就是单线程程序必须表现得像按照程序顺序执行一样。
因此,单线程内不会因为合法重排序而改变程序本身的执行结果。
但在多线程场景下,如果缺少正确同步,其他线程可能观察到与源代码顺序不同的数据状态。
JMM 通过 happens-before 对这些行为施加约束,使同步正确的程序能够获得符合预期的可见性和顺序保证。
3.6 为什么双重检查锁定单例需要 volatile?
因为双重检查锁定(DCL)在缺少正确发布机制时,可能让其他线程观察到尚未完成初始化的对象。
对象创建可以从逻辑上分为:
分配内存 → 初始化对象 → 发布对象引用
在没有适当同步约束时,初始化和引用发布的可见顺序可能发生变化,其他线程就可能读取到非空引用,却观察到不完整的对象状态。
DCL 通常使用两次空值判断:
第一次判断避免每次获取实例都加锁;第二次判断在锁内执行,防止多个线程重复创建实例。
实例引用声明为 volatile 后,能够保证对象初始化过程中的写入,在其他线程通过该变量获取实例时正确可见。
在 Java 5 及之后的内存模型下,正确实现的 volatile + 双重检查锁定可以安全使用。
对于普通懒加载单例,静态内部类也很常见,可以直接利用类初始化的线程安全保证,减少手动同步逻辑。
3.7 final 在 Java 内存模型中有什么特殊作用?
final 除了限制字段在正常代码中的再次赋值,还具有初始化安全性保证。
如果对象正确构造,并且构造过程中没有发生 this 引用逃逸,那么其他线程在对象构造完成后获得其引用时,可以看到构造函数为 final 字段设置的正确值。
例如,一个配置对象在构造函数中初始化 final int timeout = 30,即使通过普通共享引用传递,只要满足正确构造等前提,其他线程读取该 final 字段时也能看到 30。
但需要注意两个限制:
第一,final 引用只能保证引用本身不能重新赋值,引用指向的对象仍然可能被修改。
第二,final 的初始化安全性不能自动解决所有普通字段的可见性问题,也不能保证共享引用会及时被其他线程观察到。
因此,构建不可变对象时,除了使用 final,还需要避免暴露可变的内部状态。
3.8 volatile 修饰对象引用,对象内部字段也线程安全吗?
不能。volatile 可以安全发布对象初始化时的状态,但不会让对象内部的后续修改自动具备线程安全性。
例如,使用 volatile Config config 保存配置对象:
线程 A 完成 Config 对象初始化,再将对象赋值给 config;线程 B 通过读取 config 获取对象,就能够看到发布前完成的字段初始化。
但是,如果多个线程之后继续修改同一个 Config 对象的普通字段,依然可能出现数据竞争。
因此,volatile 修饰引用有两个需要区分的效果:
-
通过该引用发布对象,可以保证发布前初始化状态的可见性。
-
对象发布后的内部状态修改,仍然需要独立的线程安全保证。
比较稳妥的方式是使用不可变对象 + volatile 引用整体替换,避免多个线程并发修改同一个对象内部的字段。
3.9 项目中如何利用 JMM 解决并发可见性问题?
实际开发中,通常根据共享状态的特点选择 volatile、原子类、锁或者并发容器。
例如,一个服务支持动态更新运行参数,可以把多个配置项封装成不可变的 Config 对象,再通过 volatile 引用保存当前配置。
更新配置时:
构建完整的新配置 → 校验配置 → 替换 volatile 引用 → 业务线程读取新配置
业务线程每次读取一次引用,使用该次获取的配置快照,就可以避免读取到新旧配置字段混合的状态。
如果涉及计数器,可以使用 AtomicInteger 或 LongAdder 等工具;如果涉及多个变量之间的一致性,比如余额扣减与流水更新,则需要使用锁、事务或者其他协调机制。
另外,遇到线程无法退出、共享变量偶尔读取异常等问题,会优先检查是否存在普通字段的无同步读写,以及跨线程操作之间是否建立了正确的 happens-before 关系。
不能依靠增加 sleep()、日志打印或者反复测试没有复现问题,就认定程序是线程安全的。
4. 易混淆概念对比
4.1 volatile、synchronized、AtomicInteger
| 对比项 | volatile | synchronized | AtomicInteger |
|---|---|---|---|
| 可见性 | 保证 | 保证 | 保证 |
| 有序性 | 提供特定顺序约束 | 提供锁的同步顺序保证 | 提供对应原子操作的内存语义 |
| 复合操作原子性 | 不保证 | 临界区内可保证 | 支持单个原子变量的原子更新 |
| 互斥锁 | 无 | 有 | 无 |
| 典型场景 | 状态标志、引用发布 | 复合操作、多变量约束 | 并发计数、原子状态更新 |
4.2 可见性、原子性、有序性
| 概念 | 解决的问题 | 典型示例 |
|---|---|---|
| 可见性 | 其他线程能否观察到共享变量的修改 | volatile 状态标志 |
| 原子性 | 多个操作能否整体不可分割地执行 | AtomicInteger 自增 |
| 有序性 | 操作的重排序是否会破坏预期结果 | volatile 发布数据 |
4.3 final 和 volatile
| 对比项 | final | volatile |
|---|---|---|
| 主要作用 | 限制再次赋值,提供初始化安全性 | 跨线程可见性与有序性 |
| 是否允许正常重新赋值 | 不允许 | 允许 |
| 主要内存保证 | 正确构造后,final 字段的初始化值安全可见 | volatile 写与后续读建立 happens-before 关系 |
| 是否保证对象内部线程安全 | 不保证 | 不保证 |
| 常见场景 | 不可变对象 | 状态通知、配置引用替换 |
4.4 JMM 和 JVM 运行时内存区域
| 对比项 | JMM | JVM 运行时内存区域 |
|---|---|---|
| 关注内容 | 线程间共享变量的访问规则 | 程序运行时的内存组织 |
| 核心概念 | happens-before、可见性、同步、重排序 | 堆、虚拟机栈、方法区等 |
| 解决问题 | 并发内存访问的一致性和正确性 | 对象、局部变量、类元数据等如何存放 |
| 典型问题 | 为什么修改对其他线程不可见? | 对象分配在哪里?是否发生内存溢出? |
JMM 是语言层面的并发内存规范。常见的“主内存、工作内存”可以辅助理解线程间数据交互,但不能直接等同于物理内存、CPU 缓存或 JVM 的堆和栈。
4.5 happens-before 和实际执行顺序
| 对比项 | happens-before | 实际执行顺序 |
|---|---|---|
| 本质 | 内存语义上的偏序关系 | 指令实际执行与线程调度过程 |
| 关注点 | 可见性和顺序保证 | 操作何时运行 |
| 是否保证前者物理上先执行 | 不一定 | 由具体执行过程决定 |
| 是否能独立保证线程安全 | 有助于证明内存访问正确性,还需考虑原子性 | 单纯的时间先后无法提供同步保证 |
5. 总结
Java 内存模型是 Java 对多线程共享变量访问行为的一套规范,主要解决可见性、有序性和原子性相关问题。核心机制是 happens-before,通过程序顺序、volatile、锁以及线程启动和终止等规则,建立跨线程的数据可见性和顺序保证。其中,volatile 适合状态通知和安全发布,synchronized 能进一步通过互斥保护复合操作,final 则提供特殊的初始化安全性。JMM 允许编译器和处理器在不破坏规范语义的前提下进行优化,因此正确使用同步机制非常重要。实际判断并发代码是否安全,主要看共享数据有没有正确建立 happens-before 关系,以及涉及的复合操作是否具备原子性。