Java 内存模型

为了写出正确工作的并发程序,我们非常有必要理解 Java 内存模型。Java 内存模型描述了不同线程如何以及何时可以看到被其他线程修改后的共享变量的值,以及如何对共享变量执行同步操作。下面从一些基本概念开始解析 Java 内存模型,让大家理解多线程通信时需要注意的一些事项。

内存模型

由于计算机的内存与处理器的运算速度有几个数量级的差距,所以现代计算机系统都会加入一层或多层读写速度接近处理器运算速度的高速缓存作为中间缓冲:将运算需要的数据先复制到缓存中,让运算更快地进行,当运算结束后再从缓存同步会内存中,这样处理器就无须等待缓慢的内存读写。但是这样会引入缓存一致性问题,在多处理器系统中,每个处理器有独立的高速缓存,而它们又共享同一主内存,所以在修改同一内存区域时,不知道以哪个缓存数据为准。下图描述了处理器、高速缓存与主内存之间的交互关系:

在操作系统中,cpu 的最小执行单位是线程,所以在计算机系统中,内存模型是线程对内存或高速缓存读写过程的抽象。

2. Java 内存模型

Java 虚拟机规范试图定义 Java 内存模型(Java Memory Model, JMM)来屏蔽各种硬件和操作系统的内存访问的差异,以实现 Java 程序在各种平台一致的内存访问效果。最原始的 Java 内存模型起始于 1995 年,存在一些不足,因为限制了运行时优化并且不能保证足够的代码安全,在 JDK 1.5 实现了 JSR-133 后,Java 内存模型才成熟稳定下来。

Java 内存模型描述了在 Java 语言中线程如何与内存交互,即线程将变量存储到内存和从内存中读取变量的过程。这里的变量与 Java 编程中的变量不同,它包含了实例变量、静态变量和构成数组对象的元素,但不包括局部变量与方法参数,因为后者都是线程私有的,不会被共享。

Java 内存模型规定了所有的变量(指线程共享的变量)都存储在共享内存中,每个线程还有自己的工作内存(也称为堆内存,是广义上的堆,不是一般情况下存储对象实例的堆),工作内存中保存了被该线程使用的变量的共享内存副本,线程对变量的所有操作都必须在工作内存中进行。不同线程之间的工作内存是相互独立的,所以不同线程对于同一变量会有独立的变量副本,线程间变量值的传递必须通过共享内存来完成,它们三者之间的关系如下图所示:

如果变量为基本数据类型,那么工作内存中的副本也是等值的基本数据类型;如果变量为引用类型,工作内存存放的是变量引用,变量的对象本身在共享内存中。当两个线程同时调用同一对象上的同一方法时,它们都将会访问这个对象的成员变量,但是访问的都是成员变量的私有拷贝。

线程 1 和线程 2 数据通信需要下面两个步骤:

  • 线程 1 把工作内存中更新后的值刷新到共享内存中

  • 线程 2 从共享内存中读取刷新后的共享变量,拷贝到自己的工作内存中

上面分析了 Java 内存模型的基本概念,下面开始讨论并发开发需要了解的一些重要概念以及 Java 内存模型的特性。

重排序

在开发并发程序时,必须意识到重排序这个概念,即“编译器和处理器”为了提高性能对输入代码进行重排序,重排序后的执行结果与顺序执行的结果是一样的,但不保证各个语句的计算先后顺序与代码本身的顺序一致。重排序分为“编译器”和“处理器”两个方面,处理器重排序分为指令级重排序和内存系统重排序。对于多线程程序,重排序可能导致程序执行的结果不是我们预想的结果,因此需要通过 volatile、synchronized、锁等方式来避免这种情况。

因为重排序后需要保证执行结果与之前的一样,所以存在数据依赖关系的操作不会被重排序,例如下面的代码:

1
2
3
int i = 1; //语句1
int j = i + 2; //语句2
// 因为语句2 对语句1 有数据依赖关系,所以不会重排序,否则执行结果不正确

先行发生原则

虽然代码执行过程中可能会出现重排序,但是 Java 语言中有“先行发生”(happens-before)原则来保证一定的顺序性。它是判断数据是否存在竞争、线程是否安全的主要依据。

先行发生是 Java 内存模型中定义的两项操作之间的偏序关系,如果操作A先行发生于操作B,其实就是说发生在操作B之前,操作A产生的影响能被操作B观察到,“影响”包括修改了内存中共享内存的值、发送了消息、调用了方法等。需要注意的是如果两个操作之间存在先行发生关系,并不意味着 Java 平台的具体实现必须按照先行发生关系指定的顺序来执行。如果重排序之后的执行结果,与按先行发生关系来执行的结果一致,那么这种重排序并不非法(见 Happens-before Order。下面是 Java 内存模型下的先行发生原则,无须任何同步器协助就已经存在,在编码中可以直接使用。

  • 程序次序规则(Program Order Rule):在一个线程中,按照程序代码顺序,书写在前面的代码操作先行发生于后面的操作

  • 管程锁定规则(Monitor Lock Rule):一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。“后面”指的是时间的先后顺序。

  • volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。“后面”指的是时间的先后顺序。

  • 线程启动规则(Thread Start Rule):Thread 对象的 start() 方法先行发生于此线程的每一个动作。

  • 线程终止规则(Thread Termination Rule):线程中所有操作先行发生于对此线程的终止检测。

  • 线程中断规则(Thread Interruption Rule):对线程 interrupt() 方法的调用先行发生于代码检测到中断事件的发生。

  • 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数结束)先行发生于它的 finalize() 方法的开始。

  • 传递性(Transitivity):如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作A先行发生于操作 C。

先行发生原则与时间先后顺序之间基本没有太大的关系,所以在衡量并发安全问题时不要受到时间顺序的干扰,一切必须以先行发生原则为准。

两个操作有时间上的先后顺序,并不代表它们有先行发生关系,这比较好理解,在多线程操作时比较常见。但是即便操作 A 先行发生于操作 B,在实际执行中操作 A 也不定发生在操作 B 之前。看下下面的代码:

1
2
float pi = 3.14159f; // A
float r = 1.5f; // B

按照程序次序规则,操作 A 先行发生于操作 B,但是两个操作之间没有数据依赖关系,操作 A 的执行结果不需要对操作 B 可见,所以可能重排序后操作 B 先执行,这并不影响执行结果,所以也是合法的。

Java 内存模型的三个特性

Java 内存模型是围绕着在并发过程中如何处理原子性、可见性、有序性来建立的。

原子性

原子性指一个操作不能被打断,要么全部执行完要么不执行。基本数据类型的访问读写基本上是原子操作,比较特殊的是 long 和 double 两种类型。long 和 double 类型是 64 位的,在 32 位的 JVM 中,可以把没有 volatile 修饰的 64 位数据的读写操作分为两次 32 位的操作来进行,这样 long 和 double 类型的操作就不具备原子性。不过实际上,我们都基本不需要考虑这种情况,一般也不需要把 long 和 double 变量声明为 volatile,因为目前各平台的商用虚拟机几乎都选择把 64 位数据的读写操作作为原子操作。

可见性

可见性指一个线程修改了共享变量的值后,其他线程可以立即感知到这个修改。可以实现可见性的关键字有 volatile、synchronized、final。

volatile 的特殊规则保证了 volatile 变量值修改后立即同步到共享内存,每次使用 volatile 变量前立即从共享内存中刷新。同步块的可见性是由“对一个变量执行 unlock 操作之前必须把此变量值同步到共享内存中,执行 lock 操作之前必须从共享内存中刷新变量值到工作内存”这条规则来保证的。final 关键字的可见性是指:被 final 修饰的字段在构造器一旦初始化完成,并且构造器没有把“this”的引用传递出去( this 引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化一半”的对象),其他线程就可以访问 final 字段的值。

有序性

Java 程序的有序性可以理解为:在单线程中,因为程序次序规则,所以逻辑上代码的执行是有序的;但是多线程并发时,代码的执行可能出现乱序,因为“指令重排”现象和“工作内存与共享内存同步延迟”现象。

Java 语言提高 volatile 和 synchronized 来保证多线程之间操作的有序性。volatile 关键字在 JDK 1.5 后可以通过内存屏障来禁止指令重排序,而 synchronized 关键字由“一个变量在同一时刻只允许一个线程对其进行 lock 操作”这条规则来实现。

参考文章