【Java】JVM - Java 内存模型(Java Memory Model)

Posted by 西维蜀黍 on 2019-02-25, Last Modified on 2021-09-21

背景

重排序(Instruction Reordering)

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。

重排序分三类:

  • 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从 Java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

上面的这些重排序都可能导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 Java 编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。

JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

Java 内存模型(Java Memory Model,JMM)

Java 虚拟机规范中试图定义一种 Java 内存模型(Java Memory Model,简称 JMM),来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果,不必因为不同平台上的物理机的内存模型的差异,对各平台定制化开发程序。

在此之前,主流程序语言(如C/C++等)直接使用物理硬件和操作系统的内存模型,因此,会由于不同平台上内存模型的差异,有可能导致程序在一套平台上并发完全正常,而在另外一套平台上并发访问却经常出错,因此在某些场景下就不许针对不同的平台来编写程序。

Java内存模型组成

Java内存模型包括主内存工作内存两个部分:

  • 主内存(Main Memory):用来存储线程之间的共享变量;
  • 工作内存(Working Memory):存储每个线程使用到的变量副本。

即当某个线程要读取主内存中的某个变量时,JVM 会先将这个变量从主内存中拷贝到线程对应的工作内存;当这个线程完进行了对这个变量的修改后,JVM 会将这个变量在工作内存中的值同步到主内存中。

Java 内存模型规定:

  • 所有变量都存储在主内存(Main Memory)中;
  • 每个线程都有自己独立的工作内存(Working Memory),里面保存了该线程使用到的变量副本(主内存中该变量的拷贝);
  • 线程对共享变量的所有操作都必须在自己的工作内存中进行,而不能直接对主内存中的变量进行读写;
  • 不同线程之间也无法直接访问其他线程工作内存中的变量,线程间的变量值传递需要主内存来完成。

内存间的交互动作

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java 内存模型中定义了下面 8 种操作来完成。

虚拟机实现时,必须保证下面介绍的每种操作都是原子的,不可再分的(对于 double 和 long 型的变量来说,load、store、read、和 write 操作在某些平台上允许有例外)。

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

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

  • 不允许 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 种操作定义相当严禁,实践起来又比较麻烦,但是可以有助于我们理解多线程的工作原理。因此,有一个与此 8 种操作相等的 Happen-before 原则。

JVM 对 Java 内存模型的实现

在 JVM 内部,Java 内存模型把内存分成了两部分:线程栈区(Thread Stack)堆区(Heap)

JVM 中运行的每个线程都拥有自己的线程栈,线程栈包含了当前线程执行的方法调用相关信息,我们也把它称作调用栈(Call stack)。随着代码的不断执行,调用栈会不断变化。

线程栈还包含了当前方法的所有本地变量信息。一个线程只能读取自己的线程栈,也就是说,线程中的本地变量对其它线程是不可见的。即使两个线程执行的是同一段代码,它们也会各自在自己的线程栈中创建本地变量,因此,每个线程中的本地变量都会有自己的版本。

所有原始类型(boolean,byte,short,char,int,long,float,double)的本地变量都直接保存在线程栈当中,对于它们的值各个线程之间都是独立的。对于原始类型的本地变量,一个线程只能传递这个本地变量的副本给另一个线程,而无法在这两个线程之间共享这个变量(注意,我们讨论的是本地变量,如果这个变量已经被声明成volatile了,则不是本地变量了)。

堆区包含了 Java 应用创建的所有对象信息,不管对象是哪个线程创建的,其中的对象包括原始类型的封装类(如Byte、Integer、Long等等)。不管对象是属于一个成员变量还是方法中的本地变量,它都会被存储在堆区。

下图展示了调用栈和本地变量都存储在栈区,对象都存储在堆区:

原子性(atomicity)、可见性(visibility)以及有序性(ordering)的保证

Java 内存模型是围绕着并发编程中原子性(atomicity)、**可见性(visibility)以及有序性(ordering)**这三个特征来建立的。

那么, Java 语言本身对这三个特性提供了哪些保证呢?

原子性(Atomicity)

由 Java 内存模型来直接保证的原子性变量操作包括 read、load、assign、use、store、write,而对基本数据类型变量的访问读写是具备原子性的(long和double的非原子性协定例外),即这些操作是不可被中断的,要么执行,要么不执行。

如果应用场景需要一个更大范围的原子性保证,Java 内存模型提供了 lock 和 unlock 操作来满足这种需求。尽管虚拟机未把 lock 与 unlock 操作直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorenter 和 monitorexit 来隐式地使用这两个操作,这两个字节码指令反映到 Java 代码中就是同步块—— synchronized 关键字,因此在 synchronized 块之间的操作也具备原子性。

例子

上面的分析虽然看起来简单,但是理解起来并不是那么容易。看下面一个例子:

请分析以下哪些操作是原子性操作:

int x;
//语句1
x = 10;
//语句2
y = x;
//语句3
x++;
//语句4
x = x + 1;

其实,只有语句1是原子性操作,其他三个语句都不是原子性操作。

  • 语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。
  • 语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及将x的值写入工作内存,这2个操作都是原子性操作,但是合起来就不是原子性操作了。
  • 同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。

也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。

不过这里有一点需要注意:在 32 位平台下,对 64 位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性。

从上面可以看出,Java 内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过 synchronized 和 Lock 来实现。由于 synchronized 和 Lock 能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

synchronized 保证原子性

synchronized 能够实现原子性和可见性。在 Java 内存模型中,synchronized 规定,线程在加锁时,先清空工作内存→在主内存中拷贝最新变量的副本到工作内存→执行完代码→将更改后的共享变量的值刷新到主内存中→释放互斥锁。

可见性(Visibility)

**可见性(Visibility)**指当一个线程修改了线程共享变量的值,其它线程能够立即感知到这个修改。

共享变量可见性实现的原理

Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方法来实现可见性的,无论是普通变量还是volatile变量都是如此。

非 volatile 变量与 volatile 变量的区别是 volatile 变量能够被保证新值能立即同步到主内存,以及每次使用前先从主内存获取最新的数值。因为,我们可以说 volatile 保证了线程操作时变量的可见性,而普通变量(非 volatile 变量)则不能保证这一点。

可见性支持

Java 语言层面支持的可见性实现方式:

  • volatile 关键字
  • synchronized 关键字和 Lock 锁
  • final

**volatile **关键字

对于可见性,Java 提供了 volatile 关键字来保证可见性。 

当一个共享变量被 volatile 修饰时,JVM 会保证当其被修改后,立即将新值更新到主存,且当有其他线程需要读取时,JVM会先去主内存中读取该变量最新的值(触发缓存行失效)。

而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被同步更新到主存是不确定的。因此,当其他线程去读取时,此时主存中可能还是原来的旧值,因此无法保证可见性。

synchronized 关键字和 Lock 锁

通过 synchronized 关键字和 Lock 也能够保证可见性,synchronized 关键字和 Lock 锁能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

从 Java 内存模型的角度来说,synchronized 块的可见性是由“对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store 和 write 操作)”这条规则获得的。

final 关键字

final 关键字的可见性是指:被 final 修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”引用传递出去,那么在其它线程中就能看见 final 字段的值。

被 final 关键字修饰的变量相对是一种特殊情况,因为该变量的赋值是在构造函数执行完成之前。而在构造函数执行完成之后,该变量就不存在修改操作了,因而被 final 关键字修饰的变量是一个不可变(immutable)变量不可变(immutable)变量总是具有可见性,而且它也是线程安全的

public static final int i;
public final int j;
static{
    i = 0;
}
{
	// 也可以选择在构造函数中初始化
    j = 0;
}

在上面例子中, 变量 i 和 j 都具备可见性。

有序性(Ordering)

在 Java 内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行(这就是 as-if-serial),却会影响到多线程并发执行的正确性。

背景

我们来看一个可能触发有序性问题的例子:

public class ReorderExample {
    private int a = 2;
    private boolean flg = false;

    public void method1() {
        a = 1;
        flg = true;
    }

    public void method2() {
        if (flg) {
            if (a == 2) {
                //2 might be printed out on some JVM/machines
                System.out.println("a = " + a);
            }
        }
    }

    public static void main(String[] args) {
        for (int i = 0; i < 100000; i++) {
            ReorderExample reorderExample = new ReorderExample();
            Thread thread1 = new Thread(() -> {
                reorderExample.method1();
            });
            Thread thread2 = new Thread(() -> {
                reorderExample.method2();
            });
            thread1.start();
            thread2.start();
        }
    }
}

事实上,a = 2 只是可能会被打印。在我的macOS下执行了几次,a = 2都始终没有被打印出来过。

线程内表现为串行的语义(Within-Thread As-If-Serial Semantics)

as-if-serial 语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果不会改变。

编译器、运行时(runtime)和处理器都必须遵守 as-if-serial 语义。

为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序

举个例子:

int a = 1;
int b = 2;
int c = a + b;

假如没有重排序这个东西,CPU肯定会按照从上往下的执行顺序执行:先执行 a = 1、然后 b = 2、最后 c = a + b,这也符合我们的阅读习惯。 但是,上文也提及了:CPU 为了提高运行效率,在执行时序上不会按照刚刚所说的时序执行,很有可能是 b = 2a = 1c = a + b

因为只需要在变量 c 需要变量 a + b 的时候能够得到正确的值就行了,JVM 允许这样的行为。 这种现象就是线程内表现为串行的语义

有序性支持

Java 语言层面支持的有序性实现方式:

  • volatile 关键字:支持部分有序性,本质上,是通过禁止与 volatile 变量有关的指令重排序。
  • synchronized 关键字和 Lock 锁:保证每个时刻只有一个线程执行同步代码。

volatile

在 Java 里面,可以通过 volatile 关键字来保证部分的“有序性”,因为 volatile 机制只禁止与 volatile 变量有关的指令重排序 。

synchronized 关键字和 Lock 锁

另外,可以通过 synchronized 关键字和 Lock 锁来保证有序性,很显然, synchronized 关键字和 Lock 锁保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

另外,Java 内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。

先行发生原则(happens-before 原则)

**先行发生原则(happens-before 原则)**非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们可以解决在并发环境下,两操作之间是否可能存在冲突的所有问题。

先行发生是 Java 内存模型中定义的两项操作之间的偏序关系,如果操作 A 先行发生于操作 B,那么操作 A 产生的影响能够被操作 B 观察到。“影响”包括修改了内存中的共享变量的值、发送了消息、调用了方法等。

例子

举个例子:

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

假设线程 A 中的操作 i=1 先行发生于线程 B 的操作 j=i ,那么就可以确定在线程 B 的操作执行之后,j 一定等于 1 。因为:根据先行发生原则,i=1 的结果可以被 B 观察到。

现在保持 A 先行发生于 B,线程 C 出现在 A 与 B 之间,但是线程 C 与 B 没有先行发生关系。那么 j 会等于多少呢?

答案是不确定。因为线程 C 对变量 i 的影响可能会被 B 观察到,也可能不会。因为两者之间没有先行发生关系。这时候线程 B 就存在读取到过期数据的风险,因而不具备多线程安全性。


下面是 Java 内存模型下一些”天然的“happens-before关系,这些 happens-before 关系无须任何同步器协助就已经存在,可以在编码中直接使用。

如果两个操作之间的关系不在此列,或者无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们进行随意地重排序。

  • 程序次序规则(Program Order Rule) :一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。
  • 管程锁定规则(Monitor Lock Rule):一个管程(monitor)上的 unLock 操作先行发生于后面对同一个管程的 lock 操作。
  • volatile 变量规则(volatile Variable Rule):对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。
  • 传递规则(Transitivity) :如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。
  • 线程启动规则(Thread Start Rule) Thread对象的start()方法Happens-Before此线程的每一个动作。
  • 线程终止规则(Thread Termination Rule) 线程中的所有操作都Happens-Before对此线程的终止检测。
  • 线程中断规则(Thread Interruption Rule) 对线程interrupt()方法的调用Happens-Before被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupt()方法检测到是否有中断发生。
  • 对象终结规则(Finalizer Rule) 一个对象的初始化完成(构造函数执行结束)Happens-Before它的finalize()方法的开始。

对于程序次序规则来说,我的理解就是一段程序代码的执行在单个线程中看起来是有序的。

注意,虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,因为虚拟机可能会对程序代码进行指令重排序。

虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。

Reference