Java Memory Model

Table of Contents

1. 内存模型(Memory Model)背景知识

想深入了解 Java 并发编程,就要先了解 Java 内存模型。

在了解 Java 虚拟机并发编程前,我们先了解一下物理计算机中的并发问题。一般来说,计算机的绝大多数运算任务都不可能只靠“计算”就能完成,处理器往往要与内存交互,比如读取运算数据、存储运算结果等,这些 I/O 操作是很难消除的(无法仅依靠寄存器来完成所有运算任务)。由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都加入了一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。但是,它引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存,如图 1 所示。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢? 为了解决缓存一致性的问题,需要各个处理器访问缓存时都遵循某个协议,在读写时要根据协议来进行操作,这类协议有 MSI, MESI, MOSI, Firefly, Dragon Protocol 等。

jmm_cache_memory_and_coherence.jpg

Figure 1: 处理器、高速缓存、主内存之间的交互关系

本文中将多次提到“内存模型”一词, 可以把内存模型理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。 不同架构的物理机器可以拥有不一样的内存模型,而 Java 虚拟机也有自己的内存模型。

注:本文主要摘自《深入理解 Java 虚拟机——JVM 高级特性与最佳实践(第 2 版),第 12 章 Java 内存模型与线程》

2. Java 内存模型简介

Java 虚拟机规范中定义了 Java 内存模型(Java Memory Model, JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。在此之前,主流程序语言(如 C/C++等)直接使用物理硬件和操作系统的内存模型,因此,会由于不同平台上内存模型的差异,有可能导致程序在一套平台上并发完全正常,而在另外一套平台上并发访问却经常出错,这导致在某些场景下必须针对不同的平台来编写不同的代码。

JMM 屏蔽了不同处理器内存模型的差异,它在不同的处理器平台之上为 Java 程序员呈现了一个一致的内存模型 ,如图 2 所示(摘自http://www.infoq.com/cn/articles/java-memory-model-7)。

jmm_jmm.png

Figure 2: JMM 屏蔽了不同处理器内存模型的差异

定义 Java 内存模型并非一件容易的事情,这个模型必须定义得足够严谨,才能让 Java 并发内存访问操作不会产生歧义;但是,也必须定义得足够宽松,使得虚拟机的实现有足够的自由空间去利用硬件的各种特性来获取更好的执行速度。经过长时间的验证和修补,在 JDK 1.5(实现了 JSR-133)发布后,Java 内存模型才成熟和完善起来。

2.1. 主内存和工作内存

Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中“将变量存储到内存”和“从内存中取出变量”这样的底层细节。此处的变量与 Java 编程中所说的变量有所区别,在这里变量包括了实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,也就不存在竞争问题。

Java 内存模型规定了所有的变量都存储在主内存中(此处的主内存与介绍物理硬件时的主内存名字一样,两者可以互相类比,但此处仅是虚拟机内存的一部分)。每条线程还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类比),线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。 不同的线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如图 3 所示。

jmm_thread_working_memory.jpg

Figure 3: Java 线程、工作内存、主内存三者的交互关系(请与图 1 对比)

2.2. 内存间交互操作

内存间的交互是指:一个变量如何从主内存拷贝到工作内存、以及如何从工作内存同步回主内存之类的实现细节。

Java 内存模型中定义了以下 8 种操作来完成内存间交互操作,虚拟机实现时必须保证下面每一个操作都是原子的、不可再分的(对于 64 位的类型,即 double 和 long 类型的变量来说允许有例外情况,不过商业虚拟机实现一般可保证其原子性):

  1. lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  2. unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  3. read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到工作内存中,以便随后的 load 动作使用。
  4. load(载入):作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
  5. use(使用):作用于工作内存的变量,它把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
  6. assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  7. store(存储):作用于工作内存的变量,它把工作内存中的一个变量的值传递到主内存中,以便随后的 write 操作使用。
  8. write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量值放入主内存的变量中。

如图 4 所示。如果要把一个变量从主内存复制到工作内存,那就是顺序(不一定要连续)地执行 read 和 load 操作;类似地,如果要把变量从工作内存同步回主内存,就要顺序地执行 store 和 write 操作。

jmm_8_instructions.png

Figure 4: Java 虚拟机读写内存指令

Java 内存模型还规定了执行上述 8 种基本操作时必须满足如下规则:

  • read/load、store/write 必须成对出现,也就是不允许从主内存中读取了数据工作内存不接受(不允许只有 read 没有 load),或工作内存数据传输到主内存,主内存不回写(不允许只有 store 没有 write)。
  • 不允许一个线程丢弃它的最近的 assign 操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
  • 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能从主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。
  • 一个变量在同一个时刻只允许一条线程对其执行 lock 操作,但 lock 操作可以被同一个条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。
  • 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。
  • 如果一个变量实现没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定的变量。
  • 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存(执行 store 和 write 操作)。

前面介绍的 8 种内存访问操作以及上述规则限定,再加上稍后介绍的对 volatile 型变量的一些特殊规定,就已经完全确定了 Java 程序中哪些内存访问操作在并发下是安全的。不过,这种定义相当严谨但却十分烦琐,实践起来很麻烦,我们可以使用一个等效的判断原则——“先行发生(happens-before)”原则,来确定一个访问在并发环境下是否安全。

2.3. volatile 型变量

关键字 volatile 可以说是 Java 虚拟机提供的“最轻量级”的同步机制。

当一个变量定义为 volatile 后,它将具备两种特性:第一个特性是保证此变量对所有线程的可见性;第二个特性是禁止“指令重排序优化”。

2.3.1. volatile 可保证变量对其它线程的可见性(但不保证原子性)

当一个变量定义为 volatile 之后,某个线程修改了这个变量的值,其它线程可立即得知,这就是“可见性”。 普通变量(没有用 volatile 修饰)不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成,例如,线程 A 修改一个普通变量的值,然后向主内存进行回写,另外一条线程 B 在线程 A 回写完成了之后再从主内存进行读取操作,新变量值才会对线程 B 可见。

但是,volatile 变量的运算在并发下不一定是安全的。我们通过一个简单的例子来说明原因。

// 这个例子中,volatile变量在并发下是不安全的!
public class VolatileTest {

    public static volatile int race = 0;

    public static void increase() {
        race++;
    }

    private static final int THREAD_COUNT = 20;

    public static void main(String[] args) {
        Thread[] threads = new Thread[THREAD_COUNT];
        for (int i = 0; i < THREAD_COUNT; i++) {
            threads[i] = new Thread(new Runnable() {

                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }

        // 等待所有累加线程都结束
        while (Thread.activeCount() > 1) {
            Thread.yield();
        }

        System.out.println(race);
    }
}

上面这段代码发起了 20 个线程,每个线程对 race 变量进行 10000 次自增操作,如果这段代码能够正确并发的话,最后输出的结果应该是 200000。但是,运行这段代码并不会获得期望的结果,每次运行的输出结果都不一样,都是一个小于 200000 的数字,这是为什么呢?

问题出在方法 increase 中自增运算“race++”,用 javap 反编译后得到的代码如下所示:

$ javap -c VolatileTest
Compiled from "VolatileTest.java"
public class VolatileTest {
  public static volatile int race;

  public VolatileTest();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void increase();
    Code:
       0: getstatic     #2                  // Field race:I
       3: iconst_1
       4: iadd
       5: putstatic     #2                  // Field race:I
       8: return

......
}

从上面代码中可以发现,increase()方法在 Class 文件中由 4 条字节码指令构成(return 指令不是由 race++产生的,这条指令可以不计算),从字节码层面上很容易就分析出并发失败的原因了:当 getstatic 指令把 race 的值取到操作栈顶时,volatile 关键字保证了 race 的值在此时是正确的,但是在执行 iconst_1、iadd 这些指令的时候,线程可能已经把 race 的值加大了,而在操作栈顶的值就变成了过期的数据,所以 putstatic 指令执行后就可能把较小的 race 值同步回主内存之中。
需要注意的是,使用字节码来分析并发问题是不严谨的,因为即使编译出来只有一条字节码指令,也并不意味执行这条指令就是一个原子操作。一条字节码指令在解释执行时,解释器将运行许多行代码才能实现它的语义;如果是编译执行,一条字节码指令可能转化为多条本地机器码指令。不过,在上面这个例子中,使用字节码已经能说明问题了,所以在此处使用字节码来分析。

那么,如何修改前面的程序,使得在并发情况下得到期待的结果呢(即输出 200000)呢?
第一个修正方法是:用 synchronized 修饰方法 increase(这时,race 不需要用 volatile 修饰了),如:

......
    public static int race = 0;  // 方法increase加上synchronized后,race不需要用volatile修饰了

    public static synchronized void increase() {
        race++;
    }
......

第二个修正方法是:把 race 改为原子类 AtomicInteger,如:

......
    public static AtomicInteger race = new AtomicInteger(0);

    public static void increase() {
        race.incrementAndGet();
    }
......
2.3.1.1. volatile 可实现正确线程并发的情况(两个条件)

当同时满足下面两条规则时,使用 volatile 变量可实现正确的线程并发:
1) 运算结果并不依赖于变量的当前值(或者能够确保只有单一的线程修改变量的值);
2) 变量不需要与其他状态变量共同参与不变约束(例如 “start<=end”)。

注:上一节的例子中,方法 increase() 中语句 race++ 的结果依赖于变量的当前值,所以它不满足上面提到的第 1 个条件,从而不是线程安全的。

下面代码片断是 volatile 的一个常见使用场景:

volatile boolean shutdownRequested;

public void shutdown() {
   shutdownRequested = true;       // 满足上面提到的两条规则(不依赖于变量的当前值,也不需要与其他状态变量共同参与不变约束)
}

public void doWork() {
    while (!shutdownRequested) {
        // do stuff
    }
}

2.3.2. volatile 可禁止指令重排序优化

volatile 变量的第二个特性是:禁止指令重排序优化。

先介绍一下什么是“指令重排序”。现代处理器采用指令级并行技术(Instruction-level parallelism)允许将多条指令不按程序规定的顺序分开发送给各个相应电路单元处理,这样指令被“重排序”了,但并不是说指令任意地重排,CPU 需要能正确处理指令依赖情况以保障程序能得出正确的执行结果。譬如指令 1 把地址 A 中的值加 10,指令 2 把地址 A 中的值乘以 2,指令 3 把地址 B 中值减去 3,这时指令 1 和指令 2 是有依赖的,它们之间的顺序不能重排——因为(A+10)*2 与 A*2+10 显然是不相等的,但是指令 3 可以重排到指令 1、2 之前或者中间,只要保证 CPU 执行后面依赖的 A、B 值的操作时能获得正确的 A 和 B 值即可。

既然 CPU 需要能正确处理指令依赖情况,为什么还要禁止指令重排序呢? 因为程序中可能有一些“人为的依赖关系”,这是 CPU 无法得知的。

下面是“指令重排序”可能干扰程序并发执行的演示程序。

Map configOptions;
char[] configText;
volatile boolean initialized = false; // initialized 在此必须定义为volatile


// 假设以下代码在线程 A 中执行
// 模拟读取配置信息,当读取完成后将 initialized 设置为 true 以通知其他线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;


// 假设以下代码在线程 B 中执行
// 等待 initialized 为 true,代表线程 A 已经把配置信息初始化完成
while (!initialized) {
    sleep();
}
// 使用线程 A 中初始化好的配置信息
doSomethingWithConfig();

上面是一段伪代码,其中描述的场景十分常见,只是我们在处理配置文件时一般不会出现并发而已。

如果定义 initialized 变量时没有使用 volatile 修饰,就可能由于指令重排序的优化,导致位于线程 A 中最后一句的代码 initialized=true 被提前执行(这里虽然使用 Java 作为伪代码,但所指的重排序优化是机器级的优化操作,提前执行是指这句话对应的汇编代码被提前执行),这样在线程 B 中使用配置信息的代码就可能出现错误,而 volatile 关键字则可以避免此类情况的发生。

说明: Java 5 以前的 Java 内存模型是存在缺陷的,即时将变量声明成 volatile 也不能完全避免重排序,这个问题已经在 Java 5 及更高版本中修正。

2.3.3. volatile 应用:实现高效的单例模式(DCL)

Java 中要实现多线程安全的单例模式,可以用下面的代码:

// 懒汉式单例模式(多线程安全)
public class Singleton {
    private static Singleton instance;
    private Singleton () {}

    public static synchronized Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

//// 仅为方便对比,下面给出饿汉式单例模式(多线程安全)
//public class Singleton {
//    private static final Singleton instance = new Singleton();
//    private Singleton () {}
//
//    public static Singleton getInstance() {
//        return instance;
//    }
//}

在上面懒汉式单例模式的实现中,synchronized 修饰了整个 getInstance()方法,故同时只有一个线程可以调用 getInstance()方法,不是很高效。其实,同步操作只需要在第一次创建单例实例对象时才需要,从而引出了 Double-checked locking(DCL)。下面是 Java 中 DCL 的实现:

// 懒汉式单例模式(多线程安全),性能更好
// 说明:这种实现在Java 1.5及更高版本才正确工作
public class Singleton {
    private volatile static Singleton instance; // instance必须声明为volatile,后文将说明原因。
    private Singleton () {}

    public static Singleton getSingleton() {
        if (instance == null) {                 // 第一次非空检测。
            synchronized (Singleton.class) {
                if (instance == null) {         // 第二次非空检测。需要它的原因是可能有多个线程一起进入同步块外的if(通过第一次非空检测),如果不进行第二次检测,则会生成多个实例。
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

在 Java 的 DCL 实现中,为什么需要把 instance 声明为 volatile 呢?这是因为这行代码 instance = new Singleton(); 不是原子操作,可以分解为下面三行伪代码:

mem = allocate();      // 1. 给 instance 分配内存
createInstance(mem);   // 2. 调用 Singleton 的构造函数来初始化对象
instance = mem;        // 3. 将 instance 对象指向分配的内存空间,这时instance为非null

如果 instance 是未用 volatile 修饰的普通变量,上面三行伪代码中的 2 和 3 之间,可能会被重排序,因为重排序 2 和 3 并不会破坏线程内语义(intra-thread semantics)。2和 3 之间重排序之后的执行时序如下:

mem = allocate();      // 1. 给 instance 分配内存
instance = mem;        // 3. 将 instance 对象指向分配的内存空间,这时instance为非null
createInstance(mem);   // 2. 调用 Singleton 的构造函数来初始化对象

如果按 1-3-2 的执行顺序,当 3 执行完毕,2还未执行之前,被另外一个线程(记为线程 B)抢占了,那么 instance 已经是非 null 了(但却没有初始化),所以线程 B 直接返回 instance,然后使用,从而会报错(由于 instance 未初始化)。

我们将 instance 变量声明成 volatile 后,就可以禁止指令重排序优化,这样上面伪代码的执行顺序一定是 1-2-3,不会有问题了。

参考:http://www.infoq.com/cn/articles/double-checked-locking-with-delay-initialization

2.4. “happens-before”原则(共 8 条规则,其它情况虚拟机可以任意重排序)

前面提到过,有一个等效的判断原则——“先行发生(happens-before)”原则,来确定一个访问在并发环境下是否安全。

现在就来看看“先行发生”原则指的是什么。 先行发生是 Java 内存模型中定义的两项操作之间的偏序关系,如果说操作 A 先行发生于操作 B,其实就是说在发生操作 B 之前,操作 A 产生的影响能被操作 B 观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。 这句话不难理解,但它意味着什么呢?我们可以举个例子来说明一下,如下面代码:

//以下操作在线程A中执行
i=1;

//以下操作在线程B中执行
j=i;

//以下操作在线程C中执行
i=2;

假设线程 A 中的操作“i=1”先行发生于线程 B 的操作“j=i”,这意味着什么呢?意味着可以确定在线程 B 的操作执行后,变量 j 的值一定等于 1,得出这个结论的依据有两个:一是根据先行发生原则,“i=1”的结果可以被观察到;二是线程 C 还没“登场”,线程 A 操作结束之后没有其他线程会修改变量 i 的值。现在再来考虑线程 C,我们依然保持线程 A 和线程 B 之间的先行发生关系,而线程 C 出现在线程 A 和线程 B 的操作之间,但是线程 C 与线程 B 没有先行发生关系,那变量 j 的值会是多少呢?答案是不确定!1和 2 都有可能,因为线程 C 对变量 i 的影响可能会被线程 B 观察到,也可能不会,这时候线程 B 就存在读取到过期数据的风险,不具备多线程安全性。

下面 8 条是 Java 内存模型下一些“天然的”先行发生关系,这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。

  1. 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
  2. 管程锁定规则(Monitor Lock Rule):一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。注:intrinsic lock(关键字 synchronized)也符合相同的规则。
  3. volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。注:对原子变量(如 AtomicInteger 等)的读写也符合相同的规则。
  4. 线程启动规则(Thread Start Rule):Thread 对象的 start()方法先行发生于此线程的每一个动作。
  5. 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
  6. 线程中断规则(Thread Interruption Rule):对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted()方法检测到是否有中断发生。
  7. 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize()方法的开始。
  8. 传递性(Transitivity):如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那就可以得出操作 A 先行发生于操作 C 的结论。

Java 语言无须任何同步手段保障就能成立的先行发生规则就只有上面这些。

2.4.1. “happens-before”原则示例一

假设有下面代码:

private int value=0;

pubilc void setValue(int value) {
    this.value=value;
}

public int getValue() {
    return value;
}

上面是一组再普通不过的 getter/setter 方法,假设存在线程 A 和 B,线程 A 先(时间上的先后)调用了“setValue(1)”,然后线程 B 调用了同一个对象的“getValue()”,那么线程 B 收到的返回值是什么?

我们依次分析一下“先行发生”原则中的 8 条规则,由于两个方法分别由线程 A 和线程 B 调用,不在一个线程中,所以程序次序规则在这里不适用;由于没有同步块,自然就不会发生 lock 和 unlock 操作,所以管程锁定规则不适用;由于 value 变量没有被 volatile 关键字修饰,所以 volatile 变量规则不适用;后面的线程启动、终止、中断规则和对象终结规则也和这里完全没有关系。因为没有一个适用的先行发生规则,所以最后一条传递性也无从谈起,因此我们可以判定尽管线程 A 在操作时间上先于线程 B,但是无法确定线程 B 中“getValue()”方法的返回结果,换句话说,这里的操作不是线程安全的。

那怎么修复这个问题呢?我们至少有两种比较简单的方案可以选择:要么把 getter/setter 方法都定义为 synchronized 方法,这样就可以套用管程锁定规则;要么把 value 定义为 volatile 变量,由于 setter 方法对 value 的修改不依赖 value 的原值,满足 volatile 关键字使用场景,这样就可以套用 volatile 变量规则来实现先行发生关系。

通过上面的例子,我们可以得出结论:一个操作“时间上的先发生”不代表这个操作会是“先行发生”。

2.4.2. “happens-before”原则示例二

假设有下面代码:

// 以下操作在同一个线程中执行
int i = 1;
int j = 2;

设上面两条赋值语句在同一个线程中执行,根据程序次序规则,“int i=1”的操作先行发生于“int j=2”。但“int j=2”的代码完全可能先被处理器执行,这并不影响先行发生原则的正确性。

操作A先行发生于操作B,是指在发生操作B之前,操作A产生的影响能被操作B观察到。在这个例子中,“int i=1”和“int j=2”没有什么依赖关系,处理器可以随便对它们进行“重排序”。

这个例子中,如果把“int j=2;”改为“int j=i;”,则“int i=1”一定会被处理器先执行。

Author: cig01

Created: <2013-12-05 Thu>

Last updated: <2018-09-02 Sun>

Creator: Emacs 27.1 (Org mode 9.4)