Java内存模型

共享内存模型

什么是Java内存模型?Java内存模型就是Java Memory Model(以下简称JMM),是Java虚拟机规范的一部分。我们知道Java语言是跨平台的,所以JVM需要定义一个统一的规范,抽象规定Java程序如何访问内存,这就是JMM,具体讲就是JSR-133。注意,JMM不是JVM运行时数据区描述,那只是JMM的一小部分。

主内存和工作内存

JMM规定:变量存储在主内存(Main Memory)中,每个线程有自己的工作内存(Working Memory),线程工作内存中保存了变量在主内存的副本拷贝。主内存变量是多线程共享的,工作内存变量是各个线程独占的。线程工作时,从主内存复制变量到线程的工作内存,然后一直在工作内存中操作变量副本,最后再把工作内存中的变量值回写到主内存中。

图1-Java内存模型

Java内存模型是一种共享内存模型:

  • 线程只能直接操作工作内存,不能直接操作主内存;
  • 每个线程只能访问自己的工作内存,不能访问其他线程的工作内存;
  • 线程之间的数据通信必须通过主内存中转完成:线程1将工作内存变量写入到主内存,然后线程2再从主内存读取变量到工作内存,实现数据通信。

运行时数据区

我们再来看一下JVM运行时数据区情况

  • 所有原始类型的本地变量都直接保存在线程栈中,它们的值在各个线程之间是独立的;
  • 堆包含了Java应用创建的所有对象信息,不管对象是哪个线程创建的(包括像Integer这样的原始类型封装类);不管对象是属于一个成员变量还是方法中的本地变量,它都会被存储在堆中;
  • 一个本地变量如果是原始类型,那么它全部存储到栈区;
  • 一个本地变量也有可能是一个对象的引用,这种情况下:这个本地引用存储到栈中,对象本身存储在堆中;
  • static类型的变量以及类本身相关信息都存储在堆中。

硬件内存结构

前面把JAVA内存空间分为主内存和工作内存,或者分为栈和堆。无论那一种分法,都是逻辑上的概念,没有实际的物理存储空间与其一一对应。

我们再来看一下硬件内存结构:从上往下,首先在CPU内部有一组CPU寄存器,CPU操作寄存器的速度要比操作主存快得多。再往下,在CPU寄存器和主存之间还有CPU缓存,CPU操作CPU缓存的速度快于主存但慢于寄存器,某些CPU可能有多级缓存层。最下面是计算机主存,也称作RAM,所有的CPU都能够访问主存,而且主存比上面提到的缓存和寄存器的存储容量大很多。从上往下,存储容量越来越大,访问速度越来越慢。

再重复一遍,主内存、工作内存、堆、栈只是逻辑上的概念,和物理存储空间没有关系,下图可以说明这一点:堆和栈空间都有可能在RAM或者Cache上。

内存指令

下面再来看看JMM定义的常见内存操作指令:

指令 作用域 说明
lock 主内存 把主内存中一个变量标识为线程锁定状态
unlock 主内存 释放主内存中一个变量的线程锁定状态
read 主内存 把一个变量从主内存传递到线程的工作内存中
load 工作内存 把read操作得到的变量值更新到工作内存的变量副本中
store 工作内存 把一个变量从线程的工作线程传递到主内存中
write 主内存 把store操作得到的变量值更新到主内存的变量中

JMM对于上述指令有如下规则:

  • read/load和store/write必须成对出现;
  • 一个新的变量只能从主内存中诞生;
  • 一个变量同一时刻只允许一个线程对其进行lock操作;
  • 对一个变量lock操作之后会触发对它的read/load操作;
  • 对一个变量unlock操作之前触发对它的store/write操作。

可见性和有序性

前面介绍了JMM共享内存模型的基本概念,共享内存模型需要解决并发过程中的原子性、可见性和有序性问题。

原子性(Atomicity)

原子性即不可拆分。JMM只保证像read/load/store/write这样很少的操作是原子性的,甚至在32位平台下,对64位数据的读取和赋值都不能保证其原子性(long变量赋值是需要通过两个操作来完成的)。简单说,int i=10; 是原子的;i = i + 1 不是原子的;甚至long v = 100L 也可能不是原子的。

可见性(Visibility)

可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。

有序性(Ordering)

有序性指程序的执行顺序与源码顺序一致,有序性和重排序相关,下面展开讲。

重排序

为提高性能,编译器和处理器可能会对指令做重排序,重排序有三种类型:

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

编译器重排序和处理器重排序比较好理解,一个是源代码编译时调整了生成的机器码顺序,另外一个是执行时调整了机器码的执行顺序,内存重排序就不那么好理解了,下面展开一下。

内存重排序也可以认为是处理器重排序的一种。

内存重排序

内存访问速度虽然比硬盘访问速度快得多,但是和CPU处理器相比还是太慢。所以,现代操作系统中,CPU和内存之间还有会缓存。对于存储来说,访问速度最快的是CPU上的寄存器,速度最快,容量最小;接下来是CPU上的缓存(一般有二级缓存或者三级缓存),速度很快,容量较小;再往下是内存,速度较快,容量不小;最后是硬盘,速度最慢,容量最大。

有了缓存之后,CPU不会直接操作内存,CPU操作的是缓存,缓存数据何时更新到内存中由系统决定。所以,当我们在代码里面设置变量值的时候,只会更新写缓冲区的值,不会立即更新到内存中。这样,就可能出现先设置的值,后写入到内存的情况。

缓存中的数据与主内存的数据并不是实时同步的,各CPU(或CPU核心)间缓存的数据也不是实时同步的。这导致在同一个时间点,各CPU所看到同一内存地址的数据的值可能是不一致的。从程序的视角来看,就是在同一个时间点,各个线程所看到的共享变量的值可能是不一致的。

下面看一个具体的例子,线程1和线程2执行的代码如下,同时执行的话,输出结果是什么呢?

线程1 线程2
a = 1; // A1 b = 2; // B1
x = b; // A2 y = a; // B2

最终可能得到x = y = 0的结果,为什么呢?

  • 开始a=b=0
  • 执行语句A1,设置a=1,线程1的写缓存区中a=1,但是主内存中还是a=0
  • 执行语句B1,设置b=2,线程2的写缓存区中b=2,但是主内存中还是b=0
  • 执行语句A2,x=b,从主内存中读取b的值并赋值给x,x=0
  • 执行语句B2,y=a,从主内存中读取a的值并复制给y,y=0
  • 执行A3刷新线程1的写缓冲区,a=1
  • 执行B3刷新线程2的写缓冲区,b=2

以线程1为例:源码顺序是A1->A2,但是由于写缓存区的存在(刷新之后A1语句才实际上起作用),导致时间上的执行顺序是A2->A1,这就是所谓的内存重排序。

什么时间刷新写缓存区数据到主内存中,是处理器自己决定的。Java本身是跨平台的,硬件无关的,所以对于Java来说就是随机的,不可控的。

怎么解决重排序问题呢?Java编译器通过插入内存屏障指令来禁止处理器重排序,这就是内存栅栏,也是volatile关键字实现的原理,我们后面再说。

我们再来看一个例子:线程1执行writer()方法,线程2执行reader()方法,结果怎样?

  • 由于writer()方法中的A1和A2操作没有依赖关系,所以可能被重排序,先执行A2,再执行A1,这种情况下由于a=0,所以i=0;
  • reader()方法中的B1和B2操作是有依赖关系的,不会被重排序;但是为提高性能编译器和处理器可能会猜测执行,导致B2操作先执行,这样i还是等于0。
class ReorderExample {
int a = 0;
boolean flag = false;

public void writer() {
a = 1; // A1
flag = true; // A2
}

public void reader() {
if (flag) { // B1
int i = a * a; // B2
}
}
}

解释一下猜测执行,纯属个人理解。操作系统有2个CPU执行reader()方法,为提高性能,可能CPU1执行if(flag) 另外一个CPU2同时执行int i=a*a ;如果flag=true,那么CPU2就提前执行了,缩短了执行时间;如果flag=false,那么CPU2的执行就是废操作。在单线程情况下,这么做最多只是浪费的CPU2的操作,但是还有一半可能提高了效率。但是多线程情况下,先执行if()语句内就可能引发事实上指令重排序问题。

先行发生原则

也叫happens-before原则。前面看到了各种重排序,我们怎么保证程序能够按照我们预期的顺序执行呢?所有的代码都添加volatile和synchronized吗?当然不是,实际上我们很少能够察觉到重排序,这就是先行发生原则起了作用。

Happens-before原则本身还是有些晦涩难懂,下面先列出其中几条重要的原则,后面再解读:

  • 程序次序规则:在一个线程内,按照控制流顺序,控制流前面的操作先行发生于控制流后面的操作;
  • 锁定规则:锁的unlock操作先行发生于后面对同一个锁的lock操作;
  • volatile规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作;
  • 传递原则:如果A先行发生于B,B先行发生于C,那么A必先行发生于C。

来个例子分析一下,非常简单的get()和set()方法。如果线程1执行set(1),同时线程2执行get(),那么线程2的get()方法返回值是1还是0?套用上面的happens-before原则,一个也套不上,所以结果是不确定的,可能是0,也可能是1。首先,set()和get()方法在两个线程中执行,所以程序次序规则不生效。其次,没有使用synchronized和volatile,所以接下来的两个原则也不生效。这个方法就是线程不安全的。

private int i = 0;
public void set(int i) {
this.i = i;
}
public int get() {
return i;
}

如果我们给变量i添加volatile关键字,就是线程安全的了。根据volatile规则,写操作先行发生于读操作,get()一定返回1。

private volatile int i = 0;

volatile语义

JMM是理论,最常见的实际应用就是volatile关键字了。

volatile关键字有两个作用:一是保证内存可见性,二是保证指令不会被重排序。这么说太抽象,下面解释一下:

  • 可见性:volatile关键字保证如果一个线程修改了volatile变量的值,此后另外一个线程读取这个变量时,立即就能够读取到变化后的最新值;可见的意思就是一个线程对共享变量的修改,另外一个线程立即就能知道;
  • 禁止进行指令重排序,为了提高性能,编译器生成的字节码顺序可能和我们编写的源代码顺序不一样,CPU执行顺序也可能和字节码顺序不一样,重排序会导致执行结果与预期不符,volatile关键字能保证对volatile变量的操作不被重排序,在它前面的代码先执行,在它后面的代码后执行。

三个主要特性:原子性、可见性和有序性,volatile保证可见性和有序性,单个volatile变量读写具有原子性,类似volatile++这种复合操作不具有原子性。

从内存语义的角度来说,volatile的写/读操作与锁的释放/获取有相同的内存效果:

  • 当写一个volatile变量时,JMM会把该线程工作内存中的变量值刷新到主内存;
  • 当读一个volatile变量时,JMM会把该线程工作内存中的变量值置为无效,从主内存中读取变量值;
  • 当lock一个变量时,JMM触发read/load操作,从主内存读取变量值到线程工作内存中;
  • 当unlock一个变量时,JMM触发store/write操作,讲线程工作内存中的变量值刷新到主内存。

简单说,volatile变量写操作立即刷新到主内存,volatile变量读操作从主内存。

volatile解决的是多线程之间读的可见性问题,如果只有一个线程写共享变量,多个线程读取共享变量,那么volatile可以保证读线程能够即使读取到共享变量的最新值,也就是说,一旦写线程修改了共享变量的值,那么读线程可以立即读取到最新的值,没有脏读。

但是,volatile不是用来解决多线程一起写的。只有在一种情况下,使用volatile多线程写共享变量不会出问题,那就是共享变量的新值与旧值没有关联,或者说新值是直接设置的,不需要通过旧值计算得到,这个时候即使有多线程写,volatile共享变量也是正确的。例如:用volatile boolean变量做标志位。

下面看看volatile底层如何实现可见性和有序性的,之前我们先了解一下内存栅栏。

内存屏障

内存屏障,也叫内存栅栏,英文名Memory Barries。编译器生成机器码时在指定位置插入内存屏障指令禁止重排序,简单说:内存屏障或者内存栅栏是一组指令,起到类似路障的作用。

JMM有四类内存屏障指令:

内存栅栏指令 使用 说明
LoadLoad Load1; LoadLoad; Load2; 确保Load1操作早于Load2操作
StoreStore Store1; StoreStore; Store2; 确保Store1操作早于Store2操作
LoadStore Load1; LoadStore; Store2; 确保Load1操作早于Store2操作
StoreLoad Store1; StoreLoad; Load2; 确保Store1操作早于Load2操作

好像不太好懂,我来解读一下。以StoreLoad为例,store的意思是把线程工作内存中变量回写到主内存中,load的意思是从主内存中读取变量到线程工作内存中生成变量副本。store早于load也就是告诉处理器看到StoreLoad指令时,立即把缓存中的数据回写到主内存中,然后再从主内存中读取数据,我理解就是强制回写缓存的意思。

处理器实际操作时只识别StoreLoad指令,不会区分缓存中哪个变量是volatile变量,而是立即把缓存中的所有变量都回写到内存中。

注意:这里线程工作内存和缓存的概念要区分清楚。缓存是物理存在的,可能是CPU的寄存器、一级缓存、二级缓存或者三级缓存;工作内存是JMM的虚拟概念,不直接对应物理存在,工作内存可能是寄存器,也可以是CPU上的二级缓存。

可见性实现

volatile在编译器层面的语义如下:

  • 在每个volatile写操作的前面插入一个StoreStore屏障,后面插入一个StoreLoad屏障;
  • 在每个volatile读操作的后面插入一个LoadLoad屏障和一个LoadStore屏障。

这么说可能还是太抽象,我们结合代码实例看一下,下面set()和get()方法在两个线程中同时执行,我们期望get()方法返回结果3。

private boolean volatile flag = false;
private int value = 0;
private int a = 0;
private int b = 0;
public int get() {
if(flag) {
value = a + b;
}
return value;
}
public void set() {
a = 1;
b = 2;
flag = true;
}

如果flag变量没有volatile关键字修饰,那么这里存在可见性问题:

  • flag变量在主内存中创建,初始值为false;
  • set()线程启动,从主内存读取flag=false到工作内存,然后在工作内存中设置flag=true;
  • get()线程启动,从主内存读取flag变量,由于此时set()线程还没有将flag变量回写到主内存中,所以get()线程读取到flag=false,最后返回value=0

使用volatile关键字后,set()方法在flag = true; 之后插入了StoreLoad内存屏障指令,意思是说在后面的load操作前先执行store操作,相当于立即执行store操作刷新主内存,这就保证主内存flag变量值立即变为true。

StoreStore;
store(flag = true);
StoreLoad;

get()方法在读取flag变量之后插入了LoadLoad和LoadStore内存屏蔽指令,意思说在后面的读写操作前先执行load操作,相当于立即执行load操作从主内存读取变量flag。

load(flag);
LoadLoad;
LoadStore;

根据先行发生原则,volatile变量的写操作先于读操作,所以并发时能够保证先执行store(flag=true),将主内存中的flag变量更新为true,然后load(flag)从主内存中读取最新的flag变量值。

有序性实现

还是上面的例子,如果flag变量没有volatile关键字修饰,那么这里存在有序性问题,在两种情况下执行get()方法时可能返回value=0:

  • set()方法被重排序,先执行了flag=true ,这个时候get()开始执行,读取到flag=true ,但是set()方法还没有执行a=1; b=2; ,所以get()返回0
  • get()方法猜测执行,先执行了value=a+b,然后再判断if(flag),同样也会返回0

使用volatile关键字后,set()方法在flag = true; 之前插入StoreStore内存屏障指令,把a=1和b=2先于flag=true刷新到主内存中,阻止了第一种重排序的情况。

store(a = 1);
store(b = 2);
StoreStore;
store(flag = true);
StoreLoad;

get()在读取flag变量后插入LoadLoad内存屏障指令,保证load(flag)操作先于load(a)和load(b),阻止了第二种重排序的情况。

load(flag);
LoadLoad;
LoadStore;
load(a);
load(b);

synchronized语义

从语义的角度来看,synchronized和volatile是相似的。

  • 当线程释放锁时,JMM会把该线程工作内存中的共享变量的最新值刷新到主内存中;
  • 当线程获取锁时,JMM会把该线程工作内存置为无效,从主内存中去读取共享变量的最新值。

上述语义保证了可见性。

synchronized的执行过程如下:

  • 获得锁;
  • 清空线程工作内存中的共享变量;
  • 从主内存拷贝共享变量的最新副本到线程工作内存;
  • 执行synchronized语句块内代码;
  • 将线程工作内存中共享变量的值刷新到主内存;
  • 释放锁。

与volatile对比

相同点

  • 都解决了多线程下内存可见性问题;
  • 从内存可见性角度看,volatile读操作相当于加锁,volatile写操作相当于解锁;

不同点

  • volatile不能保证共享变量相关操作的原子性,应用场景受限;volatile使用不如synchronized广泛;
  • volatile不加锁,比synchronized轻量级,效率更高;能用volatile时尽量用volatile;

final语义

final关键字保证构造函数中对final成员变量的赋值操作不会重排序到构造函数之外,没有final关键字的成员变量赋值操作就没有这个保障了。

什么意思?构造函数也可以分为两步:第一步,创建对象,所有成员变量赋默认值;第二步,执行构造方法给成员变量赋值。多线程情况下,可能出现第一步完成,第二步执行前就读取成员变量的情况。但是如果成员变量是final的,JMM可以保证在第一步就给final成员变量赋值。

参考

深入理解Java内存模型(一)——基础

Java 虚拟机 12 :Java 内存模型

Java内存访问重排序的研究

面试必问的 volatile,你了解多少?

Java Volatile Keyword

java volatile关键字解惑

http://tutorials.jenkov.com/java-concurrency/

https://blog.csdn.net/suifeng3051

https://www.jianshu.com/u/f8e9b1c246f1