可见性,原子性,有序性,往往这些多线程的三要素都只会出现在高级编程知识中。并且涉及到了很多操作系统相关的知识,如果对操作系统不熟悉的话,就会遇见很多问题。
多线程编程经常会遇见一些玄学问题,所以编写正确的并发编程是一件很难的事情,而今天,就来重点说说并发编程bug的源头。
并发编程幕后的事情
这些年,CPU,内存,IO设备不断迭代,不断优化,但是还是有一个核心矛盾一直存在着,那就是这三者的速度差异。比如说,CPU执行一条普通指令需要一天,那么CPU读写内存要等待一年,而内存和IO设备的速度差异更大。
程序中大部分语句都要访问内存,有的时候还要访问IO,根据短板效应,导致程序的整体性能决定于IO设备的好坏,所以单纯提升CPU是无意义的。
为了合理提升CPU的性能,平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都作出了贡献,主要体现为:
- CPU增加了缓存,以均衡与内存的速度差异
- 操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与IO设备的速度差异
- 编译程序优化指令执行次序,使缓存更好的被使用
源头一:缓存导致的可见性问题
在单核时代,相当于所有的线程都是在一个CPU上执行,CPU缓存和内存数据一致性容易解决。因为所有线程都是操作同一个CPU的缓存,一个线程对缓存的写,对另一个线程来说肯定可见。比如,在线程A更新了V的值,那么B访问V,肯定是得到了最新值。这就称为可见性。
而在多核的情况下,上面所提到的可见性就不具备了,因为每个CPU都有自己的缓存,在不同的CPU上执行不同的线程,必然无法读到对方修改的数据。
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;
}}
程序执行出来之后,count表面上应该等于20000,实际上为10000,因为各自的CPU都有各自的缓存,更新数据都是基于自己CPU的缓存去更新,而不是基于主存。
源头二:线程切换带来的原子性问题
由于IO更新太慢,所以早期的操作系统就发明了多进程,让单核的CPU也可以同时做好多事情。
操作系统允许一个进程执行一段时间之后再去任务切换,执行另一个进程,而执行一个进程的时间长度就叫做时间片。
老一代的CPU,不同进程之间是不共享内存空间的,所以进程要做任务切换,就必须改变内存映射地址,但是所有的线程都是共享一个内存空间的,所以切换成本低,现在的CPU,说是切换任务,实际上就是切换线程,所以更加轻量了。
java的并发都是基于多线程开发的,所以就会涉及到任务切换,那么任务切换,往往就是一个很复杂的事情,容易因此产生原子性相关的bug。
比如,上面的count的累加,就需要三条cpu指令。
- 指令1:把count从内存加载到cpu的寄存器
- 指令2:在寄存器中进行+1操作
- 指令3:写入主存/缓存
所谓的任务切换,会有可能发生在任何一个指令结束后发生,比如在指令1结束之后,做线程切换,虽然,两个线程都执行了count++,但是结果却是1,而不是2。
而原子性,就是指令在执行的过程中,不会被中断的特性,就叫做原子性。而原子性的操作水平是CPU的指令级别的,而不是高级语言的层面。
源头三:编译优化带来的有序性问题
在程序的编译中,jvm会帮我们进行优化,比如说调整代码的执行顺序,这就会导致一定的有序性问题,比如说,原本我们的意思是让a=5,b=3,结果优化就变成了a=3,b=5,但是这个行为是不报错的,所以不影响程序的输出问题。
在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;
}
}
假设有两个线程AB,同时调用getInstance()方法,他们会发现intance == null,于是同时对Singleton.class加锁,此时JVM保证只有一个线程能够加锁成功(假设是线程A),另外一个线程就会处于阻塞状态(线程B),线程A会创建一个Singleton实例,然后释放锁,B被notify,这个时候B加锁,但是通过检查intance==null的时候已经不成立了,因为之前A已经实例化了,这就是单例模式。
但是实际上这个getIntance并不是使用的很好,众所周知,实例化的过程是三步:
- 分配一块内存M
- 在内存M上初始化Singleton对象
- M的地址赋值给intance变量
但是实际上优化后的执行顺序却是这样的:
- 分配一块内存M
- 将M的地址赋值给intance变量
- 在内存M上初始化Singleton对象
这样优化就会产生有序性的问题了:假设A执行完了getInstance,那么,指令2执行后进行线程切换,切换到B上,B也执行getInstance,这时因为instance已经被实例化了,所以不为null值,就能直接返回instance了,而此时的instance没有进行过初始化,所以访问会报空指针异常。
线程A进入第二个判空条件,进行初始化时,发生了 时间片切换,即使没有释放锁,线程B刚要进入第一个判空条件时,发现条件不成立,直接返回instance 引用,不用去获取锁。如果对instance进行volatile语义声明,就可以禁止指令重排序,避免该情况发生。 对于有些同学对CPU缓存和内存的疑问,CPU缓存不存在于内存中的,它是一块比内存更小、读写速度更 快的芯片,至于什么时候把数据从缓存写到内存,没有固定的时间,同样地,对于有volatile语义声明的 变量,线程A执行完后会强制将值刷新到内存中,线程B进行相关操作时会强制重新把内存中的内容写入 到自己的缓存,这就涉及到了volatile的写入屏障问题,当然也就是所谓happen-before问题。