可见性、原子性和有序性问题
并发编程背景
核心矛盾
这些年,我们的 CPU、内存、I/O 设备都在不断迭代,不断朝着更快的方向努力。但是,在这个快速发展的过程中,有一个核心矛盾一直存在,就是这三者的速度差异。
我形象的描述了一下这三者的速度上的差异:所谓天上一天地上一年(爱因斯坦的相对论是有合理解释的),CPU和内存之间的速度差异就是CPU天上一天,内存地上一年(假设 CPU 执行一条普通指令需要一天,那么 CPU 读写内存得等待一年的时间)。内存和I/O设备的速度差异就更大了,内存是天上一天,I/O设备是地上十年。
木桶原理
一只水桶能装多少水取决于它最短的那块木块
程序的大部分语句都要访问内存,有些还要访问I/O,根据木桶原理,程序整体的性能取决于最慢的操作,所以只是单方面的提高CPU的性能是无效的,才出现了一些提高CPU性能使用率的优化
如何提高CPU的性能?
- CPU增加了缓存,以均衡与内存的速度差异(计算机体系结构方面优化)
- 操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异(操作系统方面优化)
- 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用(编译程序带来的优化)
很多时候人们总是会很开心享受眼前的好处,却总是忽略了一句话:采用一项技术的同时,一定会产生一个新的问题,并发程序很多的诡异问题的根源也从这里开始
可见性
单核时期,所有的线程都是操作同一个CPU的,所以CPU的缓存也是线程之间可见的,如下图所示:
线程1和线程2都是编辑同一个CPU里面的缓存,所以线程1更新了变量A的值,线程2之后再访问变量A,得到的一定是A的最新值
一个线程对共享资源的修改,另一个线程能够立刻看到,称之为可见性
多核时代,每个CPU都有自己单独的缓存,当多个线程在不同的CPU上执行时,这些线程操作的就是不同的CPU缓存了,如下图所示:线程1在CPU-1的缓存上编辑变量A,线程2在CPU-2的缓存上编辑变量A,这个时候线程1对变量A的操作对于线程2来说就不具备可见性
二话不说,上代码解释是最直接的方式
下面这段代码创建了两个线程,每个线程都会调用一次updateVar
的方法,都会循环10000次的sharedVariable += 1
操作,然后打印出共享变量的结果。
/**
* 可见性测试Demo
*
* @author BO
* @date 2019-03-25
*/
public class VisibilityTest {
private long sharedVariable = 0;
private void updateVar() {
int times = 0;
while (times++ < 10000) {
sharedVariable += 1;
}
}
public static void main(String[] args) throws InterruptedException {
final VisibilityTest test = new VisibilityTest();
// 创建两个线程,执行修改方法
Thread thread1 = new Thread(() -> {
test.updateVar();
});
Thread thread2 = new Thread(() -> {
test.updateVar();
});
// 启动线程
thread1.start();
thread2.start();
// 等待两个线程执行结束
thread1.join();
thread2.join();
System.out.println("执行后共享变量的值为:" + test.sharedVariable);
}
}
讲道理,这里应该是要输出执行后共享变量的值为:20000
的,因为单线程里调用两次updateVar
方法,sharedVariable
的值就是20000,但实际上,我执行了n次(手都要点的酸死),执行的结果是10000个到20000之间的随机数。
我们假设线程1和线程2同时开始执行,那么第一次都会将sharedVariable = 0
读取到各自的CPU缓存里,执行了updateVar
方法后,各自缓存中的sharedVariable
的值都是1,同时写入内存后,内存中的sharedVariable
是1而不是我们讲道理的2,这就是缓存的可见性问题
这里因为两个线程的启动时有时差的,假设两个线程是同时执行的,这里返回的结果应该是10000
原子性
操作系统带来的多进程大家都体验过,我们可以一边听歌一边聊天就是多进程带来的好处
操作系统允许进程执行一段时间,假设100ms,过来100ms操作系统就会重新选择进程来执行,这个就是上面提到的分时服用CPU的原理,操作系统以100ms作为时间片进行任务切换
在一个时间片内,如果一个进程进行一个IO操作,加入读一个文件,这个时候进程可以把自己标记为“休眠状态”并让出CPU的使用权,等待文件读进内存,操作系统会把这个休眠的进程唤醒,唤醒后的进程就有机会重新获得CPU的使用权
这个就是分时复用CPU的过程,这样很好的提高了CPU的使用率
但是由于进程之间是不能进行内存空间的共享的,所以进程要做任务切换就要切换内存映射地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了,我们接下来讲的就是对于线程的任务切换,也就是线程切换
我们现在使用的都是高级编程语言,高级编程语言里一条语句需要多条CPU指令完成。举个简单的列子,上面的代码中,sharedVariable += 1
,在操作系统中至少需要三条CPU指令才能完成
- 把
sharedVariable
从内存加载到CPU的寄存器; - 在寄存器中执行 +1 操作
- 把结果写入内存(缓存机制可能导致写入到了CPU的缓存)
在操作系统中,进行任务切换会发生在任何一条CPU指令执行完,假设任务切换发生在第一步,如下图就是两个线程的执行过程
这个时候就导致本来是两次 +1 操作,结果到内存的实际值仍然是1
在化学上我们称化学反应不可再分的基本微粒成为原子,如果我们不熟悉操作系统的执行规则,我们会默认为sharedVariable += 1
就是一个原子,可能知道有线程切换会发生在这个操作之前,也或者这个操作之后,就是不会发生在这个操作中间,这就是我们经常忽略的原子性问题
所谓原子性,我们把一个或者多个操作在CPU执行的过程中不被中断的特性成为原子性,就像化学反应上的原子一样,是最小的单位了,不可分割
有序性
编译器为了性能的优化,有时候会改变程序中语句的先后顺序,有序性指的是程序按照代码的先后顺序执行,可能大家很疑惑这个怎么也会导致并发问题呢?请看下面的一个实例
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
上面这段代码是一个典型的双重检查创建实例对象,在获取实例getInstance
方法中,先判断了实例instance == null
,如果为空就锁定Singleton.class
类,然后再检查实例是否为空,如果还为空就创建一个Singleton
实例。
此时两个线程1和2同时调用getInstance
方法,它们会同时都发现了实例为空,于是同时对Singleton.class
类进行了加锁,这里JVM保证只有一个线程加锁成功(不要问我为什么,就是这样的),这里假设线程1成功获取到了锁,线程1就会去创建Singleton
实例,然后释放锁,线程2检查instance == null
时发现不为空,就不会再实例化了,这是不是很完美的代码。
但实际上,这里会出现一个很隐蔽的问题,问题就在这个new Singleton()
中,按照实例化的顺序,new操作是这样的:
- 分配一块内存区域M;
- 在内存M上初始化
Singleton
对象; - 然后M的地址赋值给
instance
变量。
但是实际上编译器认为第2步和第3步对结果并没有影响,所以根据实际情况进行了优化,变成了:
- 分配一块内存区域M;
- 然后M的地址赋值给
instance
变量。 - 在内存M上初始化
Singleton
对象;
这个时候如果线程1获取到锁后,开始实例化Singleton
对象,进行第2步操作结束后,操作系统进行了任务切换,此时线程2在进行第一个instance == null
检查时,发现instance
变量已经不为空,所以就直接返回了这个实例,但实际上这个实例还没有进行初始化,当程序中使用这个实例进行获取这个实例的成员变量时,就会出现NPE异常了,这个就是有序性导致的并发问题
总结
只要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发Bug都是可以解决、可以诊断的
实战
在 32 位的机器上对 long 型变量进行加减操作存在并发隐患,到底是不是这样的呢?
因为long型变量是64位,在32位CPU上执行写操作,会被分成两次写操作,所以这个操作并不是原子性的,如果线程1在完成第一次写操作后,出现线程切换,线程2将这个变量进行其他操作,就会导致线程1的这次操作存在问题