近些年来,我们的 CPU、内存、I/O 设备都在不断迭代,但这三者的速度差异却越来越大。假设 CPU 执行一条普通指令需要一天,那么 CPU 读写内存得等待一年的时间,内存和 I/O 设备的速度差异就更大了。程序里大部分语句都要访问内存,有些还要访问 I/O。
根据木桶理论,程序整体的性能取决于最慢的操作——读写 I/O 设备,也就是说单方面提高 CPU 性能是无效的。为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都进行了一些优化,主要有:
- CPU 增加了高速缓存,以均衡与内存的速度差异;
- 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
- 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
但是天下没有免费的午餐,并发程序很多诡异问题的根源也在这里。
高速缓存导致的可见性问题
由于计算机的存储设备与处理器的运算速度有着几个数量级的差距,所以现代计算机系统都不得不加入一层或多层读写速度尽可能接近处理器运算速度的 高速缓存 来作为内存与处理器之间的缓冲:高速缓存负责从内存中加载运算需要使用的数据,在运算时可以进行快速访问,当运算结束后再从高速缓存同步回内存中,这样处理器就无须等待缓慢的内存读写来完成运算了。
高速缓存很好地解决了处理器与内存速度间的矛盾,但也引入了 缓存一致性 的问题。在多核处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的高速缓存数据不一致。比如,处理器对某个共享变量进行了修改,可能只是体现在该内核的高速缓存里,而运行在其他内核上的线程,可能还是加载的旧状态。
在单核时代,所有线程都是在一颗 CPU 上执行,CPU 缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个 CPU 的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
多核时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。比如下图中,线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-2 上的缓存,很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了。
下面再用一段代码来验证一下多核场景下的可见性问题。如下代码示例,在 calc() 方法中我们创建了两个线程,每个线程调用一次 add10K() 方法,我们来想一想执行 calc() 方法得到的结果应该是多少呢?
public class Test {
private long count = 0;
private void add10K() {
int idx = 0;
while(idx++ < 10000) {
count += 1;
}
}
public static long calc() {
final Test test = new Test();
// 创建两个线程,执行add()操作
Thread th1 = new Thread(()->{
test.add10K();
});
Thread th2 = new Thread(()->{
test.add10K();
});
// 启动两个线程
th1.start();
th2.start();
// 等待两个线程执行结束
th1.join();
th2.join();
return count;
}
}
直觉上应该是 20000,但实际上 calc() 的执行结果是个 10000~20000 之间的随机数。为什么呢?
我们假设线程 A 和线程 B 同时开始执行,第一次都会将 count=0 读到各自的 CPU 缓存里,执行完 count+=1 后,各自 CPU 缓存里的值都是 1,同时写入内存后,我们会发现内存中是 1 而不是我们期望的 2。之后由于各自的 CPU 缓存里都有了 count 的值,两个线程都是基于 CPU 缓存里的 count 值来计算,所以导致最终 count 的值都是小于 20000 的。这就是缓存的可见性问题。
扩展阅读:单处理器系统是否存在可见性问题?
可见性问题是多线程衍生出来的问题,它与程序的目标运行环境是单处理器还是多核处理器无关。也就是说,单处理器系统中实现的多线程编程也可能出现可见性问题。
在目标运行环境是单处理器的情况下,多线程的并发执行实际上是通过时间片分配实现的。此时,虽然多个线程是运行在同一个处理器上的,但是由于在发生上下文切换的时候,一个线程对寄存器(Register)变量的修改会被作为该线程的线程上下文保存起来,这导致另外一个线程无法看到该线程对这个变量的修改,因此,单处理器系统中实现的多线程编程也可能出现可见性问题。
线程切换带来的原子性问题
由于 IO 太慢,早期的操作系统就发明了多进程。操作系统允许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒后操作系统就会重新选择一个进程来执行(任务切换),这个 50 毫秒称为时间片。
在一个时间片内,如果一个进程进行一个 IO 操作,此时该进程可以把自己标记为“休眠状态”并让出 CPU 的使用权,待 IO 操作结束后,操作系统会唤醒这个休眠的进程,唤醒后的进程就有机会重新获得 CPU 使用权了。
早期的操作系统基于进程来调度 CPU,不同进程间是不共享内存空间的,所以进程要做任务切换就要切换内存映射地址,而一个进程创建的所有线程都是共享一个内存空间的,所以线程做任务切换成本就很低了。现代的操作系统都基于更轻量的线程来调度,现在我们提到的“任务切换”都是指“线程切换”。
Java 并发程序都是基于多线程的,自然也会涉及到任务切换。任务切换的时机大多数是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条 CPU 指令完成,例如上面代码中的 count += 1,至少需要三条 CPU 指令。
- 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
- 指令 2:之后,在寄存器中执行 +1 操作;
- 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
操作系统做任务切换,可以发生在任何一条 CPU 指令执行完,而不是高级语言里的一条语句。对于上面的三条指令,我们假设 count=0,如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的序列执行,那么我们会发现两个线程都执行了 count+=1 的操作,但得到的结果不是我们期望的 2,而是 1。
我们把一个或多个操作在 CPU 执行的过程中不被中断的特性称为原子性。CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。
理解原子操作这个概念还需要注意以下两点:
- 原子操作是针对访问共享变量的操作而言的。也就是说,仅涉及局部变量访问的操作无所谓是否是原子的,或者干脆把这一类操作都看成原子操作。
- 原子操作是从该操作的执行线程以外的线程来描述的,也就是说它只有在多线程环境下有意义。换言之,单线程环境下一个操作无所谓是否具有原子性,或者干脆把这一类操作都看成原子操作。
编译优化带来的有序性问题
有序性指的是程序按代码的先后顺序执行。编译器为了优化性能,使处理器内部的运算单元能被充分利用,有时会改变程序中语句的先后顺序,即会对源代码进行 乱序执行 的优化,但是会保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致。因此,有时候编译器及解释器的优化可能导致意想不到的 Bug。
Java 平台包含两种编译器:静态编译器( javac )和动态编译器( JIT 编译器)
- 前者将 .java 文件编译为 .class 二进制字节码文件,它是在代码编译阶段介入的,基本上不会执行指令重排序。
- 后者将字节码动态编译为 Java 虚拟机的本地代码(机器码),它是在 Java 程序运行过程中介入的,处于性能考虑,在其认为不影响程序(单线程)正确性的前提下可能会对源代码顺序进行调整,从而造成程序顺序与相应的源代码顺序不一致。
现代处理器为了提高指令的执行效率,往往不是按照程序顺序逐一执行指令的,而是动态调整指令的顺序,做到哪条指令就绪就先执行哪条指令(保证单线程程序的正确性),因此会导致 CPU 指令的重排序。
在 Java 领域一个经典的案例就是利用双重检查创建单例对象,如下代码示例:在获取实例 getInstance() 的方法中,我们首先判断 instance 是否为空,为空则锁定 Singleton.class 并再次检查 instance 是否为空,如果还为空则创建 Singleton 的一个实例。
public class Singleton {
static Singleton instance;
static Singleton getInstance() {
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
假设有两个线程 A、B 同时调用 getInstance() 方法,他们会同时发现 instance == null ,于是同时对 Singleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会 new 一个 Singleton 实例后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。
但实际上这个 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 的成员变量就可能触发空指针异常。
对于上面这个问题,加 volatile 是可以解决的,之所以 new 操作中三条指令中的赋值操作会放到初始化操作指令之前,这本身是编译器优化的结果,因为这样能节省执行时间,但是却带来了空指针异常问题,所以加 volatile 禁止把赋值操作重排序到初始化操作之前,原理其实就是内存屏障,内存屏障也是靠 CPU 指令来实现的,这样保证了,先赋值操作,然后在把地址值赋给变量,就解决了空指针问题。