1.Java内存模型的基础
1.1 并发编程模型的两个关键问题
-
线程之间如何通信:
-
通信是指线程之间以何种机制来交换信息
-
通信机制有两种:共享内存和消息传递
-
-
线程之间如何同步:
- 同步:指程序中用于控制不同线程间操作发生相对顺序的机制
1.2 Java内存模型的抽象结构
- Java线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见
- 线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存(抽象概念),本地内存中存储了该线程以读/写共享变量的副本
- JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证
- 下图中两个线程要通信,要经历下面2个步骤:
- 线程A把本地内存A中更新过的共享变量刷新到主内存中去。
- 线程B到主内存中去读取线程A之前已更新过的共享变量
1.3 从源代码到指令序列的重排序
-
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序
-
重排序分3种类型,第一种属于编译器重排,后两种属于处理器重排:
- 编译器优化的重排序
- 指令级并行的重排序
- 内存系统的重排序
-
JMM通过禁止特定类型的编译器重排序和处理器重排序,提供内存可见性保证
由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致
1.4 happens-before简介
-
JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在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
对于下面代码,假设线程A执行writer()方法,随后线程B执行reader()方法:
- 根据程序顺序规则:1 happens-before 2,2 happens-before 3;4 happens-before 5,5 happens before 6
- 根据监视器锁规则:3 happens-before 4
- 根据传递性:2 happens-before 5
class MonitorExample {
int a = 0;
public synchronized void writer() { // 1
a++; // 2
} // 3
public synchronized void reader() { // 4
int i = a; // 5
……
} // 6
}
2.重排序
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段
2.1 数据依赖性
- 如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间
就存在数据依赖性 - 数据依赖可分为:读后写、写后读、写后写
- 编译器和处理器在重排序时,会遵守数据依赖性(即编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序)
- 数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑
2.2 as-if-serial语义
- 不管怎么重排序,单线程的程序的执行结果不能被改变
int a = 10;
int b = 20;
int c = a + b;
执行顺序可以是a->b->c,也可以是b->a->c
- 为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序(因
为这种重排序会改变执行结果) - as-if-serial语义使程序员无需担心重排序会干扰单线程,也无需担心内存可见性问题
2.3 重排序对多线程的影响
假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法
class ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; // 1
flag = true; // 2
}
Public void reader() {
if (flag) { // 3
int i = a * a; // 4
……
}
}
上面代码中,操作1和操作2没有数据依赖关系,且操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两对操作重排序:
- 对线程A的两个操作进行重排序:
- 线程A首先写标记变量flag
- 随后线程B读这个变量。由于条件判断为真,线程B将读取变量a
- 此时,变量a还没有被线程A写入,多线程程序的语义被重排序破坏
- 对线程B的两个操作进行重排序:
- 由于操作3和操作4存在控制依赖关系,执行线程B的处理器可提前读取并计算a*a
- 然后把计算结果临时保存到一个名为重排序缓冲(Reorder Buffer,ROB)的硬件缓存中
- 当操作3的条件判断为真时,就把该计算结果写入变量i中,多线程程序的语义被重排序破坏
3.顺序一致性
处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照
3.1 数据竞争与顺序一致性
-
数据竞争的定义:在一个线程中写一个变量,在另一个线程读同一个变量,而且写和读没有通过同步来排序
-
顺序一致性:如果程序是正确同步的,程序的执行将具有顺序一致性(即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)
3.2 顺序一致性内存模型
假设有两个线程A和B并发执行。其中A线程有3个操作,它们在程序中的顺序是A1→A2→A3。B线程也有3个操作,它们在程序中的顺序是B1→B2→B3
- 当A和B线程使用了锁进行同步:
- 当A和B线程没有进行同步:未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序(即A和B看到的执行顺序为B1→A1→A2→B2→A3→B3),因为顺序一致性内存模型中的每个操作必须立即对任意线程可见
JMM中没有上述保证。未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致
3.3 同步程序的顺序一致性效果
顺序一致性模型中,所有操作完全按程序的顺序串行执行。在JMM中,临界区内的代码可以重排序(因为JMM目的是在不改变程序执行结果的前提下,尽可能优化编译器和处理器)
3.4 未同步程序的执行特性
JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为如果想要保证执行结果一致,JMM需要禁止大量的处理器和编译器的优化,这对程序的执行性能会产生很大的影响
4.volatile的内存语义
4.1 volatile的特性
把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步
- 可见性:对一个volatile变量的读,总是能看到任意线程对这个volatile变量最后的写入
- 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile变量++这种复合操作不
具有原子性(可以认为不具备原子性)
4.2 volatile写-读的内存语义
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存
- 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量
为了实现volatile的内存语义,编译器在生成字节码时会在指令序列中插入内存屏障来禁止特定类型的处理器重排序
5.锁的内存语义
5.1 锁的释放-获取建立的happens-before关系
以1.4节中的线程A和B为例,因为2 happens-before 5,所以线程B获取同一个锁之后,共享变量将立刻变得对B线程可见
5.2 锁的释放和获取的内存语义
锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息
-
线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了消息(即线程A
对共享变量做出修改) -
线程B获取一个锁,实质上是线程B接收了之前某个线程发出的消息(即在释放这个锁之前对共
享变量做出修改)
6.happens-before
6.1 JMM的设计
- JMM对两种不同性质的重排序(即会改变程序结果的重排序和不会改变程序结果的重排序),采取不同的策略:
- 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
- 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求
- 只要不改变程序的执行结果,编译器和处理器怎么优化都行:
- 编译器经过分析后,认定一个锁只会被单个线程访问,则该锁可以被消除
- 编译器经过分析后,认定一个volatile变量只会被单个线程访问,则编译器可以把该变量当作一个普通变量
6.2 happens-before的定义
-
happens-before的概念来指定两个操作之间的执行顺序(这两个操作可以在一个线程之内,也可以在不同线程之间)
-
两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行(假如重排序后执行结果一致也是可以的)
-
as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变
6.3 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()
,则A线程的ThreadB.start()
操作happens-before于线程B中的任意操作 - join()规则:如果线程A执行操作
ThreadB.join()
并成功返回,则线程B中的任意操作happens-before于线程A从ThreadB.join()
操作成功返回。
7.双重检查锁定与延迟初始化
双重检查锁定1:
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
}
在某个线程执行到第4行,代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化。因为
instance = new Instance();
内部可能会发生重排序
双重检查锁定2:使用volatile禁止A2和A3的重排序
public class SafeDoubleCheckedLocking {
private volatile static Instance instance;
public static Instance getInstance() {
if (instance == null) {
synchronized (SafeDoubleCheckedLocking.class) {
if (instance == null)
instance = new Instance();
}
}
return instance;
}
}
可以使用类初始化的方案解决双重检查锁定1中的问题:因为初始化实际是执行方法,该方法线程安全
public class InstanceFactory {
private static class InstanceHolder {
public static Instance instance = new Instance();
}
public static Instance getInstance() {
return InstanceHolder.instance ;
}
}