并发编程的幕后故事:
- CPU、内存、I/O设备都在不断迭代,不断朝更快的方向努力。但是,在这个快速发展的过程中,有一个核心矛盾一直存在,就是三者的速度差异,假设CPU执行一条普通指令需要一天,那么内存读写内存得等待一年。内存和I/O设备的的速度差异就更大了,内存一天,I/O设备是地上十年
- 根据木桶理论,程序整体性能取决于最慢的操作-读写I/O设备,也就是说单方面提高CPU性能是无效的。
- 为了平衡三者,计算机体系机构、操作系统、编译程序都做了贡献,主要体现为:
CPU增加了缓存,以均衡内存的速度差异;
操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异;
编译程序优化指令执行次序,使得缓存能够更加合理地利用。
源头之一:缓存导致的可见性问题
一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为有效性。
public class Test {
private static 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;
}
}
//运行结果随机:10000~20000之间
我们假设线程A和线程B同时开始执行,那么第一次都会将Count=0读到各自的CPU里,执行完count+1之后,各自CPU缓存里的值都是1,同时写入内存后,我们会发现内存中是1,而不是我们期望的2。之后由于各自的CPU缓存都有了count的值都是小于2000的。这就是缓存可见性问题
源头之二:线程切换带来的原子性问题
由于I/O太慢,早期的操作系统就发明了多线程,即使在单核的CPU上我们也可以一边听着歌,一边写代码,这都是多线程的功劳
操作系统允许某个进程执行一小片时间,例如五十毫秒,过了五十毫秒系统就会重新选择进程来执行我们称为“任务切换”,这五十毫秒称为“时间片”。
在上面代码中Count+=1至少需要三条cpu指令
指令1:首先,需要把变量count从内存加载到CPU的寄存器;
指令2:之后,在寄存器中执行+1操作;
指令3:最后,将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存)。
我们把一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性。
源头之三:编译带来的有序问题
经典的单例模式:
public class Singleton{
static Singleton instance;
static Singleton getInstance(){
if(instance==null){
synchronized(Singleton.class){
if(instance==null){
instance=new Singleton();
}
}
}
}
我们认为new操作应该是:
-
分配一块内存M
-
再内存上初始化Singletion变量
-
然后M的地址赋值给instance变量
但实际上路径是这样的: -
分配一块内存M
-
将M的地址赋值给Instance变量
-
最后在内存M上初始化Singleton对象
优化后导致什么问题呢?我们假设线程A先执行getInstance()方法,当执行完指令2时恰好发生了线程切换,切换到了线程B上;如果此时线程B也执行getInstance()方法,那么线程B在执行第一个判断时会发现instance!=null,所以直接返回instance,而此时的instance是没有初始化过的,如果我们这个时候访问instance的成员变量就可能触发空指针异常
总结
只要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发bug都是可以理解、可以诊断的。