JMM: Java内存模型

硬件效率与一致性

一般来说,“让计算机并发执行若干个运算任务”(多任务处理)可以“更充分地利用计算机处理器的效能”。但是其中一个重要的复杂性(问题)来源是:绝大多数的运算任务不可能只靠处理器的计算就能完成,处理器还需要和内存交互,如读取运算数据、存储原酸结果等,这些I/O操作很难消除。

由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以每个处理器增加自己的高速缓存(Cache)来作为内存与处理器之间的缓冲。将运算需要的数据复制到缓存中,让运算能快速进行,运算结束后再从缓存同步到内存中,这样处理器就无须等待缓慢的内存读写了。

基于告诉缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也引入了一个新问题:缓存一致性(Cache Coherence)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致缓存数据不一致。

内存模型”:可以理解为对特定的内存或高速缓存进行读写访问的过程抽象。

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

Java内存模型

Java内存模型(Java Memory Model,JMM):用来屏蔽掉硬件和操作系统的内存访问差异,以实现各平台达到一致的内存访问效果。

Java线程通信由Java内存模型控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见,也就是提供内存可见性保证。

JMM的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量的这些底层细节。

此处的变量指的是线程共享的变量,包括了实例字段、静态字段和构成数组对象的元素。

JMM是一个语言级的内存模型,处理器内存模型是硬件级的内存模型。

主内存与工作内存

Java内存模型规定所有的变量都存储在主内存(虚拟机内存的一部分,可以和硬件中的主内存类比)中。
每条线程都有自己的工作内存(Working Memory,或者叫本地内存,可以和硬件中的高速缓存类比,本地内存只是JMM的一个抽象概念,并不真实存在)。

线程A与线程B之间如要通信的话,必须要经历下面2个步骤:

  1. 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
  2. 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。

线程、工作内存、主内存间的交互关系

内存间的交互操作

关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,JMM中定义了以下8种操作完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的(对于long、double类型的变量来说,在某些平台上有例外)。

  1. lock(锁定)
    作用于主内存的变量,把一个变量标识为线程独占状态。

  2. unlock(解锁)
    作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后才能被其他线程锁定。

  3. read(读取)
    作用于主内存的变量,把一个变量的值从主内存中传输到线程的工作内存中,以便load动作使用。

  4. load(载入)
    作用于工作内存的变量,把read操作从主内存中得到变量值放入工作内存的变量副本中。

  5. use(使用)
    作用于工作内存的变量,把工作内存中的一个变量的值传递给工作引擎

  6. assign(赋值)
    作用于工作内存的变量,把一个从执行引擎接收到的值赋给工作内存的变量。

  7. store(存储)
    作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便write操作使用。

  8. write(写入)
    作用于主内存的变量,把store操作从工作内存中得到的变量放入主内存的变量中。

JMM还规定了上述8种操作必须满足如下规则

  • 不允许read和load、store和write操作之一单独出现
    即不允许一个共享变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
  • 一个新的共享变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。
  • 一个共享变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  • 如果对一个共享变量执行lock操作,那将会清空工作内存中变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
  • 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把变量同步回主内存中。

JMM的特征

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

原子性

原子性是指一个操作不能被打断,要么全部执行完毕,要么不执行。

JMM直接保证的原子性变量操作包括read、load、assign、use、store和write。大致可以认为基本数据类型的访问读写是具备原子性的(例外就是long和double的非原子协定)。

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

volatile关键字的增强语义确保volatile的写-读和锁的释放-获取具有相同的内存语义,因而也就具有了原子性。

可见性

可见性是指一个线程对共享变量做了修改之后,其他的线程立即能够看到(感知到)该变量这种修改(变化)

Java内存模型是通过将在工作内存中的变量修改后的值同步到主内存,在读取变量前从主内存刷新最新值到工作内存中,这种依赖主内存的方式来实现可见性的。

可见性的实现:volatilesynchronizedfinal关键字还有Lock

  • 普通变量和volatile变量都是如此,区别是volatile的特殊规则保证了新值能立即同步到主内存,以及使用前立即从主内存刷新。
  • synchronized同步块的可见性是由unlock操作的规则获得的。
  • final关键字的可见性是指被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”引用传递出去,那么其他线程就能看见final字段的值。
  • 使用Lock接口的实现锁,和synchronized有相同的语义。

有序性

在本线程内观察,操作都是有序的;如果在一个线程中观察另外一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行语义(WithIn Thread As-if-Serial Semantics)”,后半句是指“指令重排”现象和“工作内存和主内存同步延迟”现象。

Java提供了两个关键字volatilesynchronized来保证多线程之间操作的有序性,volatile关键字本身通过加入内存屏障来禁止指令的重排序,而synchronized关键字通过一个变量在同一时间只允许有一个线程对其进行加锁的规则来实现,在单线程程序中,不会发生“指令重排”和“工作内存和主内存同步延迟”现象,只在多线程程序中出现。

重排序

在执行程序时,为了提高性能(例如,在不改变程序语义的前提下,减少寄存器、缓存的读写次数),编译器和处理器通常会对指令进行重排序。

重排序分为3种类型:

  1. 编译器优化的重排序
    编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

  2. 指令集并行的重排序
    现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

  3. 内存系统的重排序
    由于处理器使用缓存和读/写缓冲区,这使得加载和存储执行看上去可能是在乱序执行。

从源码到最终执行的指令序列

这些重排序可能会导致多线程程序出现内存可见性问题。

对于编译器重排序,JMM的编译器重排序规则会禁止特定类型的编译器重排序。
对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers)指令,通过内存屏障来禁止特定类型的重排序。

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

数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性

数据依赖性包括3种情况:写后读、写后写、读后写。只要重排序这两个操作,程序执行的结果就会改变。

编译器和处理器在重排序时,会遵守数据依赖性,仅针对单个处理器中执行的指令序列和单个线程中执行的操作。

as-if-serial语义

as-if-serial语义:不管怎么重排序(编译器和处理器),单线程程序的执行结果不能被改变。

happens-before

在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系,这里的两个操作既可以是一个线程内的,也可以是在不同线程之间的。

与程序员密切相关的happens-before规则:

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!
happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见

happens-before与JMM的关系图

一个happens-before规则对应于一个或多个编译器和处理器重排序规则。happens-before规则避免Java程序员为了理解JMM提供的内存可见性保证去学习复杂的重排序规则,及其实现方法。