1 JAVA内存模型的基础

1.1 JAVA内存模型的抽象结构

JAVA中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享
局部变量、方法定义参数和异常处理器参数不会在线程之间共享,不会有内存可见性的问题

JAVA线程之间的通信由JAVA内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见

线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读、写共享变量的副本
线程A与线程B之间要通信,必须经历下面两个步骤:

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

JMM通过控制主内存与每个线程的本地内存之间的交互,来为JAVA程序员提供内存可见性保证

1.2 从源代码到指令序列的重排序

重排序分3种:

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

为了保证内存可见性,JAVA编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序

屏障类型 指令类型 说明
LoadLoad Barriers Load1;LoadLoad;Load2 确保Load1数据的装载优先于Load2以及所有后续装载指令的装载
StoreStore Barriers Strone1;StoreStore;Store2 确保Store1数据对其他处理器可见(刷新到内存)先于Store2以及所有后续存储指令的存储
LoadStore Barriers Load1;LoadStore;Store2 确保Load1数据装载先于Store2以及所有后续的存储指令刷新到内存
StoreLoad Barriers Store1;StoreLoad;Load2 确保Store1数据对其他处理器变的课件先于Load2以及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令完成之后,才执行该屏障之后的内存访问指令

StoreLoad Barriers是一个“全能型”的屏障,同时具有其他3个屏障的效果。执行该屏障的开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中。

1.3 happens-before简介

如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系
与程序员相关的happens-before关系如下:

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

两个操作之间具有happens-before关系,并不意味着前一个操作必须在后一个操作之前执行!!!!!
仅仅要求前一个操作对后一个操作可见,且前一个操作按顺序排在第二个操作之前


2 重排序

2.1 数据依赖性

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

名称 代码示例 说明
写后读 a=1;
b=a;
写一个变量后,再读这个位置
写后写 a=1;
a=2;
写一个变量之后,再写这个变量
读后写 a=b;
b=1;
读一个变量之后,再写这个变量

编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序

2.2 as-if-serial语义

as-if-serial语义的意思是: 不管怎么重排序(编译器和处理器为了提高并行度),程序的执行结果不能被改变。
编译器、runtime和处理器都必须遵守as-if-serial语义。
编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。
如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序

2.3 程序顺序规则

3 顺序一致性

3.1 数据竞争与顺序一致性

数据竞争的定义:

在一个线程中写一个变量 在另一个线程读同一个变量 而且写和读没有通过同步来排序

当代码中包含数据竞争,程序的执行往往产生违反直觉的结果

JMM对正确同步的多线程程序的内存一致性做了如下保证:
如果程序是正确同步,程序的执行将具有顺序一致性—程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。

3.2 顺序一致性内存模型

两大特性:

  1. 一个线程中的所有操作必须按照程序的顺序来执行
  2. (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序

顺序一致性内存模型提供的视图:
image.png
从上面的示意图可以看出,在任意时间点最多只能有一个线程可以连接到内存,当多个线程并发执行时,图中的开关装置能把所有的线程的所有内存读/写操作串行化

3.3 未同步程序的执行特性

对于未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值。
为了实现最小安全性,JVM在堆上分配对象是,首先会对内存空间进行清零,然后才会在上面分配对象(JVM内部会同步这两个操作)

未同步程序在两个模型中的执行特性有如下几个差异:

  1. 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行
  2. 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序
  3. JMM不保证对64位的long型和double型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读/写操作都具有原子性

4. volatile的内存语义

4.1 volatile的特性

把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步

  1. class VolatileFreaturesExample{
  2. volatile long vl = 0L; //使用volatile声明64位的long型变量
  3. public void set(long l){
  4. vl=l; //单个volatile变量的写
  5. }
  6. public void getAndIncrement(){
  7. vl++; //复合volatile变量的读/写
  8. }
  9. public long get(){
  10. return vl; //单个volatile变量的读
  11. }
  12. }

多线程调用上面的程序三个方法,在语义上和下面程序等同

  1. class VolatileFreaturesExample{
  2. long vl = 0L; //64位的long型普通变量
  3. public synchronized void set(long l){
  4. vl = l; //对单个的普通变量的写用同一个锁同步
  5. }
  6. public void getAndIncrement(){ //普通方法调用
  7. long temp = get(); //调用已同步的读方法
  8. temp += 1L; //普通写操作
  9. set(temp); //调用已同步的写方法
  10. }
  11. public synchronized long get(){
  12. return vl; //对单个的普通变量的读用同一个锁同步
  13. }
  14. }

一个volatile变量的单个读/写操作,与一个普通变量的读/写操作都是使用同一个锁来同步,它们之间的执行效果相同。
volatile变量具有下列特性:

  • 可见性 :对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入
  • 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这个复合操作不具有原子性

4.2 volatile写-读建立的happens-before关系

从内存语义来说,volatile的写-读与锁的释放-获取有相同的内存效果

4.3 volatile写-读的内存语义

volatile写的内存语言如下:

当写一个volatile变量是,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存

volatile读的内存语义如下:

当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量

下面对volatile写和volatile读的内存语义做个总结:

  • 线程A写一个volatile变量,实质是线程A向接下来将要读这个volatile变量的某个线程发出来(其对共享变量所做修改的)消息
  • 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息
  • 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息

4.4 volatile内存语义的实现

为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型

是否能重排序 第二个操作
第一个操作 普通读/写 volatile读 volatile写
普通读/写 NO
volatile读 NO NO NO
volatile写 NO NO

举例,第三行最后一个单元格的意思是:当第一个操作未普通变量的读或写时,如果第二个操作未wolatile写,则编译器不能重排序这两个操作

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。发现一个最优布置来最小化插入屏障的总数几乎不可能,所以JMM采用保守策略

  • 在每个volatile写操作的前面插入一个StoreStore屏障
  • 在每个volatile写操作的后面插入一个StroeLoad屏障
  • 在每个volatile读操作的后面插入一个LoadLoad屏障
  • 在每个volatile读操作的后面插入一个LoadStore屏障

image.png
为什么volatile写后面插入一个StoreLoad屏障而不是在volatile读前面插入一个StoreLoad屏障?

因为常见模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。

image.png
在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障

5.锁的内存语义

5.1 锁的释放-获得建立的happens-before关系

锁是Java并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。

5.2 锁的释放和获取的内存语义

当线程释放锁时,JMM会把线程对应的本地内存中的共享变量刷新到主内存中
当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量

锁释放与volatile写有相同的内存语义;锁获取与volatile读有相同的内存语义

  • 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改)消息
  • 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息
  • 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息

5.3 锁内存语义的实现

  • 公平锁和非公平锁释放时,最后都要写个volatile变量state
  • 公平锁获取时,首先会去读volatile变量
  • 非公平锁获取时,首先会用CAS更新volatile变量,这个操作同时具有volatile读和volatile写的内存语义

锁释放-获取的内存语义的实现至少有下面两种方式:

  1. 利用volatile变量的写-读所具有的内存语义
  2. 利用CAS所附带的volatile读和volatile写的内存语义

5.4 concurrent包的实现

首先,声明共享变量为volatile
然后,使用CAS的原子条件更新来实现线程之间的同步
同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信

6. final域的内存语义

final域的读和写更像是普通的变量访问

6.1 final域的重排序规则

  1. 在构造函数内堆一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序
  2. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序

6.2 写final域的重排序规则

写final域的重排序规则禁止把final域的写重排序到构造函数之外
实现包含两个方面:

  1. JMM禁止编译器把final域的写重排序到构造函数之外
  2. 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外

写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域不具有这个保障

6.3 读final域的重排序规则

在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作。编译器会在度final域操作的前面插入一个LoadLoad屏障。
确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。

6.4 final域为引用类型

对于引用类型,写final域的重排序规则对编译器增加了如下约束:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序
在X86处理器中,final域的读/写不会插入任何内存屏障

7 happens-before

7.1 JMM的设计

JMM把happens-before要求禁止的重排序分为了下面两类:

  • 会改变程序执行结果的重排序
  • 不会改变程序执行结果的重排序

JMM对这两种不同性质的重排序,采取了不同的策略:

  • 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序
  • 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM允许这种重排序)

image.png

  • JMM向程序员提供的happens-before规则能满足程序员的需求。JMM的happens-before规则不但简单易懂,而且也向程序员提供了足够强的内存可见性保证
  • JMM对编译器和处理器的束缚已经尽可能少

7.2 happens-beofre的定义

定义如下:

  1. 如果第一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前
  2. 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-beofre关系来执行的结果一致,那么这种重排序并不非法

7.3 happens-before规则

  1. 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作
  2. 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁
  3. volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读
  4. 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C
  5. start()规则:如果线程A执行操作ThreadB.start(),那么A线程ThreadB.start()操作happens-beofre于线程B中的任意操作
  6. join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回