Jakob Jenkov 2020-07-01
Java 内存模型(Java Memory Model,JMM)规定了 Java 虚拟机(JVM)如何与计算机的内存(RAM)进行交互。由于 JVM 本质上是对整台计算机的抽象建模,因此它自然也包含一个内存模型——即 Java 内存模型。
如果你希望编写行为正确的并发程序,理解 Java 内存模型就显得尤为重要。该模型定义了不同线程在何时、以何种方式能够看到其他线程对共享变量所写入的值,以及在必要时如何同步对共享变量的访问。
最初的 Java 内存模型存在不足之处,因此在 Java 1.5 中对其进行了修订。当前(截至 Java 14+)仍在使用的就是这个修订后的版本。
JVM 内部的 Java 内存模型
JVM 内部使用的 Java 内存模型将内存划分为线程栈(thread stacks)和堆(heap)。下图从逻辑角度展示了 Java 内存模型的结构:

每个在 Java 虚拟机中运行的线程都有自己的线程栈。线程栈中包含了该线程为达到当前执行点而调用的方法信息,我们通常称之为“调用栈”(call stack)。随着线程执行代码,调用栈会不断变化。
线程栈还包含当前正在执行的每个方法中的所有局部变量。一个线程只能访问自己的线程栈。由某个线程创建的局部变量对其他线程是不可见的。即使两个线程执行完全相同的代码,它们也会在各自的线程栈中分别创建该代码的局部变量副本。因此,每个线程都拥有自己独立的局部变量版本。
所有基本类型(boolean、byte、short、char、int、long、float、double)的局部变量都完全存储在线程栈中,因此对其他线程不可见。一个线程可以将基本类型变量的副本传递给另一个线程,但无法直接共享该局部变量本身。
堆(heap)则包含 Java 应用程序中创建的所有对象,无论这些对象是由哪个线程创建的。这包括基本类型的包装类对象(如 Byte、Integer、Long 等)。无论是将对象赋值给局部变量,还是作为另一个对象的成员变量,该对象本身始终存储在堆上。
下图展示了调用栈和局部变量存储在线程栈中,而对象存储在堆上的情况:

- 局部变量可能是基本类型,此时它完全保存在线程栈中。
- 局部变量也可能是一个对象引用。在这种情况下,引用本身(即局部变量)存储在线程栈中,而对象本身则存储在堆上。
- 对象可能包含方法,而这些方法又可能包含局部变量。这些局部变量同样存储在线程栈中,即使所属的对象位于堆上。
- 对象的成员变量(无论是基本类型还是对象引用)都与对象本身一起存储在堆上。
- 静态类变量也与类定义一起存储在堆上。
堆上的对象可以被所有持有该对象引用的线程访问。当一个线程可以访问某个对象时,它也可以访问该对象的成员变量。如果两个线程同时调用同一个对象的方法,它们都能访问该对象的成员变量,但各自拥有方法中局部变量的独立副本。
下图进一步说明了上述要点:

- 两个线程各自拥有一组局部变量。
- 其中一个局部变量(
Local Variable 2)指向堆上的一个共享对象(Object 3)。两个线程各自持有一个指向同一对象的不同引用,这些引用作为局部变量存储在各自的线程栈中。 - 注意,共享对象(Object 3)的成员变量又分别指向 Object 2 和 Object 4。通过这些成员变量引用,两个线程也能间接访问 Object 2 和 Object 4。
- 图中还展示了一个局部变量分别指向堆上两个不同对象的情况(Object 1 和 Object 5)。理论上,如果两个线程都持有这两个对象的引用,它们都可以访问这两个对象;但在图示场景中,每个线程只持有一个对象的引用。
那么,什么样的 Java 代码会导致上述内存布局呢?其实非常简单,例如下面的代码:
public class MyRunnable implements Runnable {
public void run() {
methodOne();
}
public void methodOne() {
int localVariable1 = 45;
MySharedObject localVariable2 = MySharedObject.sharedInstance;
// ... 对局部变量做更多操作
methodTwo();
}
public void methodTwo() {
Integer localVariable1 = new Integer(99);
// ... 对局部变量做更多操作
}
}
public class MySharedObject {
// 静态变量,指向 MySharedObject 的一个实例
public static final MySharedObject sharedInstance = new MySharedObject();
// 成员变量,指向堆上的两个 Integer 对象
public Integer object2 = new Integer(22);
public Integer object4 = new Integer(44);
public long member1 = 12345;
public long member2 = 67890;
}
如果有两个线程执行 run() 方法,就会产生前面图示的内存结构:
run()调用methodOne(),methodOne()又调用methodTwo()。methodOne()声明了一个基本类型局部变量localVariable1(int类型)和一个对象引用局部变量localVariable2。- 每个执行
methodOne()的线程都会在自己的线程栈中创建localVariable1和localVariable2的独立副本。localVariable1完全隔离,一个线程对它的修改对另一个线程不可见。- 两个线程的
localVariable2副本都指向同一个MySharedObject实例(即sharedInstance静态变量所指向的对象),该实例存储在堆上(对应图中的 Object 3)。
MySharedObject类中的两个Integer成员变量(object2和object4)分别指向堆上的两个Integer对象(对应图中的 Object 2 和 Object 4)。methodTwo()中创建的localVariable1是一个指向新Integer实例的引用。每次调用methodTwo()都会创建一个新的Integer对象,因此两个线程会各自创建独立的Integer实例(对应图中的 Object 1 和 Object 5)。- 注意
MySharedObject中的两个long类型成员变量(member1和member2)虽然是基本类型,但由于是成员变量,它们仍然与对象一起存储在堆上。只有局部变量才存储在线程栈中。
硬件内存架构
现代硬件的内存架构与 JVM 内部的 Java 内存模型有所不同。为了理解 Java 内存模型如何与底层硬件协同工作,了解硬件内存架构也很重要。
下图是现代计算机硬件架构的简化示意图:

- 现代计算机通常包含两个或更多 CPU,有些 CPU 还具有多个核心。这意味着在多线程 Java 应用中,多个线程可以真正并行运行(每个 CPU 或核心运行一个线程)。
- 每个 CPU 都包含一组寄存器(registers),这是 CPU 内部的高速存储单元。CPU 对寄存器的操作速度远快于对主内存(RAM)的操作。
- 每个 CPU 通常还有一层或多层缓存(cache)内存。CPU 访问缓存的速度比访问主内存快得多(但通常仍慢于访问寄存器)。
- 计算机还有一个主内存区域(RAM),所有 CPU 都可以访问它,且其容量通常远大于 CPU 缓存。
通常,当 CPU 需要访问主内存中的数据时,会先将部分主内存数据读入其缓存,甚至进一步加载到寄存器中进行运算。当需要将结果写回主内存时,CPU 会先将数据从寄存器写入缓存,再在适当的时候将缓存中的数据“刷新”(flush)回主内存。
缓存中的数据通常以“缓存行”(cache lines)为单位进行读写和刷新,并不需要每次操作整个缓存。
桥接 Java 内存模型与硬件内存架构
如前所述,Java 内存模型和硬件内存架构存在差异。在硬件层面,并没有“线程栈”和“堆”的区分——线程栈和堆在物理上都位于主内存中。不过,它们的部分内容可能会被加载到 CPU 缓存甚至寄存器中。如下图所示:

当对象和变量可能分布在主内存、缓存和寄存器等多个存储层级时,就会出现两个主要问题:
- 共享变量更新的可见性问题(Visibility of thread updates)
- 竞态条件(Race conditions)
下面将分别解释这两个问题。
共享对象的可见性问题
如果多个线程共享一个对象,且未正确使用 volatile 关键字或同步机制(synchronization),那么一个线程对该共享对象的修改可能对其他线程不可见。
假设共享对象最初位于主内存中。运行在 CPU 1 上的线程将其读入自己的 CPU 缓存,并在缓存中修改了对象的某个字段(例如 count 变量)。只要该 CPU 缓存尚未将修改刷新回主内存,运行在其他 CPU 上的线程就看不到这个更新。这样,每个线程实际上可能持有共享对象的不同副本(各自位于不同的 CPU 缓存中)。
下图展示了这种情况:

左侧 CPU 上的线程将共享对象读入缓存,并将
count修改为 2。由于该修改尚未刷新回主内存,右侧 CPU 上的线程仍然看到的是旧值。
解决方案:使用 Java 的 volatile 关键字。volatile 可以确保对该变量的读取总是直接从主内存进行,且每次更新后都会立即写回主内存。
竞态条件(Race Conditions)
如果多个线程共享一个对象,且不止一个线程会更新该对象中的变量,就可能发生竞态条件。
假设线程 A 和线程 B 分别将共享对象的 count 变量读入各自 CPU 的缓存。然后两个线程都对 count 执行加 1 操作。如果这两个操作是串行执行的,最终 count 的值应为原始值 + 2。
然而,如果这两个操作并发执行且没有同步机制,无论哪个线程先将结果写回主内存,最终主内存中的 count 值都只会是原始值 + 1,因为两个线程都是基于同一个原始值进行计算的。
下图展示了这一问题:

解决方案:使用 Java 的 synchronized 块。synchronized 块确保同一时间只有一个线程可以进入代码的临界区(critical section)。此外,它还保证:
- 进入
synchronized块时,所有相关变量都会从主内存重新读取; - 退出
synchronized块时,所有修改过的变量都会刷新回主内存(无论是否声明为volatile)。