Fork me on GitHub

Java并发编程的艺术(2)

Java内存模型

  • Java内存模型的基础
  • Java内存模型中的顺序一致性
  • 同步原语(synchronized、volatile、final)
  • Java内存模型的设计

Java内存模型的基础

并发编程中的两个关键问题:

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

命令式编程中,线程之间的通信机制有两种:共享内存消息传递

共享内存的并发模型中,线程之间共享程序的公共状态,通过读-写内存中的公共状态进行隐式通信
消息传递的并发模型中,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信

同步:程序中用于控制不同线程间操作发生相对顺序的机制
共享内存并发模型中,同步显式进行
消息传递的并发模型中,同步是隐式进行的。

Java的并发采用的是共享内存模型。

JMM抽象结构

Java内存模型研究的是线程共享–>JVM中的堆内存和方法区,而不是其他内存区域.

线程A与线程B通信步骤

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

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

重排序分为编译器重排序处理器重排序
处理器重排序又分为

  • 指令级并行重排序
  • 内存系统重排序

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

处理器重排序时,JMM通过插入特定类型的内存屏障指令来禁止特定类型的处理器重排序。

写缓冲区

  1. 保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟
  2. 通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中同一内存地址的多次写,减少对内存总线的占用。
  3. 每个处理器上的写缓冲区,仅仅对它所在的处理器可见,会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致。

内存屏障

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

happens-before

在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。

重排序

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

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

数据依赖的三种情况

  1. 写一个变量之后,再读这个变量a=1;b=a;
  2. 写一个变量之后,再写这个变量a=1;a=2;
  3. 读这个变量之后,再写这个变量a=b;b=1;

上面提到的三种情况,只要重排序两个操作的执行顺序,程序的执行结果就会发生改变

as-if-serial:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

重排序对多线程的影响

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

顺序一致性

数据竞争与顺序一致性

数据竞争:在一个线程中写一个变量,在另一个线程度同一个变量,而且写和读没有通过同步来排序

顺序一致性内存模型
两大特性:

  1. 一个线程中的所有操作必须按照程序的顺序来执行
  2. (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

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

同步程序顺序一致性

所有操作完全按程序的顺序串行执行

未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,False),JMM保证线程读操作读取到的值不会无中生有的冒出来。为实现最小安全性,JVM在堆上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象。

未同步程序在两个模型中的执行特性有以下三点差异

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

总线的工作机制可以把所有处理器对内存的访问以串行化的方式来执行。在任意时间点,最多只能有一个处理器可以访问内存,这个特性确保了单个总线事务之中的内存读/写操作具有原子性。

第3点,32位处理器上将64位的long或double拆分为两个32位来进行,这样这两个32位写操作可能就会分在不同的总线事务中执行,这样对这个64位的写操作就不具备原子性。下图为总线的工作机制

volatile的内存语义

volatile变量自身具有的特性

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

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

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

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。为此,JMM采取保守策略

  1. 在每个volatile写操作的前面插入一个StoreStore屏障
  2. 在每个volatile写操作的后面插入一个StoreLoad屏障
  3. 在每个volatile读操作的后面插入一个LoadLoad屏障
  4. 在每个volatile读操作的后面插入一个LoadStore屏障

StoreStore屏障保障上面所有的普通写在volatile写之前刷新到主内存中。

锁的内存语义

锁的两个功能:临界区互斥执行、锁的内存语义

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

  • 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了消息
  • 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的消息
  • 线程A释放锁,随后线程B获取这个锁,这个过程实质是线程A通过主内存向线程B发送消息

公平锁和非公平锁的内存语义

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

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

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

Java线程之间通信的4种方式

  1. A线程写volatile变量,随后B线程读这个volatile变量
  2. A线程写volatile变量,随后B线程用CAS更新这个volatile变量
  3. A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量
  4. A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量

final域的内存语义

final域,编译器和处理器要遵守两个重排序规则

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

写final域的重排序规则
写final域的重排序规则禁止把final域的写重排序到构造函数之外,实现包括:

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

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

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

读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。

happens-before

定义:

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

双重检查锁定与延迟初始化

双重检查锁定的来源

不安全的懒汉式

1
2
3
4
5
6
7
8
9
public class UnsafeLazyInitialization {
private static Instance instance;
public static Instance getInstance() {
if (instance == null){ //1:A线程执行
instance = new Instance();//2:B线程执行
}
return instance;
}
}

线程A执行代码1的同时,B线程执行代码2,这个时候线程A可能会看到instance引用的对象还没有完全初始化。

根源在于对象初始化重排序

进化为安全的懒汉式

1
2
3
4
5
6
7
8
9
public class SafeLazyInitialization {
private static Instance instance;

public synchronized static Instance getInstance() {
if (instance == null)
instance = new Instance();
return instance;
}
}

synchronized带来的问题就是导致性能开销

进化为双重锁定

1
2
3
4
5
6
7
8
9
10
11
12
13
public class DoubleCheckedLocking {                      //1
private static Instance instance; //2

public static Instance getInstance() { //3
if (instance == null) { //4:第一次检查
synchronized (DoubleCheckedLocking.class) { //5:加锁
if (instance == null) //6:第二次检查
instance = new Instance(); //7:问题的根源出在这里
} //8
} //9
return instance; //10
} //11
} //12

  • 多个线程试图在同一时刻创建对象时,会通过加锁来保证只有一个线程能创建对象
  • 在对象创建好之后,执行getInstance()方法将不需要获取锁,直接返回已创建好的对象

缺陷:在执行到第4行的时候,代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化

缺陷根源所在:
第7行代码instance = new Instance();可以分解为

1
2
3
memory = allocate();   //1:分配对象的内存空间
ctorInstance(memory); //2:初始化对象
instance = memory; //3:设置instance指向刚分配的内存地址

可能发生重排序,变为

1
2
3
4
memory = allocate();   //1:分配对象的内存空间
instance = memory; //3:设置instance指向刚分配的内存地址
//注意,此时对象还没有被初始化!
ctorInstance(memory); //2:初始化对象

这样就可能还没有完成初始化

解决方案

  1. 不允许2,3行代码进行重排序
  2. 允许2,3行代码重排序,但不允许其他线程”看到”这个重排序

基于volatile的解决方案

1
2
3
4
5
6
7
8
9
10
11
12
13
public class SafeDoubleCheckedLocking {
private volatile static Instance instance;

public static Instance getInstance() {
if (instance == null) {
synchronized (SafeDoubleCheckedLocking.class) {
if (instance == null)
instance = new Instance();//instance为volatile,现在没问题了
}
}
return instance;
}
}

基于类初始化的解决方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class InstanceFactory {
private static class InstanceHolder {
public static Instance instance = new Instance();
}

public static Instance getInstance() {
return InstanceHolder.instance ; //这里将导致InstanceHolder类被初始化
}
}
``` if (instance == null) { //4:第一次检查
synchronized (DoubleCheckedLocking.class) { //5:加锁
if (instance == null) //6:第二次检查
instance = new Instance(); //7:问题的根源出在这里
} //8
} //9
return instance; //10
} //11
} //12

  • 多个线程试图在同一时刻创建对象时,会通过加锁来保证只有一个线程能创建对象
  • 在对象创建好之后,执行getInstance()方法将不需要获取锁,直接返回已创建好的对象

缺陷:在执行到第4行的时候,代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化

缺陷根源所在:
第7行代码instance = new Instance();可以分解为

1
2
3
memory = allocate();   //1:分配对象的内存空间
ctorInstance(memory); //2:初始化对象
instance = memory; //3:设置instance指向刚分配的内存地址

可能发生重排序,变为

1
2
3
4
memory = allocate();   //1:分配对象的内存空间
instance = memory; //3:设置instance指向刚分配的内存地址
//注意,此时对象还没有被初始化!
ctorInstance(memory); //2:初始化对象

这样就可能还没有完成初始化

解决方案

  1. 不允许2,3行代码进行重排序
  2. 允许2,3行代码重排序,但不允许其他线程”看到”这个重排序

基于volatile的解决方案

1
2
3
4
5
6
7
8
9
10
11
12
13
public class SafeDoubleCheckedLocking {
private volatile static Instance instance;

public static Instance getInstance() {
if (instance == null) {
synchronized (SafeDoubleCheckedLocking.class) {
if (instance == null)
instance = new Instance();//instance为volatile,现在没问题了
}
}
return instance;
}
}

基于类初始化的解决方案

1
2
3
4
5
6
7
8
9
public class InstanceFactory {
private static class InstanceHolder {
public static Instance instance = new Instance();
}

public static Instance getInstance() {
return InstanceHolder.instance ; //这里将导致InstanceHolder类被初始化
}
}