多线程中级篇(3) Java内存模型
条评论Java内存模型
共享内存模型
什么是Java内存模型?Java内存模型就是Java Memory Model(以下简称JMM),是Java虚拟机规范的一部分。我们知道Java语言是跨平台的,所以JVM需要定义一个统一的规范,抽象规定Java程序如何访问内存,这就是JMM,具体讲就是JSR-133。注意,JMM不是JVM运行时数据区描述,那只是JMM的一小部分。
主内存和工作内存
JMM规定:变量存储在主内存(Main Memory)中,每个线程有自己的工作内存(Working Memory),线程工作内存中保存了变量在主内存的副本拷贝。主内存变量是多线程共享的,工作内存变量是各个线程独占的。线程工作时,从主内存复制变量到线程的工作内存,然后一直在工作内存中操作变量副本,最后再把工作内存中的变量值回写到主内存中。

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 { |
解释一下猜测执行,纯属个人理解。操作系统有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; |
如果我们给变量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; |
如果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; |
get()方法在读取flag变量之后插入了LoadLoad和LoadStore内存屏蔽指令,意思说在后面的读写操作前先执行load操作,相当于立即执行load操作从主内存读取变量flag。
load(flag); |
根据先行发生原则,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); |
get()在读取flag变量后插入LoadLoad内存屏障指令,保证load(flag)操作先于load(a)和load(b),阻止了第二种重排序的情况。
load(flag); |
synchronized语义
从语义的角度来看,synchronized和volatile是相似的。
- 当线程释放锁时,JMM会把该线程工作内存中的共享变量的最新值刷新到主内存中;
- 当线程获取锁时,JMM会把该线程工作内存置为无效,从主内存中去读取共享变量的最新值。
上述语义保证了可见性。
synchronized的执行过程如下:
- 获得锁;
- 清空线程工作内存中的共享变量;
- 从主内存拷贝共享变量的最新副本到线程工作内存;
- 执行synchronized语句块内代码;
- 将线程工作内存中共享变量的值刷新到主内存;
- 释放锁。
与volatile对比
相同点
- 都解决了多线程下内存可见性问题;
- 从内存可见性角度看,volatile读操作相当于加锁,volatile写操作相当于解锁;
不同点
- volatile不能保证共享变量相关操作的原子性,应用场景受限;volatile使用不如synchronized广泛;
- volatile不加锁,比synchronized轻量级,效率更高;能用volatile时尽量用volatile;
final语义
final关键字保证构造函数中对final成员变量的赋值操作不会重排序到构造函数之外,没有final关键字的成员变量赋值操作就没有这个保障了。
什么意思?构造函数也可以分为两步:第一步,创建对象,所有成员变量赋默认值;第二步,执行构造方法给成员变量赋值。多线程情况下,可能出现第一步完成,第二步执行前就读取成员变量的情况。但是如果成员变量是final的,JMM可以保证在第一步就给final成员变量赋值。
参考
http://tutorials.jenkov.com/java-concurrency/