可见性、原子性和有序性问题
# 1. 可见性、原子性和有序性问题:并发编程 Bug 的源头
这一节介绍并发编程大部分 Bug 的源头。
计算机的发展中,有一个核心矛盾:CPU、内存、IO 设备的速度差异。为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都做出了贡献,主要体现为:
- CPU 增加了缓存,以均衡与内存的速度差异;
- 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
- 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
而并发程序的很多诡异问题就根源于这里。
# 1.1 源头一:缓存导致的可见性问题
单核时代,所有线程都跑在一颗 CPU 上,因为所有线程都操作同一个 CPU 缓存,所以 CPU 缓存与内存的数据一致性容易解决,如下图:
这时,一个线程对共享变量的修改,另一个线程能够立刻看到,我们称之为可见性。
多核时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了:
这时,线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了。
# 1.2 源头二:线程切换带来的原子性问题
操作系统对所有进程的调度是分时调度,也就是允许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操作系统就会重新选择一个进程来执行(我们称为“任务切换”),这个 50 毫秒称为时间片。
例如一个进程在进行一个 IO 操作时,就可以把自己标为“休眠状态”并让出 CPU 使用权。
Java 并发是基于多线程的,这些线程也受到操作系统的调度。现代 Java 语言的一行代码会被编译为多条 CPU 指令完成,而操作系统的任务切换可能发生在任何一条 CPU 指令执行完时,这导致一行代码的执行被切分开执行了。
我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。
CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。
比如,Java 的 long 类型是 64 位的,当运行在 32 位的机器上时,long 类型变量的加减操作都会被转换成多个指令执行,这就有可能导致并发问题的出现。
# 1.3 源头三:编译优化带来的有序性问题
有序性是指程序按照代码的先后顺序执行。
编译器为了优化性能,有时会调整语句的执行顺序,但这可能导致并发时出现意想不到的 Bug。
在 Java 领域一个经典的案例就是利用双重检查创建单例对象,代码如下:
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
这段代码看上去没问题。但这个 getInstance()
方法并不完美,问题出在 new 操作上,我们以为的 new 操作是:
- 分配一块内存 M;
- 在内存 M 上初始化 Singleton 对象;
- 然后 M 的地址赋值给 instance 变量。
但实际上优化后的执行路径却可能是:
- 分配一块内存 M;
- 将 M 的地址赋值给 instance 变量;
- 最后在内存 M 上初始化 Singleton 对象。
这会导致什么问题呢?们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null
,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。
# 1.4 总结
并发程序经常出现的诡异问题看上去非常无厘头,但是深究的话,无外乎就是直觉欺骗了我们,只要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发 Bug 都是可以理解、可以诊断的。
# 2. Java 内存模型
上一节说到,在并发编程中,因可见性、原子性、有序性导致的问题常常会违背我们的直觉,从而成为并发编程的 Bug 之源。
Java 在诞生之初就支持多线程,并有针对这三者的技术方案。这一节介绍如何解决可见性和有序性导致的问题,也就是今天的主角 —— Java 内存模型。
# 2.1 什么是 Java 内存模型
导致可见性的原因是缓存,导致有序性的原因是编译优化,那合理的解决方案就是:按需禁用缓存以及编译优化。Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法,这些方法包括:volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则,这也正是本期的重点内容。
# 2.2 使用 volatile 的困惑
volatile 在 C 语言中就已存在,它最原始的意义就是禁用 CPU 缓存。
例如,我们声明一个 volatile 变量 volatile int x = 0,它表达的是:告诉编译器,对这个变量的读写,不能使用 CPU 缓存,必须从内存中读取或者写入。这个语义看上去相当明确,但是在实际使用的时候却会带来困惑。
例如下面的示例代码,假设线程 A 执行 writer() 方法,按照 volatile 语义,会把变量 “v=true” 写入内存;假设线程 B 执行 reader() 方法,同样按照 volatile 语义,线程 B 会从内存中读取变量 v,如果线程 B 看到 “v == true” 时,那么线程 B 看到的变量 x 是多少呢?
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
// 这里 x 会是多少呢?
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
直觉上看,应该是 42,那实际应该是多少呢?这个要看 Java 的版本,如果在低于 1.5 版本上运行,x 可能是 42,也有可能是 0;如果在 1.5 以上的版本上运行,x 就是等于 42。
我们可以猜到是因为变量 x 被 CPU 缓存而导致的可见性问题。我们只是禁用了 boolean v
变量的缓存优化,但 x 仍然面临可见性的问题。直到 Java 1.5 才解决了这个问题。因为 Java 内存模型在 1.5 版本对 volatile 语义进行了增强。怎么增强的呢?答案是一项 Happens-Before 规则。
# 2.3 Happens-Before 规则
Happens-Before 真正要表达的意思是:前面一个操作的结果对后续操作是可见的。就像有心灵感应的两个人,虽然远隔千里,一个人心之所想,另一个人都看得到。Happens-Before 规则就是要保证线程之间的这种“心灵感应”。
Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens-Before 规则。
Happens-Before 规则应该是 Java 内存模型里面最晦涩的内容了,和程序员相关的规则一共有如下六项,都是关于可见性的。下面分别介绍。
# 1)程序的顺序性规则
这条规则是指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。简单说就是,程序前面对某个变量的修改一定是对后续操作可见的。
# 2)volatile 变量规则
这条规则是指对一个 volatile 变量的写操作,Happens-Before 于后续对这个 volatile 变量的读操作。
这个就有点费解了,对一个 volatile 变量的写操作相对于后续对这个 volatile 变量的读操作可见,这怎么看都是禁用缓存的意思啊,貌似和 1.5 版本以前的语义没有变化啊?如果单看这个规则,的确是这样,但是如果我们关联一下规则 3,就有点不一样的感觉了。
# 3)传递性
这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。
我们将规则 3 的传递性应用到我们的例子中,会发生什么呢?可以看下面这幅图
如果线程 B 读到了“v=true”,那么线程 A 设置的“x=42”对线程 B 是可见的。也就是说,线程 B 能看到 “x == 42”。这就是 Java 1.5 版本对 volatile 语义的增强,这个增强意义重大,1.5 版本的并发工具包(java.util.concurrent)就是靠 volatile 语义来搞定可见性的。
# 4)管程中锁的规则
这条规则是指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。
管程是一种通用的同步原语,Java 中的 synchronized 就是对管程的实现。
管程中的锁在 Java 里是隐式实现的,例如下面的代码,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。
synchronized (this) { // 此处自动加锁
// x 是共享变量, 初始值 =10
if (this.x < 12) {
this.x = 12;
}
} // 此处自动解锁
2
3
4
5
6
所以结合规则 4——管程中锁的规则,可以这样理解:假设 x 的初始值是 10,线程 A 执行完代码块后 x 的值会变成 12(执行完自动释放锁),线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x==12。这个也是符合我们直觉的,应该不难理解。
# 5)线程 start() 规则
指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。
换句话说就是,如果线程 A 调用线程 B 的 start() 方法(即在线程 A 中启动线程 B),那么该 start() 操作 Happens-Before 于线程 B 中的任意操作。具体可参考下面示例代码:
Thread B = new Thread(()->{
// 主线程调用 B.start() 之前
// 所有对共享变量的修改,此处皆可见
// 此例中,var==77
});
// 此处对共享变量 var 修改
var = 77;
// 主线程启动子线程
B.start();
2
3
4
5
6
7
8
9
# 6)线程 join() 规则
它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。
换句话说就是,如果在线程 A 中,调用线程 B 的 join() 并成功返回,那么线程 B 中的任意操作 Happens-Before 于该 join() 操作的返回。具体可参考下面示例代码:
Thread B = new Thread(()->{
// 此处对共享变量 var 修改
var = 66;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程 B 可见
// 主线程启动子线程
B.start();
B.join()
// 子线程所有对共享变量的修改
// 在主线程调用 B.join() 之后皆可见
// 此例中,var==66
2
3
4
5
6
7
8
9
10
11
12
这里只讲了 6 个 happens-before 规则,其实还有两个:
- 线程中断规则:对线程 interrupt 方法的调用先行发生于被中断线程的代码检测到中断事件的发生。【先中断,后检测】
- 对象终结规则:一个对象的初始化完成先行发生于它的 finalize 方法的开始。
# 2.4 被我们忽视的 final
volatile 为的是禁用缓存和编译优化,而另一个关键字 final 修饰变量时,初衷就是告诉编译器:这个变量生而不变,可以可劲儿优化。
Java 编译器在 1.5 以前的版本的确优化得很努力,以至于都优化错了。导致的问题类似于之前双重检查锁创建单例时的问题,错误的指令重排可能会导致线程看到 final 变量的值发生变化。在 Java 1.5 以后,内存模型对 final 类型变量的重排进行了约束。现在只要我们提供正确构造函数没有“逸出”,就不会出问题了。
“逸出”有点抽象,我们还是举个例子吧,在下面例子中,在构造函数里面将 this 赋值给了全局变量 global.obj,这就是“逸出”,线程通过 global.obj 读取 x 是有可能读到 0 的。因此我们一定要避免 this 逸出:
final int x;
// 错误的构造函数
public FinalFieldExample() {
x = 3;
y = 4;
// 此处就是讲 this 逸出,
global.obj = this;
}
2
3
4
5
6
7
8
# 2.4 总结
Java 的内存模型是并发编程领域的一次重要创新,其中最晦涩的部分就是 Happens-Before 规则了。Happens-Before 规则最初是在一篇叫做 Time, Clocks, and the Ordering of Events in a Distributed System 的论文中提出来的,在这篇论文中,Happens-Before 的语义是一种因果关系。在 Java 语言里面,Happens-Before 的语义本质上是一种可见性。
Java 内存模型主要分为两部分,一部分面向你我这种编写并发程序的应用开发人员,另一部分是面向 JVM 的实现人员的,我们可以重点关注前者,也就是和编写并发程序相关的部分,这部分内容的核心就是 Happens-Before 规则。
# 3. 互斥锁:解决原子性问题
之前说过,一个或者多个操作在 CPU 执行的过程中不被中断的特性,称为“原子性”。
在单核 CPU 环境下,禁止 CPU 中断可以禁止线程切换,获得 CPU 使用权的线程就可以不间断地执行,所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性。
但在多核 CPU 环境下,不同线程可能在不同 CPU 上运行,所以即使禁止 CPU 中断也不能保证对一个变量的访问在同一时刻只有一个线程在执行。
“同一时刻只有一个线程执行”这个条件非常重要,我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核 CPU 还是多核 CPU,就都能保证原子性了。
# 3.1 简易锁模型
谈到互斥,就能想到一个杀手级的解决方案:锁。
很容易想到下面这个简易锁模型:
我们把一段需要互斥执行的代码称为临界区。
# 3.2 改进后的锁模型
上图没有体现一个重要关系:锁和要保护的资源的关联关系。你不能拿别人家的锁来保护你们家的东西。
改进后的锁模型如下:
这种关联关系特别重要。“自家门来保护他家资产”的 bug 很难诊断。
# 3.3 Java 提供的锁技术:synchronized
锁是一种通用的技术方案,Java 语言提供的 synchronized 关键字,就是锁的一种实现。
synchronized 可以修饰方法,也可以修饰代码块,示例:
class X {
// 修饰非静态方法
synchronized void foo() {
// 临界区
}
// 修饰静态方法
synchronized static void bar() {
// 临界区
}
// 修饰代码块
Object obj = new Object();
void baz() {
synchronized(obj) {
// 临界区
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
编译器会在 synchronized 修饰的方法或代码块前后自动加锁 lock()
和解锁 unlock()
。
注意,当 synchronized 修饰方法的时候,锁定的是什么呢?这是 Java 的一条隐式规则:
- 当修饰静态方法的时候,锁定的是当前类的 Class 对象
- 当修饰非静态方法的时候,锁定的是当前实例对象 this
# 3.4 使用 synchronized 解决 count += 1
我们知道,对于 long count
的类型来说,多个线程同时对其调用 count += 1
会出现并发问题,导致最后的结果不可预测,现在尝试使用 synchronized 来解决一下这个问题。
看一下下面这个写法:
class SafeCalc {
long value = 0L;
long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}
2
3
4
5
6
7
8
9
根据管程中的锁规则:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁因此每一次进入临界区修改 value 的结果对于其他线程再次进入临界区都是可见的,因此最终多次累加后的结果一定是正确的。
但 get()
方法还存在问题。管程中锁的规则,是只保证后续对这个锁的加锁的可见性,而 get() 方法并没有加锁操作,所以可见性没法保证。那如何解决呢?很简单,就是 get() 方法也 synchronized 一下,完整的代码如下所示:
class SafeCalc {
long value = 0L;
synchronized long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}
2
3
4
5
6
7
8
9
上面的代码转换为我们提到的锁模型,就是下面图示这个样子。get() 方法和 addOne() 方法都需要访问 value 这个受保护的资源,这个资源用 this 这把锁来保护。线程要进入临界区 get() 和 addOne(),必须先获得 this 这把锁,这样 get() 和 addOne() 也是互斥的:
# 3.5 锁和受保护资源的关系
受保护资源和锁之间的关联关系非常重要,一个合理的关系是:受保护资源和锁之间的关联关系是 N:1 的关系。也就是可以拿一把锁来保护多个对象,但最好不要用多把锁来保护一个对象,否则容易出问题。
比如我们把之前 count +=1 的例子稍微改动一下:
class SafeCalc {
static long value = 0L;
synchronized long get() {
return value;
}
synchronized static void addOne() {
value += 1;
}
}
2
3
4
5
6
7
8
9
观察一下就可以发现,改动后的代码其实是在用两个锁(this 和 SafeCalc.class)来保护同一个 value,用图来描述这个关系如下:
由于临界区 get() 和 addOne() 是用两个锁保护的,因此这两个临界区没有互斥关系,临界区 addOne() 对 value 的修改对临界区 get() 也没有可见性保证,这就导致并发问题了。
# 3.6 用一把锁保护多个资源
这一小节看一下转账业务,假设每个人的账户都是一个 Account
对象,其中有一个属性 balance
记录余额,这样每次转账都涉及到两个 account 对象的改变,对于有关联关系的多个资源,我们需要使用同一个把锁来保护。
这里最简单的一种方式就是对 Account.class 进行加一把大锁,这是所有 account 都能操作的锁。于是,代码如下:
class Account {
private int balance;
// 转账
void transfer(Account target, int amt){
synchronized(Account.class) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
下面这幅图很直观地展示了我们是如何使用共享的锁 Account.class 来保护不同对象的临界区的:
通过以上示例,我们可以更加理解“原子性”。原子性的本质就是:多个资源间有一致性的要求,操作的中间状态对外不可见。所以,解决原子性问题,是要保证中间状态对外不可见。