引言:一个永不停止的循环
作为一名Java开发者,你是否曾遇到过如下诡异的情景?这段代码在你的IDE里运行一段时间就会停止,但在生产环境的服务器上却可能永远停不下来。
java
public class NeverStop {
private static /*volatile*/ boolean stop = false; // 注意:这里没有volatile
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (!stop) { // 子线程无法"看到"主线程修改后的值
// 疯狂循环
}
System.out.println("Thread stopped.");
}).start();
Thread.sleep(1000);
stop = true; // 主线程修改共享变量
System.out.println("Main thread set stop to true.");
}
}
问题根源 并非Java的BUG,而是源于现代计算机的硬件架构。为了充分压榨CPU性能,计算机会采用多级缓存、指令乱序执行等优化策略。这导致了同一个变量在多个CPU核心的"视角"下可能拥有不同的值。
Java内存模型(Java Memory Model, JMM) 就是一个为了屏蔽各种硬件和操作系统的内存访问差异,让Java程序在各种平台下都能达到一致的内存访问效果而提出的抽象规范。它定义了线程如何与主内存进行交互,是理解Java并发编程的基石。
本文将围绕JMM,从基础概念到高级特性,并结合实战案例,带你彻底攻克并发编程中的可见性、原子性和有序性问题。
一、核心概念:理解JMM的抽象模型
1.1 主内存与工作内存
JMM的主要目标是定义程序中各个变量的访问规则。它规定了所有变量都存储在主内存中。
- 主内存:存储所有共享变量。是线程共享的区域。
- 工作内存 :每个线程都有自己的工作内存,其中保存了该线程使用到的变量的主内存副本拷贝。线程对变量的所有操作(读、写)都必须在工作内存中进行,而不能直接读写主内存中的变量。
它们之间的交互关系,可以用下图清晰地表示:
(JMM内存交互模型)
交互协议:JMM定义了8种原子操作来完成主内存与工作内存的交互:
操作名称 | 作用范围 | 功能描述 |
---|---|---|
lock (锁定) | 主内存变量 | 将变量标识为线程独占状态 |
unlock (解锁) | 主内存变量 | 释放变量的锁定状态 |
read (读取) | 主内存变量 | 将变量值从主内存传输到工作内存 |
load (载入) | 工作内存变量 | 将read得到的值放入工作内存的变量副本中 |
use (使用) | 工作内存变量 | 将工作内存变量值传递给执行引擎 |
assign (赋值) | 工作内存变量 | 从执行引擎接收值,赋给工作内存变量 |
store (存储) | 工作内存变量 | 将工作内存变量值传输到主内存 |
write (写入) | 主内存变量 | 将store传输的值放入主内存变量中 |
JMM规定了这些操作的执行顺序和规则:
- 不允许read和load、store和write操作单独出现
- 不允许一个线程丢弃它最近的assign操作(变量在工作内存中改变了之后必须把该变化同步回主内存)
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存
- 一个新的变量只能在主内存中"诞生",不允许在工作内存中直接使用一个未被初始化(load或assign)的变量
- 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值
- 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store和write操作)
注意 :read
和load
、store
和write
必须成对出现,但不必连续。中间可以插入其他指令,这就为重排序提供了可能。
1.2 内存屏障
在深入JMM的解决方案之前,必须了解一个底层概念------内存屏障(Memory Barrier) ,也称内存栅栏(Memory Fence)。它是理解volatile
和锁
如何工作的基石。
为什么需要内存屏障? 编译器和CPU为了优化性能会进行指令重排序 。这在单线程下无懈可击,但在多线程下会导致灾难。内存屏障就像是一个"刹车",告诉编译器和CPU:在此处,某些类型的重排序必须停止!
内存屏障主要分为四种类型,几乎所有的并发同步机制都间接或直接地使用了它们:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load1; LoadLoad; Load2 |
确保Load1 的数据装载先于Load2 及其后所有装载指令。 |
StoreStore | Store1; StoreStore; Store2 |
确保Store1 的数据刷新到主内存先于Store2 及其后所有存储指令。 |
LoadStore | Load1; LoadStore; Store2 |
确保Load1 的数据装载先于Store2 及其后所有存储指令。 |
StoreLoad | Store1; StoreLoad; Load2 |
确保Store1 的数据刷新到主内存先于Load2 及其后所有装载指令。这是一个"全能型"屏障,开销最大。 |
JMM的作用 :它通过在不同的字节码指令(如monitorenter/monitorexit
、volatile
读写)前后插入特定类型的内存屏障,来禁止特定类型的重排序,从而为开发者提供清晰的内存可见性保证。
1.3 缓存一致性协议(MESI)
JMM 是一个抽象模型,而它的很多设计灵感与约束,都来自于底层的硬件机制,其中最重要的就是缓存一致性协议。
为什么需要缓存一致性?
现代CPU的多级缓存架构极大地提升了性能,但也导致了"数据副本"不一致的问题。为了解决这个问题,硬件设计者提出了一系列缓存一致性协议,其中最著名的是 MESI 协议。
MESI 协议工作原理
MESI 是四种缓存行(Cache Line)状态的缩写,任何时刻一个CPU核心中的缓存行都处于以下状态之一:
- M (Modified - 修改) :缓存行是脏的(Dirty),即数据已被当前CPU核心修改,与主内存中的数据不一致。此缓存行拥有该数据的"独家所有权"。
- E (Exclusive - 独占) :缓存行是干净的(Clean),数据与主内存一致,且只有当前CPU核心拥有这份副本。
- S (Shared - 共享) :缓存行是干净的,数据与主内存一致,但可能存在于多个其他CPU核心的缓存中。
- I (Invalid - 无效) :缓存行中的数据是无效的,不能使用。下次访问时必须从主内存或其他CPU缓存中重新加载。
CPU核心之间通过一个复杂的消息传递机制来维护这些状态,从而保证所有缓存中的数据副本最终保持一致。其交互过程可以通过以下状态机来表示:
缓存行不存在 I --> S: CPU发起读请求
其他缓存有或无(S状态) I --> M: CPU发起写请求
缓存行不存在 E --> M: 本地CPU写 E --> S: 其他CPU读请求
状态变为共享(S) M --> S: 其他CPU读请求
先写回内存再共享 M --> I: 其他CPU写请求
先写回内存再失效 S --> I: 其他CPU写请求
收到失效信号 S --> M: 本地CPU写
发送失效信号给其他缓存 note right of I 初始或已被丢弃 必须从主内存读取 end note
工作流程举例(简化的读/写交互):
会议室白板比喻:我们可以把主内存 比作公司服务器上的共享云文档 ,把每个CPU的缓存 比作每个员工工位上的个人白板。
MESI协议的工作流程,就像一套员工之间协作更新白板和云文档的默契规则:
写回云文档 (Write-Back) Note right of CPU_A: 状态降级为共享-S Main Memory -->> CPU_B: 返回最新的 X=1 Note left of CPU_B: 我的白板: X=1 (状态:共享-S) deactivate CPU_B
第一步:独占读取 (Read for Exclusive)
- CPU-A 需要读数据X。它发现自己的缓存里没有(I 状态),于是向总线发起
读
请求。 - 它从主内存 读到值
0
,并记录在自己的缓存中。此时其他核心都没这个数据,所以CPU-A独占 这份数据,状态变为E。
第二步:共享读取 (Read for Share)
- CPU-B 也需要读X。它发起
读
请求。 - CPU-A 的缓存控制器嗅探到了这个请求,将其缓存行状态从E改为S,并允许数据被共享。
- CPU-B 从主内存(或通过缓存间传输)读取数据,并标记为S状态。
第三步:写入修改 (Write to Modified)
- CPU-A 现在要修改X,将其设为
1
。 - 由于状态是S (共享),它不能直接修改,必须先获得独占权。
- CPU-A 向总线发送一个
无效化
消息,告诉所有其他缓存了X的副本(CPU-B):"我要改了,你们的副本马上作废!" - CPU-B 收到消息后,将自己的缓存行状态置为I(无效),并回复一个确认消息。
- CPU-A 收到所有确认后,才能放心地在自己的缓存中修改X=
1
,并将状态升级为M (修改)。此时,数据只存在于CPU-A的缓存中,且与主内存不一致。
第四步:同步与再次共享
- CPU-B 后来也需要读X。它发现自己的副本是I (无效),于是发起
读
请求。 - CPU-A 的缓存控制器嗅探 到了这个请求。它知道自己持有最新的脏数据(状态M ),于是拦住这个发向主内存的请求。
- CPU-A 做两件事:1) 将自己缓存中的最新值(X=
1
)写回主内存;2) 将数据传给CPU-B。 - 之后,CPU-A 和 CPU-B 的缓存行状态都变为S(共享),数据再次保持一致。
现在我们可以看到,MESI 协议已经在硬件层面解决了缓存一致性 问题,那为什么还需要 JMM 和 volatile
呢?
原因在于 性能。CPU核心间发送"无效化"消息和等待响应是需要时间的(CPU 时钟周期)。如果CPU-A 在等待CPU-B 确认"无效化"的这段时间里什么都做不了,那将是对计算资源的巨大浪费。
为了解决这个性能问题,CPU 设计引入了 Store Buffer 和 Invalidate Queue:
- Store Buffer :CPU 要写入数据时,如果发现缓存行不是E或M状态(即不独占),它会把写入操作放到一个专用的 存储缓冲区(Store Buffer) 中,然后异步地发送"无效化"消息并继续执行后续指令,而不会傻傻地等待响应。这导致了指令重排序。
- Invalidate Queue :其他CPU核心收到"无效化"消息后,并不会立即处理,而是将其放入一个 无效化队列(Invalidate Queue) 中,并立即回复"已收到",然后再择机去真正失效掉自己缓存中的副本。这导致了可见性延迟。
正是这些硬件优化(Store Buffer, Invalidate Queue)的引入,导致了即使有MESI协议,依然会出现可见性和有序性问题。
而 JMM 的作用 ,就是通过在不同程序点(如 volatile
写、锁
释放)插入内存屏障,来告诉CPU:"在执行到这的时候,必须清空Store Buffer,处理Invalidate Queue,保证所有的读写操作都符合MESI协议的规定流程,不允许搞任何小动作(重排序和延迟失效)",从而绕过这些异步优化带来的副作用,为程序员提供确定性的内存可见性保证。
1.4 并发三大问题:原子性、可见性、有序性
1. 原子性(Atomicity)
原子性是指一个或多个操作要么全部执行成功,要么全部不执行,不会被打断。
案例 :i++
操作不是原子性的。它实际上分为三步:
- 从主内存
read
变量i到工作内存。 - 在工作内存执行
i+1
操作(use/assign)。 - 将新值
store/write
回主内存。
如果两个线程同时执行i++
,可能会出现交错执行,导致最终结果小于预期。
java
public class AtomicityDemo {
private int count = 0;
public void increment() {
count++; // 非原子操作
}
}
2. 可见性(Visibility)
可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
案例 :本文开头的例子就是典型的可见性问题。线程1修改了stop
的值,但未能及时刷回主内存,或者线程2未能及时从主内存更新自己的副本,导致线程2无法"看见"这个变化。
3. 有序性(Ordering)
有序性是指程序执行的顺序按照代码的先后顺序执行。但为了性能,编译器和处理器会进行指令重排序。
案例:双重检查锁定(DCL)单例模式。
java
public class Singleton {
private static Singleton instance; // 错误写法,缺少volatile
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // 问题所在!
}
}
}
return instance;
}
}
instance = new Singleton();
这行代码分为三步:
- 分配对象内存空间
- 初始化对象(调用构造函数)
- 将instance引用指向内存地址
步骤2和3可能被重排序 。如果线程A执行完1和3后被挂起,此时instance
已非null但对象未初始化。线程B在第一次检查时看到instance
非null,会直接返回一个未初始化完成的对象,导致程序错误。
解决方案 :使用 volatile
禁止重排序。
java
private static volatile Singleton instance; // 正确写法
二、深入JMM解决方案:Happens-Before与volatile
2.1 Happens-Before原则
Happens-Before是JMM最核心的概念。它是一组规则,用于判断两个操作之间的内存可见性。如果操作A Happens-Before 操作B,那么A所做的任何更改对B都是可见的。
重要原则:
- 程序次序规则:一个线程内,书写在前面的操作Happens-Before后面的操作。
- 监视器锁规则:对一个锁的解锁Happens-Before于后续对这个锁的加锁。
- volatile变量规则:对一个volatile变量的写Happens-Before于任意后续对这个volatile变量的读。
- 传递性:如果A Happens-Before B,且B Happens-Before C,那么A Happens-Before C。
- start()规则 :
Thread.start()
的调用Happens-Before于启动线程中的任何操作。 - join()规则 :线程中的所有操作Happens-Before于其他线程成功从该线程的
join()
中返回。
Happens-Before关系并不等同于时间上的先后,它强调的是内存可见性的保证。
2.2 volatile 关键字:轻量级的同步
volatile
是JMM提供的轻量级同步机制。它通过底层插入内存屏障来实现两大特性:
- 保证可见性 :当一个线程修改了volatile变量,新值会立即被强制刷到主内存。并且其他线程的工作内存中该变量的副本会立即失效,迫使它们必须从主内存重新读取最新值。
- 禁止指令重排序:通过插入内存屏障来禁止编译器和处理器的优化重排序。
其内存屏障策略如下图所示,这直接对应了 volatile变量规则:
- StoreStore屏障:禁止上面的普通写和下面的volatile写重排序。
- StoreLoad屏障:禁止上面的volatile写和下面可能有的volatile读/写重排序。(开销最大)
- LoadLoad屏障:禁止下面的普通读和上面的volatile读重排序。
- LoadStore屏障:禁止下面的普通写和上面的volatile读重排序。
volatile的应用:DCL单例模式
java
public class Singleton {
// 使用volatile关键字防止指令重排序
private static volatile Singleton instance;
// 私有构造函数防止外部实例化
private Singleton() {
// 初始化代码
}
/**
* 获取单例实例的公共方法
* 使用双重检查锁定来减少同步开销
*/
public static Singleton getInstance() {
// 第一次检查(无锁)
if (instance == null) {
// 同步代码块
synchronized (Singleton.class) {
// 第二次检查(有锁)
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
在DCL单例中,volatile
关键字至关重要,因为它解决了以下问题:
-
防止指令重排序 :
instance = new Singleton()
这行代码实际上包含三个步骤:- 分配对象内存空间
- 初始化对象(调用构造函数)
- 将instance引用指向内存地址
如果没有
volatile
,步骤2和3可能被重排序,导致其他线程看到一个非null但未完全初始化的对象。 -
保证可见性:确保一个线程对instance的修改能立即对其他线程可见。
重要提示 :volatile
不保证原子性 。对于复合操作(如count++
),仍需使用synchronized
或AtomicInteger
等原子类。
2.3 synchronized 关键字:全能的同步
synchronized
关键字是 JMM 提供的、功能最强大的同步原语之一。它直接对应着 JMM 中的 lock
和 unlock
操作,能够完整地解决并发三大问题,其底层实现同样依赖于内存屏障。
它如何解决三大问题?
-
原子性 (Atomicity):
synchronized
块或方法确保了一次只有一个线程可以执行被保护的代码段。这使得像i++
这样的复合操作在同步块内成为原子操作。
-
可见性 (Visibility):
- 根据 监视器锁规则:对一个锁的解锁 Happens-Before 于后续每一个对这个锁的加锁。
- 具体机制 :当线程释放锁(执行
monitorexit
指令)时,JMM 会强制将该线程的工作内存中的修改刷新到主内存中。当线程获取锁(执行monitorenter
指令)时,JMM 会使该线程的工作内存中的变量副本失效,从而必须从主内存重新加载共享变量。这个过程包含了内存屏障的使用。
-
有序性 (Ordering):
- 由于
synchronized
保证了同一时刻只有一个线程执行同步代码("串行"执行),因此在这个线程内部,代码是顺序执行的。 - 同时,监视器锁规则也通过内存屏障限制了一部分重排序,保证了临界区内的代码不会"逸出"到临界区之外。
- 由于
代码示例:解决原子性和可见性
java
public class SynchronizedDemo {
private int count = 0;
// synchronized 保证 increment() 方法的原子性和可见性
public synchronized void increment() {
count++; // 原子操作,且修改对所有后续获取此锁的线程可见
}
public synchronized int getCount() {
return count; // 总能读取到最新值
}
}
synchronized 的实现原理
synchronized 通过对象监视器 (Monitor) 实现其语义。每个 Java 对象都可以作为一个监视器,线程获取监视器的所有权后才能执行 synchronized 块中的代码。
当线程进入 synchronized 块时,会执行以下操作:
- 检查对象的锁状态
- 如果对象未被锁定,线程获取锁并进入同步块
- 如果对象已被锁定,线程会被阻塞,直到锁被释放
当线程退出 synchronized 块时,会执行以下操作:
- 释放锁
- 将修改后的变量值刷新到主内存
- 唤醒其他等待该锁的线程
锁的优化
JDK 1.6之后,synchronized 引入了锁升级机制:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。这种优化减少了锁操作的开销,只有在真正竞争激烈时才会升级为重量级锁。
- 偏向锁 (Biased Locking) :当一个线程频繁获取同一把锁时,JVM 会将锁标记为偏向该线程,避免每次获取锁都进行 CAS 操作。
- 轻量级锁 (Lightweight Locking) :当多个线程交替获取同一把锁时,JVM 使用轻量级锁,通过 CAS 操作尝试获取锁,避免线程阻塞。
- 锁粗化 (Lock Coarsening) :如果一段代码中频繁对同一个对象加锁 / 解锁,JVM 会将这些连续的锁操作合并成一次 ------ 在整个代码块开始时加一次锁,结束时解锁一次。
- 锁消除 (Lock Elimination) :JVM 通过逃逸分析发现某个锁对象只会被一个线程访问,会自动消除该锁。
结论 :synchronized
是 JMM 规范的一个直接且核心的实现,它通过锁的获取和释放操作,内置了强大的内存可见性、原子性和有序性保证。
2.4 final 关键字:不可变性的安全承诺
final
关键字的行为在多线程环境下有着特殊的、由 JMM 规范保证的语义。它主要解决的是对象构造过程中的可见性和有序性问题,是一种免同步的解决方案。
它如何属于 JMM 解决方案?
JMM 为 final
域的写入和读取提供了特殊的 "初始化安全" 保证,这主要通过禁止特定重排序来实现。
-
禁止重排序:
- 在构造函数内部,对一个
final
域的写入(初始化),与随后将被构造对象的引用赋值给一个变量(比如instance = new MyClass();
),这两个操作不能被重排序。 - 这意味着,一旦其他线程看到了一个包含
final
域的对象引用,那么这个final
域必然已经被构造函数正确初始化了。这完美解决了 DCL 问题中可能遇到的未初始化问题。
- 在构造函数内部,对一个
-
可见性保证:
- 一个线程构造了一个包含
final
域的对象,只要构造过程没有发生"this引用逸出",那么当其他线程看到这个对象时,该对象的final
字段的值一定是被构造函数写入的值,而不需要额外的同步。
- 一个线程构造了一个包含
代码示例:初始化安全
java
public class FinalFieldExample {
final int x; // final 域,享受JMM的初始化安全保证
int y; // 普通域,不享受
static FinalFieldExample instance;
public FinalFieldExample() {
x = 42; // 1. 写入final域
y = 50; // 2. 写入普通域
}
public static void writer() {
instance = new FinalFieldExample(); // 3. 发布对象
}
public static void reader() {
if (instance != null) {
int a = instance.x; // 保证读到 42 (Thanks to JMM and final)
int b = instance.y; // 可能读到 0 (默认值) 或 50,无法保证
}
}
}
注意:final 字段的"初始化安全"保证有一个重要前提:在构造过程中不能发生"this引用逸出"。即在构造函数中不得将this传递给其他线程,否则仍可能看到未初始化完成的对象。
结论 :final
是 JMM 提供的一种免同步的、隐式的内存可见性与有序性解决方案 ,但它有严格的适用范围------仅限于对象的构造阶段。它是实现安全发布 和不可变对象的关键。
三、实战应用:性能优化与问题排查
3.1 性能优化最佳实践
- 缩小同步范围:尽量使用同步块而不是同步方法,只锁必要的共享资源。
- volatile替代锁 :如果共享变量是独立的(如状态标志位
stop
),且操作本身是原子的,使用volatile
性能远高于synchronized
。 - 使用并发容器 :优先使用
ConcurrentHashMap
、CopyOnWriteArrayList
等,它们使用了更细粒度的锁或无锁技术(如CAS)。 - 使用原子类 :对于计数器等场景,使用
AtomicInteger
、LongAdder
等,它们基于volatile
和CAS,避免了互斥锁。
3.2 并发问题排查指南
问题类型 | 典型现象 | 排查工具与思路 |
---|---|---|
可见性 | 程序行为不稳定,时而正确时而错误,数据不新鲜。 | 使用volatile 或synchronized 来验证猜想。使用jstack 查看线程状态。 |
原子性 | 计数结果总是偏小,数据不一致。 | 检查是否存在i++ 等复合操作。使用原子类或同步块解决。 |
有序性 | 现象极其诡异,如DCL单例拿到未初始化对象。 | 检查是否存在指令重排序的可能。使用volatile 关键字。 |
死锁 | 程序卡住,无响应。 | 使用jstack 或jconsole 查看线程堆栈,检测锁的持有和等待关系。 |
推荐工具:
- jstack:打印线程堆栈信息,分析死锁。
- jconsole / VisualVM:图形化监控线程状态、内存使用等。
- jcstress:Java并发压力测试工具,用于测试并发代码的正确性。
总结
Java内存模型(JMM)是Java并发编程的底层核心,它通过定义主内存、工作内存的交互协议,以及提供synchronized
、volatile
、Happens-Before规则等工具,来解决由于硬件优化带来的可见性 、原子性 和有序性问题。
特性 | volatile |
synchronized |
final (在JMM语境下) |
---|---|---|---|
原子性 | 否 | 是 | 否 |
可见性 | 是(直接保证) | 是(通过锁规则) | 是(仅限于构造阶段) |
有序性 | 是(限制重排序) | 是(限制重排序+串行执行) | 是(禁止初始化重排序) |
机制 | 内存屏障 | 锁 + 内存屏障 | 禁止特定重排序 |
场景 | 状态标志、DCL | 安全的复合操作、临界区 | 安全发布、不可变对象 |