一、 多线程隐患
1. 内存可见性
在赋值变量时,会经历数据由 CPU 写入到 内存 的过程,由于现代 CPU 一般都会有多级缓存,导致写指令可能并不能立即将数据写入到内存。如下图:
举个例子:
public class Demo {
private int a = 0;
public void write() {
a = 1;
}
public void read() {
Log.e("TAG", a);
}
}
线程 A 先 调用 write()
,线程 B 后 调用 read()
,返回的结果可能是 1 也有可能是 0,参考上图可知,线程 B 调用 read()
时,数据可能写入到了多级缓存里,而没有写入到内存中,导致读取到的值是 0。
多线程的程序中,一个线程写入的数据不能及时反映到另一个线程中,此时会说一个线程对变量的修改对另一个线程不可见,即 内存可见性。
放到 Java 里,对内存可见性又做了一层抽象:
JVM 规定所有变量都存在 主存 中,但是每个线程又有自己的 工作内存,线程的操作都是以工作内存为主,它们只能访问自己的工作内存,且工作前后都要把值在 同步回主内存。
即线程执行时,对于读操作,会先从主存中读值,然后赋值到工作内存的副本中,最后传给 CPU。对于写操作,CPU 会先写入到工作内存的副本,然后再传回给主存,此时主存才真正更新。即 Java 的内存可见性。
方便理解,主存即可当作内存可见性中的内存,工作内存即可视为内存可见性中的 CPU 多级缓存。
2. 指令重排
看个例子
public class Demo {
int a = 0;
boolean flag = false;
/**
* A线程执行
*/
public void writer(){
a = 1; // 1
flag = true; // 2
}
/**
* B线程执行
*/
public int read(){
if(flag){ // 3
return a; // 4
}
}
}
线程 A 先 调用 writer()
,线程 B 后 调用 read()
,因为指令重排的影响,read()
返回值可能是 0,也可能是 1。
为什么会这样呢?下面分析原因。
CPU 和编译器为了提升程序执行的效率,会按照一定的规则允许进行指令优化。举个例子:
int a = 1 ; //1
int b = 2 ; //2
int c = a + b; //3
由于语句 1 和语句 2 不存在依赖关系,因此在重排序时,语句 1、2 可以随意排序,只要总位于语句 3 前即可。
这在单线程是没有问题的,但是多线程情况下,就会有问题。比如指令重排一开始的例子,可能执行顺序为:
2 → 3 → 4 → 1 结果为 0.
可见指令重排在多线程情况下可能导致原语义的破坏。
二、 volatile 解析
正是因为多线程环境下存在 内存可见性、指令重排 等问题,所以诞生了 volatile 关键字。
首先对于 内存可见性 导致的问题,volatile 修饰的成员变量在每次被线程访问时,都强迫从主存中重读该成员变量的值。而且,当成员变量发生变化时,强迫线程将变化值回写到主存。这样在任何时刻,两个不同的线程总是看到某个成员变量的同一个值,这样也就保证了同步数据的 可见性。
然后对于 指令重排 导致的问题,volatile 做了如下规定:
- 在 volatile 变量的写入指令之前,对其它变量的读写指令不能重排到该指令之后。
- 在 volatile 变量的读取指令之后,对其它变量的读写指令不能重排到该指令之前。
还是上边的例子:
public class Demo {
int a = 0;
volatile boolean flag = false;
/**
* A线程执行
*/
public void writer(){
a = 1; // 1
flag = true; // 2
}
/**
* B线程执行
*/
public int read(){
if(flag){ // 3
return a; // 4
}
}
}
因为变量 flag 写入指令之前,其它变量的读写指令不能重排到该指令之后,所以语句 1 一定先语句 2 执行,那么 read()
结果一定为 1。
volatile 如何限制指令重排,涉及到内存屏障的知识点,不是本文重点。
三、 synchronized 与 volatile
既然说到 volatile,就必然提到几个与 synchronized 相关的问题。
1. synchronized 能防止指令重排序吗?
能,synchronized 保证原子性、有序性和可见性,只是代价高。
JVM 规定了
happens-before
规则,volatile 和 synchronized 可以防止指令重排序本质都是遵守此规则。关于happens-before
后续会单出一篇博客来说明,这里可以不深究。
2. double check 单例是否需要使用 volatile?
public class SingletonClass {
private static SingletonClass instance = null;
public static SingletonClass getInstance() {
if (instance == null) {
synchronized (SingletonClass.class) {
if (instance == null) {
instance = new SingletonClass();
}
}
}
return instance;
}
private SingletonClass() { }
}
因为第一个 if 没有包入到 synchronized 中,所以 synchronized 的有序性对第一个 if 是不生效的。
对于 new SingletonClass();
这个语句,并不是原子操作,可以分为以下三个步骤:
- 为 instance 分配内存;
- 调用构造函数初始化成员变量;
- 将 instance 指向分配的内存空间。
其中步骤 2、3 的顺序因为指令重排的原因,是不确定的。
这就可能发生如下情况:
- 线程 A 先将 instance 指向分配的内存空间,此时构造函数还未调用,就执行下一步。
- 线程 B 执行第一个 if 语句时,instance 不为空,于是返回、调用。但是实际上 instance 还未初始化,于是出错了。
将 instance 修饰为 volatile,实际上是保证了第一个 if 读的时候的有序性,防止了 instance 指令重排带来的隐患。
3. volatile 能保证原子性吗?
不能。举个例子:
// 定义
private volatile int a = 0;
private class IncreaseThread extends Thread {
@Override
public void run() {
super.run();
increase();
}
private void increase() {
a++;
}
}
// 执行
for (int i = 0; i < 100; i++) {
new IncreaseThread().start();
}
Log.e("TAG", "a is " + a);
a 的值总小于 100,原因是因为自增不具备原子性,它由三个字操作构成:
- 读取原始值;
- 进行加1操作;
- 写入工作内存。
有可能出现线程 A 读取原始值之后,阻塞,线程 B 再读取原始值,然后线程 B 加 1 后写入,线程 A 加 1 后写入,俩次自增,实际结果只加了 1。
参考链接
https://www.zhihu.com/question/37601861
https://lotabout.me/2019/Java-volatile-keyword/
https://juejin.im/post/5a2b53b7f265da432a7b821c
//www.greatytc.com/p/b4d4506d3585
[TOC]