← 返回文章

Java 内存模型

发布于 2026/10/19

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 并发程序提供一致的内存语义。

  1. 多线程访问共享变量

线程之间可能因为编译器优化、处理器缓存和指令重排序,观察到不同的变量值。

  1. 通过同步操作建立 happens-before 关系

例如,线程 A 初始化普通变量 data,然后通过 volatile 标志通知线程 B:

线程 A:

data = 42 → ready = true

线程 B:

读取 ready == true → 读取 data

其中,ready 声明为 volatile。

  1. 通过传递性保证数据可见

整个过程是:

线程 A 写入 data → 写入 volatile ready → 线程 B 读取到 ready == true → 读取 data

由于程序顺序规则、volatile 同步规则以及 happens-before 的传递性,线程 B 在读取到 ready == true 后,能够看到发布前写入的 data = 42。

这里假设没有其他线程对 data 进行无同步的并发修改。

  1. 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 关系,以及涉及的复合操作是否具备原子性。