目录
- 双重检测锁的演变过程
- 利用 HappensBefore 分析并发问题
- 无 volatile 的双重检测锁
双重检测锁的演变过程
synchronized 修饰方法的单例模式
双重检测锁的最初形态是通过在方法声明的部分加上 synchronized 进行同步,保证同一时间调用方法的线程只有一个,从而保证 new Singlton()
的线程安全:
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static synchronized Singleton getInstance() {if (instance == null) {instance = new Singleton();
}
return instance;
}
}
这样做的好处是代码简单、并且 JVM 保证 new Singlton()
这行代码线程安全。但是付出的代价有点高昂:
所有的线程的每一次调用都是同步调用,性能开销很大,而且 new Singlton()
只会执行一次,不需要每一次都进行同步。
既然只需要在 new Singlton()
时进行同步,那么把 synchronized
的同步范围缩小呢?
线程不安全的双重检测锁
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {if (instance == null) {synchronized (Singleton.class) {if (instance == null) {instance = new Singleton();
}
}
}
return instance;
}
}
把 synchronized
同步的范围缩小以后,貌似是解决了每次调用都需要进行同步而导致的性能开销的问题。但是有引入了新的问题:线程不安全,返回的对象可能还没有初始化。
深入到字节码的层面来看看下面这段代码:
instance = new Singleton()
returen instance;
正常情况下 JVM 编译成成字节码,它是这样的:
step.1 new:开辟一块内存空间
step.2 invokespecial:执行初始化方法,对内存进行初始化
step.3 putstatic:将该内存空间的引用赋值给 instance
step.4 areturn:方法执行结束,返回 instance
当然这里限定在正常情况下,在特殊情况下也可以编译成这样:
step.1 new:开辟一块内存空间
step.3 putstatic:将该内存空间的引用赋值给 instance
step.2 invokespecial:执行初始化方法,对内存进行初始化
step.4 areturn:方法执行结束,返回 instance
步骤 2 和步骤 3 进行了调换:先执行步骤 3 再执行步骤 2。
- 如果只有一个线程调用是没有问题的:因为不管步骤如何调换,JVM 保证返回的对象是已经构造好了。
- 如果同时有多个线程调用,那么部分调用线程返回的对象有可能是没有构造好的对象。
这种特殊情况称之为:指令重排序
:CPU 采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。当然不是乱排序,重排序保证 CPU 能够正确处理指令依赖情况以保障程序能够得出正确的执行结果。
利用 HappensBefore 分析并发问题
什么是 HappensBefore
HappensBefore
:先行发生,是
- 判断数据是否存在竞争、线程是否安全的重要依据
- A happens-beforeB, 那么 A 对 B 可见(A 做的操作对 B 可见)
- 是一种偏序关系。hb(a,b),hb(b,c) => hb(a,c)
换句话说,可以通过 HappensBefore 推断代码在多线程下是否线程安全
举一个《深入理解 Java 虚拟机》上的例子:
// 以下操作在线程 A 中执行
int i = 1;
// 以下操作在线程 B 中执行
j = i;
// 以下操作在线程 C 中执行
i = 2;
如果 hb(i=1
,j=i
),那么可以确定变量 j 的值一定等于 1。得出这个结论的依据有两个:
- 根据 HappensBefore 的规则,
i=1
的结果可以被j=i
观察到 - 线程 C 还没有登场
如果线程 C 的执行时间在线程 A 和线程 B 之间,那么 j
的值是多少呢?答案是不确定!因为线程 C 和线程 B 之间没有 HappensBefore 的关系:线程 C 对变量的 i
的更改可能被线程 B 观察到也可能不会!
HappensBefore 关系
这些是“天然的”、JVM 保证的 HappensBefore 关系:
- 程序次序规则
- 管程锁定规则
- volatile 变量规则
- 线程启动规则
- 线程终止规则
- 线程中断规则
- 对象终结规则
- 传递性
重点介绍 程序次序规则
, 管程锁定规则
,volatile 变量规则
, 传递性
,后面分析需要用到这四个性质:
- 程序次序规则:在一个线程内,按照程序控制流顺序,书写在前面的操作 HappensBefore 书写在后面的操作
- 管程锁定规则:对于同一个锁来说,在时间顺序上,上一个 unlock 操作 HappensBefore 下一个 lock 操作
- volatile 变量规则:对于一个 volatile 修饰的变量,在时间顺序上,写操作 HappensBefore 读操作
- 传递性:hb(a,b),hb(b,c) => hb(a,c)
分析之前线程不安全的双重检测锁
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {if (instance == null) { //1
synchronized (Singleton.class) { //2
if (instance == null) { //3
instance = new Singleton(); //4
new //4.1
invokespecial //4.2
pustatic //4.3
}
}
}
return instance; //5
}
}
经过上面的讨论,已经知道因为 JVM 重排序导致 代码 4.2
提前执行了,导致后面一个线程执行 代码 1
返回的值为 false,进而直接返回了还没有构造好的 instance 对象:
线程 1 | 线程 2 |
---|---|
1 | |
2 | |
3 | |
4.1 | |
4.3 | |
1 | |
5 | |
4.2 | |
5 |
通过表格,可能清晰看到问题所在:线程 1 代码 4.3 执行后,线程 2 执行代码 1 读到了脏数据。要想不读到脏数据,只要证明存在 hb(T1-4.3,T2-1)(T1- 4 表示线程 1 代码 4,T2- 1 表示线程 2 代码 1,下同),那么是否存在呢?很遗憾,不存在:
- 程序次序规则:不在同一个线程
- 管程锁定规则:线程 2 没有尝试 lock
- volatile 变量规则:instance 对象没有通过 volatile 关键字修饰
- 传递性:不存在
用 HappensBefore 分析,可以很清晰、明确看到没有 volatile 修饰的双重检测锁是线程不安全的。但,真的是这样的吗?
无 volatile 的双重检测锁
在第二部分,通过 HappensBefore 分析没有 volatile 修饰的双重检测锁是线程不安全,那只有用 volatile 修饰的双重检测锁才是线程安全的吗?答案是否定的。
用 volatile 关键字修饰的本质是想利用 volatile 变量规则
,使得写操作(T1-4)HappensBefore 读操作(T2-1),那只要另找一条 HappensBefore 规则保证即可。答案是 程序次序规则
和管程锁定规则
先看代码:
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {if (instance == null) { //1
synchronized (Singleton.class) { //2
if (instance == null) { //3
Singleton temp = new Singleton(); //4
temp.toString(); //5
instance = temp; //6
}
}
}
return instance; //7
}
}
在原有的基础上加了两行代码:
instance = new Singleton(); //4
Singleton temp = new Singleton(); //4
temp.toString(); //5
instance = temp; //6
为什么要这么做?
通过管程锁定规则保证执行到 代码 6
时,temp 对象已经构造好了。想一想,为什么?
- 其他线程执行代码 1 时,如果能够观察到 T1- 6 的写操作,那么直接返回 instance 对象
- 如果没有观察到 T1- 6 的写操作,那么尝试获取锁,此时
管程锁定规则
开始生效:保证当前线程一定能够观察到 T1- 6 操作
执行流程可能是这样的:
线程 1 | 线程 2 | 线程 3 |
---|---|---|
1 | ||
1 | ||
2 | ||
3 | ||
4 | ||
5 | ||
6 | ||
2 | ||
3 | ||
1 | 7 | |
7 | ||
7 |
无论怎样执行,其他线程都能够观察到 T1- 6 的写操作
其他
volatile、synchronized 为什么可以禁止 JVM 重排序
内存屏障。
JVM 在凡是有 volatile、synchronized 出现的地方都加了一道内存屏障:重排序时,不可以把内存屏障后面的指令重排序到内存屏障前面执行,并且会及时的将线程工作内存中的数据及时更新到主内存中,进而使得其他的线程能够观察到最新的数据
参考资料
- 《深入理解 Java 虚拟机》