第3章 Java内存模型

前言

本章主要通过Java的内存模型来讲解汇编代码的重排序现象,顺序一致性,happens-before原则,volatile内存语义和synchronized,final,锁,双重检查锁等在内存模型中的体现和原理表达。

第3章 Java内存模型

内存模型

内存模型中的顺序一致性,重排序以及顺序一致性内存模型

同步原语:synchronized,volatile,final

一、Java内存模型的基础

1.1. 并发编程模型的两个关键问题(通信、同步):

  • 线程之间如何通信
  • 线程之间如何同步

线程之间通信机制有两种:共享内存、消息传递

同步程序中用于控制不同线程间操作发生相对顺序的机制

两种并发模型:

  • 共享内存:(Java采用该并发模型)

    通信:线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。

    同步:显式进行,程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。

  • 消息传递

    通信:显式,线程之间没有公共状态,线程之间必须通过发送消息来显式通信。

    同步:隐式,由于消息发送必须先于接收,所以同步是隐式进行的。

Java采用的是共享内存模型

1.2. Java内存模型的抽象结构

Java内存模型的抽象结构示意图

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

抽象角度看:

线程之间的共享变量存储在主内存(Main Memory);每个线程都有一个私有的本地内存(抽象概念,不真实存在);本地内存中存储了该线程以读/写共享变量的副本

A—>B的通信步骤:

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

实质:线程A在向线程B发消息,而且必须经过主内存

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

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

为了提高执行程序的性能,编译器和处理器常常会对指令做重排序(3种)。

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

每个处理器写缓冲区仅对自己可见,这会导致处理器执行内存操作的顺序会与内存实际的操作执行顺序不一致。因此现代的处理器都会允许对写-读操作进行重排序。

JMM通过禁止某些编译器重排序(插入内存屏障)和处理器重排序,为程序员提供一致的内存可见性保证。

1.4. happens-before

Java的JSR-133内存模型使用happens-before的概念来阐述操作之间的内存可见性。

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

  • 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与JMM的关系:

二、重排序

重排序是指编译器和处理器优化程序性能而对指令序列进行重新排序的一种手段。

2.1. 数据依赖性

两个操作访问同一个变量,其中一个是写操作,则存在数据依赖性。

分为:

  • 写后读
  • 写后写
  • 读后写

上述三种情况,只要重排序两个操作,执行结果就会被改变。

编译器和处理器会遵守数据依赖性,不会改变存在数据依赖关系的两个操作的执行顺序(仅针对单个处理器和单个线程中的操作,不同处理器之间和不同线程之间的数据依赖性不被考虑)

2.2. as-if-serial语义

as-if-serial:不管怎么重排序,(单线程)程序的执行结果不能被改变。

编译器、runtime、处理器都必须遵守as-if-serial语义,因此它们不会对存在数据依赖关系的操作做重排序。

as-if-serial语义保护了单线程程序。使单线程程序员无需担心重排序会干扰到程序和内存可见性问题。

2.3. 程序顺序规则

软件和硬件技术准求的目标:在不改变程序执行结果的前提下,尽可能提高并行度

编译器和处理器遵从这一目标,JMM也同样遵从这一目标(happens-before)

2.4. 重排序对多线程的影响

控制依赖:(if(xxxx) yyyy; //x与y操作之间存在控制依赖关系)

当代码中存在控制依赖性时,会影响序列执行的并行度。编译器和处理器会采用猜测执行来客服控制相关性对并行度的影响。(猜测:在控制条件前提前读取数据并计算,将计算结果临时保存到名为重排序缓冲的硬件缓冲中)

单线程中对存在控制依赖的操作进行重排序不影响结果(所以as-if-serial语义允许对其重排序)

但在多线程中,对存在控制依赖的操作重排序可能会改变程序的执行结果

三、顺序一致性

3.1. JMM的内存一致性保证

JMM对正确同步的多线程程序的内存一致性做了如下保证:

如果程序是正确同步的,程序的执行将具有顺序一致性(执行结果与顺序一致性模型相同)//(这里的同步适用于同步原语synchronized、volatile、final的正确使用)。

3.2. 顺序一致性模型

顺序一致性模型是一个理想化的理论参考模型(实际上完全实现的代价很大),它为程序员提供了极强的内存可见性保证。

图3-2 顺序一致性内存模型的视图

顺序一致性内存模型的视图(如上图):任意时间点最多只有任意一个线程可以连接到内存,当多个线程并发执行时,能把所有线程的所有内存读写都串行化。

它有两大特性

  1. 单个线程中的所有操作必须按照程序的顺序来执行(JMM没有保证这点,临界区内可以重排序)

  2. (不管程序是否同步)所有线程都只看得到唯一的操作执行顺序,每个操作都必须原子执行立即对所有线程可见

    对此,JMM只保证同步程序的顺序一致性这是由Java内存模型决定的:在未同步程序中,不但整体的执行顺序是无序的,线程锁看到的操作执行顺序也可能不一致:A线程写到A本地内存后,自以为执行结束,实际上还没刷入主内存,这相对于B线程是不立即可见的,即看到的执行顺序不一致(这里要结合上面的JAVA内存模型思考)

    此外,JMM不保证对64位long和double变量的写操作具有原子性(读操作有原子性)(这是由总线带宽决定的,在32位处理器上,64位可能会被拆成2个32位执行。)(这点可以用锁来保证原子性)

3.3. Java同步程序的顺序一致性效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class SynchronizedExample {
int a = 0;
boolean flag = false;

public synchronized void writer() { //获得锁
a = 1;
flag = true;
} //释放锁

public synchronized void reader(){ //获得锁
if (flag) { //临界区,其内可以重排序(JMM会在临界区创建顺序一致性模型内存视图)
int i = a; //由于监视器互斥执行的特性,
...... //其他线程无法得知该线程访问临界区内的重排序
} //这种重排序能够提高执行效率而且保证执行结果
} //释放锁
}

3.4. 未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行读到的值,要么是之前某线程写入的值,要么是默认值(0,Null,False),JMM只保证线程读取到的值不会无中生有冒出来。

四、volatile

volatile可以看作为使用同一个锁对volatile变量进行单个读或写做了同步。

volatile可以告知程序任何对该变量的访问必须要从共享内存中获取,并且其修改必须同步刷新到共享内存,以保证该变量在线程之间的可见性。

volatile变量的特性

  • 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入
  • 仅对单一读/写有原子性:对任意单个volatile变量的读/写具有原子性(无论是64位还是32位数据),但类似于volatile++这种复合操作不具有原子性总体上不保证原子性

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

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

举例分析一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class VolatileExample{
int a = 0;
volatile boolean flag = false;

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

public void reader(){
if (flag) { //3
int i = a; //4
}
}
}

根据程序次序规则:1 happens-before 2; 3 happens-before 4

根据volatile规则:2 happens-before 3

根据happens-before的传递性:1 happens-before 4

也就是:

A写了一个volatile变量之后,B读同一个volatile变量。

A线程在写volatile变量之前所有可见的共享变量,在B线程读了同一个volatile变量之后,立即变得对B线程可见。(这句话可以用下面的内存语义结合内存模型理解)

  • (个人理解:写volatile变量即为解锁,读volatile即为加锁,因为任何线程都可以原子性地单独读/写volatile变量,所以volatile不会造成死锁)

3.2. volatile 写-读的内存语义(与锁的释放-获取内存语义相似)

volatile的内存语义:(同释放锁)

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

volatile的内存语义:(同获取锁)

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

3.2.1. 写-读volatile变量的内存语义实质:(与锁的释放-获取内存语义相似)

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

3.3. volatile 内存语义的实现

JMM对volatile制定的重排序规则:

  • volatile写之前的操作绝对不会重排序到其之后。
  • volatile读之后的操作不会重排序到其之前。
  • volatile写在volatile读之前时,不会重排序。

这些规则都是使用插入内存屏障来解决的(编译器会适当减少不必要、重复的内存屏障)。

五、锁的内存语义

锁可以让临界区互斥执行,还可以让释放锁的线程向获取同一个锁的线程发送消息。

1
2
3
4
5
6
7
8
9
10
class MonitorExample {
int a = 0;
public synchronized void writer() { //1 获得锁
a++; //2 执行临界区代码
} //3 释放锁

public synchronized void reader() { //4
int i = a; //5
} //6
}

假设线程A执行writer,然后线程B执行reader。根据happens-before原则推导得出:2 happends-before 5

因此,线程A在释放锁之前的所有可见的共享变量,在线程B获取同一个锁之后将立刻变得对B线程可见。

内存语义:(与volatile具有相同的内存语义)

  • 释放锁时,JMM把该线程的本地内存中的共享变量会刷新到主内存
  • 获得锁时,JMM将该线程对应的本地内存置为无效,并且从主内存中读取共享变量

实质上也是在通过主内存发送消息

5.3. ReentrantLock的实现方式(以此例讲锁的内存语义实现方式)

ReentrantLock的实现依赖于Java同步器框架AbstractQueuedSynchronizer(简称AQS)。AQS使用一个整形的volatile变量(state)来维护同步状态

1
2
3
4
5
6
7
8
9
10
11
12
class ReentrankLockExample {
int a = 0;
ReetrantLock lock = new ReetrantLock();
public void writer(){
lock.lock();
try {
a++;
} finally {
lock.unlock();
}
}
}
  • 公平锁:
    • 获取锁:读volatile变量
    • 释放锁:写volatile变量
  • 非公平锁:
    • 获取锁:用原子性的CAS(compareAndSet)操作更新volatile变量(CAS同时具有volatile读和写的内存语义
    • 释放锁:写volatile变量

5.4. 锁内存语义的两种实现方式

  • 利用volatile的写-读所具有的内存语义
  • 利用CAS所同时附带的volatile读和volatile写的内存语义

这两种方式可以实现线程之间的通信。

5.5. concurrent包的实现

  • A线程写volatile变量,随后B线程读这个volatile变量
  • A线程写volatile变量,随后B线程CAS更新这个volatile变量
  • A线程CAS更新volatile变量,随后B线程CAS更新这个volatile变量
  • A线程CAS更新volatile变量,随后B线程读这个volatile变量

(其实就是利用锁语义的实现方式做了个排列组合)

通用的实现模式:

  1. 声明共享变量为volatile;
  2. 使用CAS的原子条件更新来实现线程之间的同步;
  3. 配合以volatile读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。

六、final域的内存语义

与锁和volatile相比,对final域的读写更像是普通变量的访问。

6.1. final域的重排序规则

  • 写:禁止把final域的写重排序到构造函数之外。这可以保证在对象引用为任意线程可见之前,该对象的final域已经被正确初始化了(普通域不具有这个保证)
  • 读:初次读对象的引用 限定在 初次读该对象包含的final域 之前。(确保在读一个对象的final域之前,一定会先读包含了这个final域的对象的引用
  • 只要对象是正确构造的(不在构造函数中逸出),那么不需要使用同步(volatile和lock)就可以保证任意线程都可以看到这个final域在构造函数中被初始化之后的值。

6.2. 当final域为引用类型时

  • 对final域为引用类型的多线程读写时,就像对普通域一样读写,是存在数据竞争的,这时候需要用同步原语(lock和volatile)来确保内存可见性。

final引用不能从构造函数内溢出:

写final域的保证:这可以保证在对象引用为任意线程可见之前,该对象的final域已经被正确初始化了

前提:构造函数内部,不能让这个被构造函数的引用为其他线程可见,也就是说对象应用不能在构造函数中“溢出”。(因为此时的final域肯恩给还没有被初始化)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* 错误的构造示例 */
public class FinalReferenceEscapeExample {
final int i;
static FinalReferenceEscapeExample obj;

public FinalReferenceEscapeExample () {
i = 1; //写final域
obj = this; //this引用再次溢出了(对外可见了)这是不正确的构造
}

public static void writer() { //A线程写
new FinalReferenceEscapeExample(); //创建一个实例,调用了构造函数
}
public static void reader() { //B线程读
if (obj != null) {
int temp = obj.i;
}
}
}

在这里,A线程写调用构造函数返回前,该对象的引用就变得对线程B可见了,这可能会导致i在被初始化之前就被读取。(虽然final保证final的写不被重排序到构造函数之外,但构造函数结束之前就被暴露了出来,可能会被B线程在final域未被初始化前提前读取)。

七、happens-before

happens-before是JMM最核心的概念。对于程序员来说,理解happens-before是理解JMM的关键。

7.1. JMM的设计

图3-3 JMM的设计示意图

  • 程序员希望内存模型易于理解和编程,是个强内存模型。JMM对此向程序员提供happens-before规则保证了内存可见性。
  • 编译器和处理器对内存模型的实现希望束缚越少越好以提高性能,是个弱内存模型。JMM规定只要不改变程序(单线程)的执行结果,编译器和处理器怎么优化都行。

7.2. happens-before的定义

  • 如果一个操作happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。(JMM对程序员的保证)
  • 两个操作之间存在happens-before 关系,并不意味着执行顺序一定是happens-before指定的顺序。(JMM对编译器和处理器的保证)

7.3. as-if-serial

  • as-if-serial保证单线程内程序的执行结果不改变,happens-before关系保证了多线程程序执行结果不被改变。
  • as-if-serial让程序员感受到单线程程序是按照程序的顺序执行的,happens-before让程序员感觉正确同步的多线程程序是按照happens-before指定的顺序来执行的。

7.4. happens-before规则

  • 程序顺序规则:一个线程中的每个操作happens-before于该线程中的任意后续操作。(同一线程内顺序排序)
  • 监视器锁规则:对一个锁的解锁happens-before于随后对其加锁。(解锁—>加锁)(解锁的写入一定能被加锁的线程看到)
  • volatile变量规则:对一个volatile域的写happens-before于任意后续对这个volatile域的读。(写—>读)
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C
  • start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作
  • join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回

八、双重检查锁与延迟初始化

双重检查锁的出现是为了实现延迟初始化,从而来降低初始化类和创建对象的开销

8.1. 线程安全的延迟初始化(方法加锁,不推荐)

1
2
3
4
5
6
7
8
public class SaveLazyInitDemo {
private Helper helper = null;
public synchronized static Helper getHelper() { // 在方法上加锁,虽然线程安全,但性能开销大
if (helper == null)
helper = new Helper();
return helper;
}
}

8.2. 错误 的双重检查锁用法

1
2
3
4
5
6
7
8
9
10
11
12
class LazyInitDemo {							// 1
private Helper helper = null; // 2
public static Helper getHelper() { // 3
if (helper == null) { // 4:第一次检查
synchronized(LazyInitDemo.class) { // 5:加锁
if (helper == null) // 6:第二次检查
helper = new Helper(); // 7:产生实例:【问题就出在这】,错误的原因就是因为helper的线程间内存不可见,若helper以volatile声明可以解决这个问题
}
}
return helper; //8
}
}

8.2.1. 这个用法的问题

  • 4 当中检查helper的时候,可能不会为null(已经分配了内存空间),但是并没有完成初始化!

8.2.2. 原因

7 当中helper = new Helper()可以分解为:

  1. 分配对象内存空间
  2. 初始化对象
  3. 将helper赋值指向1中分配的内存空间

实际上2和3可能会被重排序:也就是说,helper已经被赋值了,但是可能还没完成helper对象的初始化!

这种情况下可能造成错误的场景:一个线程成功进入双重检查锁创建实例执行7语句,但是初始化和赋值被重排序了,此时另一个线程也进入getHelper方法,但是在第一次检查时,读到了未被初始化内存的引用。

8.2.3. 解决办法

  1. volatile声明helper:不允许2和3重排序(volatile写happen-before读)
  2. 基于类初始化:允许2和3重排序,但是要保证其他线程看不到被重排序过了(意思就是保证其他线程看到的是统一正确的执行结果,不管有没有被重排序都好,都是一样的)

8.3. 正确方案1:基于volatile(推荐)

做点小修改:将helper声明为volatile类型(利用volatile的写happen-before读保证内存可见性),本质上是禁止了实例化helper过程中初始化对象(写)和给helper赋值(读)的重排序

1
2
3
4
5
6
7
8
9
10
11
12
class LazyInitDemo {							// 1
private volatile Helper helper = null; // 2 变化在这!用volatile声明helper
public static Helper getHelper() { // 3
if (helper == null) { // 4:第一次检查
synchronized(LazyInitDemo.class) { // 5:加锁
if (helper == null) // 6:第二次检查
helper = new Helper(); // 7:产生实例
}
}
return helper; //8
}
}

8.4. 正确方案2:基于类初始化:

九、JMM综述

JMM是语言级的内存模型,处理器内存模型是硬件及的内存模型(更弱)。

9.1. JMM的内存可见性保证:

  • 单线程程序:单线程程序不会出现内存可见性问题。
  • 正确同步的多线程程序:正确同步的多线程程序的执行见具有顺序一致性(程序的执行结果与顺序一致性内存模型中的结果相同)(JMM用限制重排序来保证)
  • 未同步/未正确同步的多线程程序:最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false)

本文标题:第3章 Java内存模型

文章作者:Aaron.H

发布时间:2018年04月29日 - 20:04

最后更新:2018年09月18日 - 13:09

原始链接:https://uncleaaron.github.io/Blog/Java/Java并发编程艺术/第3章-Java内存模型/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。