从源到流:详解Java的内存模型
在单核处理器时代,线程对共享变量的访问顺序是确定的,因为所有操作在同一个处理器上串行执行。多核处理器的普及带来了真正的并行计算能力,但也引入了一个棘手的问题:多个CPU核心各自拥有独立的缓存,当它们同时访问同一块内存地址时,缓存内容可能不一致。更复杂的是,为了提升执行效率,编译器和处理器会对指令序列进行重排序,使得最终执行的指令顺序与源代码顺序不同。
一、现代计算机的内存层次结构
在理解JMM之前,必须了解硬件层面的内存模型。现代多核处理器普遍采用多级缓存结构,以提高CPU与内存之间的数据交换速度。
1. CPU缓存的工作原理
CPU的运算速度远超主内存(RAM)的访问速度,当CPU需要读取一个变量时,如果每次都直接访问主内存,数百个时钟周期会被浪费在等待数据上。因此,芯片设计者在CPU内部集成了容量较小但速度极快的缓存(Cache)。常见的层次包括:每个核心私有的L1缓存(通常32KB-64KB,延迟约4个时钟周期),稍大的L2缓存(256KB-512KB,延迟约10个时钟周期),以及多个核心共享的L3缓存(8MB-32MB,延迟约30-50个时钟周期)。主内存的延迟则在200个时钟周期以上。
当CPU执行int a = b;时,它会先检查L1缓存中是否有b的值。如果命中,直接返回;如果未命中,则检查L2缓存,再未命中则检查L3缓存,最后才从主内存加载。加载后的值会逐级存入缓存,以便后续快速访问。写入操作类似:修改通常先写入缓存,并在某个时机(如缓存行被替换或执行刷新指令)写回主内存。
2. 多核带来的缓存不一致问题
假设一个双核处理器系统,主内存中变量x的初始值为0。核心A执行x = 1,修改了它的L1缓存中的x值,但尚未写回主内存。此时核心B执行int y = x,由于x在核心B的缓存中仍然是0(或者未缓存而从主内存读到0),核心B得到的y为0。这就产生了数据不一致——不同核心看到的变量值不同。
为了解决这个问题,硬件工程师设计了缓存一致性协议(Cache Coherence Protocol)。最著名的是MESI协议及其衍生版本(MESIF、MOESI)。MESI协议将每个缓存行标记为四种状态之一:Modified(已修改,当前核心独享且已修改,与主内存不一致)、Exclusive(独占,当前核心独享,与主内存一致)、Shared(共享,多个核心拥有此缓存行,与主内存一致)、Invalid(无效,表示该缓存行不可用)。当一个核心修改处于Shared状态的缓存行时,协议会向其他核心发送“失效”消息,将它们的对应缓存行状态转为Invalid。这样,后续其他核心读取时必须重新从主内存加载,从而保证了可见性。
然而,缓存一致性协议并非全时运作。广播失效消息需要时间,而且对于频繁修改的变量,每次都进行同步会严重降低性能。因此,很多处理器采用“宽松一致性”模型,允许在一定条件下暂时不通知其他核心。这解释了为什么即使有硬件协议,多线程程序仍然需要软件层面的同步——硬件协议只保证最终一致性,而不保证在特定顺序下的实时可见性。
3. 指令重排序
为了提升执行效率,编译器(静态编译期)和处理器(运行时)都可能对指令序列进行重排序。考虑以下代码片段:
a = 1; // 语句1 b = a + 2; // 语句2 c = 3; // 语句3
编译器可能将语句3移动到语句1之前,因为c = 3与a、b没有数据依赖关系(即不会改变语句2的结果)。这种重排序在单线程下完全合法,因为最终a、b、c的值与原始顺序执行一致。但在多线程环境中,重排序可能破坏并发逻辑。
考虑两个线程共享变量ready和data:
// 线程A
data = 42;
ready = true;
// 线程B
while (!ready) { }
System.out.println(data);
如果处理器将ready = true重排序到data = 42之前,线程B可能在data尚未写入时看到ready为true,从而输出0(默认值)而不是42。这种错误很难复现且难以调试,因为重排序取决于具体的处理器微架构和运行时负载。
二、Java内存模型的抽象定义
JMM建立在硬件模型之上,但屏蔽了不同处理器架构的差异。它定义了线程之间通过主内存进行通信的抽象规则。
1. 主内存与工作内存
JMM将内存划分为两个逻辑区域。主内存(Main Memory)是所有线程共享的存储区域,存放所有实例字段、静态字段和数组元素。工作内存(Working Memory)是每个线程私有的缓存抽象,包含了该线程使用到的变量的主内存副本的拷贝。工作内存对应了硬件层面的寄存器、CPU缓存以及编译器的优化空间。
线程对变量的所有操作(读取、赋值、运算)都必须在工作内存中进行,不能直接读写主内存。不同线程之间无法直接访问对方的工作内存,数据传递的唯一路径是:线程A将工作内存中的修改通过store和write同步到主内存,线程B从主内存通过read和load将最新值拉入自己的工作内存。
2. 内存交互的8种原子操作
JMM定义了8种原子操作来实现上述流程。这些操作在Java虚拟机实现中是不可再分的最小单位。
lock:作用于主内存变量,将变量标识为某个线程独占状态。 unlock:作用于主内存变量,释放被lock占用的变量。 read:从主内存读取变量的值,将其传输到线程的工作内存。 load:将read操作得到的值放入工作内存的变量副本中。 use:将工作内存的变量值传递给执行引擎(即实际使用该值进行计算)。 assign:将执行引擎的计算结果赋值给工作内存的变量。 store:将工作内存变量的值传输到主内存。 write:将store操作得到的值写入主内存的变量中。
这些操作必须遵循如下规则:read和load必须成对出现,且read之后必须紧跟load;store和write必须成对出现,且store之后必须紧跟write;不允许一个线程丢弃其最近的assign操作(即工作内存修改后必须同步回主内存);不允许一个线程在未assign的情况下将数据同步回主内存。此外,lock操作会使工作内存中对应变量的值失效,因此重新使用时必须从主内存load。
3. 64位变量的特殊处理
对于long和double类型的64位变量,JVM允许将read、load、store、write操作分为两个32位的操作执行。这意味着在多线程环境下,一个线程可能读到另一个线程改写long变量时的一半新值和一半旧值,这种现象被称为“字撕裂”(Word Tearing)。虽然大多数现代JVM在64位平台上会自动保证64位操作的原子性,但JMM规范明确要求将volatile修饰的long/double视为完全原子操作。因此,如果我们担心字撕裂问题,要么使用volatile,要么使用synchronized或原子类(如AtomicLong)。
三、JMM保证的三个语义特性
原子性(Atomicity)、可见性(Visibility)和有序性(Ordering)是JMM的三大支柱。
原子性:一个或多个操作在执行期间不被中断。在JMM中,对基本数据类型(除了非volatile的long/double)的读写操作天然具有原子性。更复杂的操作如count++(读取-修改-写入)不具备原子性,因为读取和写入之间可能被其他线程插入。要实现复合操作的原子性,必须使用synchronized、Lock或java.util.concurrent.atomic包中的原子类。例如:
// 非原子操作,结果可能小于预期
public class Counter {
private int count = 0;
public void increment() { count++; }
}
// 使用synchronized保证原子性
public class SafeCounter {
private int count = 0;
public synchronized void increment() { count++; }
public synchronized int get() { return count; }
}
可见性:当一个线程修改了共享变量,其他线程能够立即看到这个修改。JMM通过工作内存与主内存的同步规则来保证可见性:对普通变量的修改,没有强制刷新到主内存的时机,因此可见性无法保证。而volatile关键字强制规定:对volatile变量的写操作必须在store+write后立即刷新到主内存,读操作必须从主内存read+load,禁止使用工作内存中的旧副本。synchronized和Lock也提供可见性——释放锁前会将所有工作内存修改刷新到主内存,获取锁时会强制从主内存加载。final字段的特殊规则是:一旦在构造函数中初始化完成(且对象引用未逃逸),所有线程都能看到final字段的最终值,无需同步。
下面的例子展示了不使用volatile时可见性缺失导致无限循环:
public class VisibilityDemo {
private static boolean running = true; // 非volatile
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
int x = 0;
while (running) { // 可能永远看不到running变为false
x++;
}
System.out.println("Thread finished");
});
t.start();
Thread.sleep(1000);
running = false; // 主线程修改
System.out.println("Set running to false");
}
}
将running声明为volatile后,子线程会立即退出循环。
有序性:在本线程内观察,所有操作表现为有序(as-if-serial语义);但其他线程观察本线程,看到的操作顺序可能无序。JMM通过happens-before规则来约束重排序,使得程序员能够推断并发代码的正确性。
四、Happens-Before规则
happens-before关系是JMM的核心抽象。如果两个操作之间存在happens-before关系,那么第一个操作的结果对第二个操作可见,并且第一个操作的执行顺序在第二个操作之前(不会被重排序打乱)。以下是关键的happens-before规则(以表格形式列出,便于查阅):
| 规则名称 | 具体描述 | 示例说明 |
|---|---|---|
| 程序次序规则 | 同一个线程内,书写在前面的操作happens-before书写在后面的操作 | a = 1; happens-before b = a; |
| volatile变量规则 | 对volatile变量的写操作happens-before后续对该变量的读操作 | 线程1写volatile x=1,线程2读到x=1时,线程1在写之前的所有操作对线程2可见 |
| 锁规则 | 对锁的解锁happens-before后续对该锁的加锁 | synchronized块的结束happens-before下一个线程进入该块 |
| 传递性 | 若A happens-before B,B happens-before C,则A happens-before C | 结合其他规则推断 |
| 线程启动规则 | 主线程调用Thread.start() happens-before新线程中的任何操作 | 子线程内能看见主线程在start()之前的所有修改 |
| 线程中断规则 | 对线程的interrupt()调用happens-before被中断线程检测到中断 | 通过Thread.interrupted()或InterruptedException可见 |
| 线程终止规则 | 线程中的所有操作happens-before其他线程通过Thread.join()或Thread.isAlive()检测到该线程终止 | 线程结束后的状态对join返回的线程可见 |
| 对象终结规则 | 对象的构造函数执行结束happens-before它的finalize()方法开始 | finalize方法能看见正确构造的final字段 |
这些规则允许我们在不使用volatile或synchronized的情况下,也能推断某些场景的可见性。例如,在线程A中调用threadB.start()之前修改的变量,在线程B中必然是可见的——因为start()规则建立了happens-before关系。
五、Volatile的深入剖析
volatile在很多资料中被简单描述为“保证可见性,不保证原子性”,但它的实际语义要丰富得多。除了可见性,volatile还通过内存屏障(Memory Barrier)禁止特定类型的重排序。
内存屏障的作用:内存屏障是一种特殊的CPU指令,它强制屏障前的所有读/写操作必须先于屏障后的操作执行完毕。JMM在不同的位置插入四种屏障:LoadLoad屏障(禁止屏障前后的两个读操作重排序)、StoreStore屏障、LoadStore屏障和StoreLoad屏障(最强屏障,同时禁止写后读重排序)。
对于一个volatile写操作,JMM插入的屏障序列如下:在写操作之前插入StoreStore屏障,确保在此之前的所有普通写操作已经对其他处理器可见;在写操作之后插入StoreLoad屏障,这个屏障开销最大,它会强制将写缓冲区的内容刷新到主内存,并使得屏障前的所有读/写操作的结果对屏障后的操作立即可见。
对于一个volatile读操作,JMM在读之后插入LoadLoad屏障(禁止后续普通读重排序到volatile读之前)和LoadStore屏障(禁止后续普通写重排序到volatile读之前)。
经典应用:双重检查锁定(Double-Checked Locking)
实现线程安全的单例模式时,双重检查锁定是一种常见的优化手段,但如果没有volatile,它将是错误的。
public class Singleton {
private static volatile Singleton instance; // volatile必不可少
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查(无锁)
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查(有锁)
instance = new Singleton();
}
}
}
return instance;
}
}
instance = new Singleton();这行代码并非原子操作,它包含三个步骤:
-
分配内存空间(对象被分配堆内存,字段为默认值)。
-
调用构造函数初始化对象的字段。
-
将
instance引用指向该内存空间。
步骤2和3在某些编译器或处理器上可能被重排序(先执行步骤3,再执行步骤2)。如果没有volatile,线程A进入临界区执行了步骤1和步骤3,此时instance非空,但对象尚未完成初始化(字段仍为默认值)。线程B在第一次检查时看到instance != null,直接返回该半初始化对象,使用时就可能抛出异常或得到错误数据。volatile通过插入StoreStore屏障,禁止步骤3重排序到步骤2之前,从而保证了构造过程的完整性。
需要强调的是,volatile不能代替synchronized用于复合操作。例如volatile int count; count++仍然不是原子的,因为三个步骤之间可能被中断。
六、Synchronized的内存语义
synchronized在JMM中对应锁规则。当线程进入一个synchronized块(或方法)时,它会:清空工作内存中的所有变量副本(相当于强制从主内存重新加载);当线程退出synchronized块时,会将工作内存中的所有修改刷新到主内存。
这两个操作综合起来的效果是:线程A在释放锁之前的所有修改,在线程B获得同一个锁之后,立即变得可见。因此,synchronized不仅保证了临界区代码的原子执行,还自动提供了可见性和有序性。
下面的代码展示了如何使用synchronized实现线程安全的共享计数器:
public class SynchronizedCounter {
private long value = 0;
public synchronized long get() {
return value;
}
public synchronized void increment() {
value++;
}
}
如果没有synchronized,get()方法可能返回一个过期的值(因为value可能一直在工作内存中,主内存未更新)。加上synchronized后,get()的锁迫使线程清空工作内存,从而读取到最新值。
值得注意的是,synchronized的重排序限制是保守的:它只保证临界区内的代码在相对于锁的获取和释放时有序,但对临界区内部的代码仍然允许编译器和处理器进行重排序,只要不影响单线程语义。
七、Final字段的初始化安全
final字段在JMM中拥有特殊的初始化安全性保证,这旨在简化不可变对象的线程安全发布。所谓初始化安全,指的是:如果一个对象被正确构造(即构造函数内部的this引用没有逸出),那么任何线程在获得该对象的引用后,都能保证看到该对象所有final字段的正确初始化值,而无需任何同步手段。
例如:
public class ImmutablePoint {
private final int x;
private final int y;
public ImmutablePoint(int x, int y) {
this.x = x;
this.y = y;
}
// 只有getter,没有setter
}
线程A创建ImmutablePoint p = new ImmutablePoint(3,4);,然后将p的引用通过某个线程安全的方式发布(例如放入concurrentHashMap或使用volatile引用)。线程B获取到p后,调用p.x和p.y保证能看到3和4,而不是默认值0。这一保证依赖于JMM在final字段写操作之后、构造函数返回之前插入的一个StoreStore屏障,该屏障阻止将final字段的初始化重排序到构造函数之外。
然而,如果对象引用在构造函数完成之前就暴露给其他线程(比如在构造函数内将this赋值给一个静态变量,或者启动一个内部线程),初始化安全就不再成立。即使对象有final字段,其他线程也可能看到默认值。因此,不可变对象的安全发布仍然需要程序员主动使用volatile、锁或静态初始化器。
八、综合示例:通过JMM推导正确性
假设我们有如下代码段,初始时a = 0, b = 0,flag是volatile变量。
// 线程1
a = 1;
flag = true; // volatile写
// 线程2
if (flag) { // volatile读
b = a;
}
问:线程2中b的值会是多少?根据volatile变量规则,线程1的flag = true happens-before线程2的读flag(如果线程2读到了true)。再根据程序次序规则,a = 1 happens-before flag = true。结合传递性,a = 1 happens-before线程2中的b = a。因此,线程2看到的a值必为1。即使没有显式的同步,volatile也保证了这种传递式的可见性。
再考虑一个不使用volatile的版本:将flag改为普通boolean。此时没有happens-before关系,线程2即使在if (flag)中看到了true,仍然可能读到a的旧值0,因为重排序可能将a=1和flag=true的顺序调换,或者线程1的缓存刷新不及时。
九、JMM与底层实现的映射
JMM规范通过JVM的内存屏障生成策略映射到具体的硬件指令。不同处理器架构提供的内存屏障指令不同。在x86平台(强内存模型,普通写操作不会重排序到读操作之前),实现volatile写只需要一个lock addl $0x0, (%rsp)或mfence指令来完成StoreLoad屏障。在ARM/PowerPC等弱内存模型平台上,可能需要插入dmb(数据内存屏障)指令,开销更大。JVM根据平台自动选择合适的指令,这正是Java跨平台特性的体现。
十、总结
Java内存模型是一套精确的抽象规范,它定义了在多线程环境下,共享变量的访问行为如何与底层硬件交互。理解JMM的核心在于记住三点:普通变量缺乏可见性保证,volatile提供了可见性并禁止重排序,synchronized提供了原子性+可见性+有序性三者的整合。
在实际编写并发代码时,遵循以下原则可以避免大部分内存模型相关的问题:尽量使用java.util.concurrent包中的高级工具(ConcurrentHashMap、BlockingQueue、AtomicInteger等),避免手写同步逻辑;必须使用低层同步时,优先考虑volatile(如果只需要可见性)或synchronized(如果需要复合操作);使用final创建不可变对象,并确保安全发布(例如通过静态工厂方法+volatile引用);永远不要假设重排序不会发生,即使在x86平台上,编译器重排序仍然存在。
JMM在Java 5(JSR-133)之后得到了大幅强化,特别是volatile和final的语义被明确定义,因此在JDK 5以上的版本中,上述所有规则都严格适用。理解JMM并非为了写出花哨的并发技巧,而是为了在面对并发错误时,能够基于理性推导而非猜测解决问题。掌握了内存模型,就掌握了并发编程的“道”,剩下的只是工具的选择而已。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)