作者:不才陈某

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

前言

在高并发的情况下,你的程序是不是经常出现一些诡异的BUG,每次都是花费大量时间排查,但是你有没有思考过这一切罪恶的源头是什么呢?

幕后那些事

CPU、内存、I/O设备的速度差异越来越大,这也是程序性能的瓶颈,根据木桶理论,最终决定程序的整体性能取决于最慢的操作-读写I/O设备,单方面的提高CPU的性能是无用的。为了平衡三者的差距,大牛前辈们不断努力,最终做出了卓越的贡献:CPU增加了缓存,平衡与内存之间的速度差异操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。注意:正是硬件前辈们做的这些贡献,额外的后果需要软件工程师来承担,太坑了。

坑一:CPU缓存导致的可见性问题

在单核CPU的时代,所有的线程都在单个CPU上执行,不存在CPU数据和内存的数据的一致性。 一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。 因为所有的线程都是在同一个CPU缓存中读写数据,一个线程对缓存的写,对于另外一个线程肯定是可见的。如下图:

并发编程中原子性、可见性、有序性这些特性的起源?_java


从上图可以很清楚的了解,线程A对于变量的修改都是在同一个CPU缓存中,则线程B肯定是可见的。但是多核时代的到来则意味着每个CPU上都有一个独立的缓存,信息不再互通了,此时保证内存和CPU缓存的一致性就很难了。如下图:

并发编程中原子性、可见性、有序性这些特性的起源?_缓存_02

从上图可以很清楚的了解,线程A和线程B对变量A的改变是不可见的,因为是在两个不同的CPU缓存中。最简单的证明方式则是在多核CPU的电脑上跑一个循环相加的方法,同时开启两个线程运行,最终得到的结果肯定不是正确的,如下:

public class TestThread {
private Long total=0L;
//循环一万次相加
private void add(){
for (int i = 0; i < 10000; i++) {
total+=1;
}
}

//开启两个线程相加
public static void calc() throws InterruptedException {
TestThread thread=new TestThread();
//创建两个线程
Thread thread1=new Thread(thread::add);
Thread thread2=new Thread(thread::add);

//启动线程
thread1.start();
thread2.start();

//阻塞主线程
thread1.join();
thread2.join();
System.out.println(thread.total);
}

上述代码在单核CPU的电脑上运行的结果肯定是20000,但是在多核CPU的电脑上运行的结果则是在1000020000之间,为什么呢?原因很简单,第一次在两个线程启动后,会将total=0读取到各自的CPU缓存中,执行total+1=0后,各自将得到的结果total=1写入到内存中(理想中应该是total=2),由于各自的CPU缓存中都有了值,因此每个线程都是基于各自CPU缓存中的值来计算,因此最终导致了写入内存中的值是在1000020000之间。注意:如果循环的次数很少,这种情况不是很明显,如果次数设置的越大,则结果越明显,因为两个线程不是同时启动的。

坑 二:线程切换导致的原子性问题

早期的操作系统是基于进程调度CPU,不同进程间是共享内存空间的,比如你在IDEA写代码的同时,能够打开QQ音乐,这个就是多进程。操作系统允许某个进程执行一段时间,比如40毫秒,过了这个时间则会选择另外一个进程,这个过程称之为任务切换,这个40毫秒称之为时间片,如下图:

并发编程中原子性、可见性、有序性这些特性的起源?_赋值_03


在一个时间片内,如果一个进程进行IO操作,比如读文件,这个时候该进程可以把自己标记为休眠状态并让出CPU的使用权,待文件读进内存,操作系统会将这个休眠的进程唤醒,唤醒后的进程就有机会重新获得CPU的使用权。现代的操作系统更加轻量级了,都是基于线程调度,现在提到的任务切换大都指示线程切换。注意:操作系统进行任务切换是基于CPU指令。 基于CPU指令是什么意思呢?Java作为高级编程语言,一条简单的语句可能底层就需要多条CPU指令,例如total+=1这条语句,至少需要三条CPU指令,如下:指令1:将total从内存读到CPU寄存器中指令2:在寄存器中执行+1指令3:将结果写入内存(缓存机制可能导致写入的是CPU缓存而不是内存) 基于CPU指令是什么意思呢?简单的说就是任务切换的时机可能是上面的任何一条指令完成之后。 我们假设在线程A执行了指令1后做了任务切换,此时线程B执行,虽然执行了total+1=1,但是最终的结果却不是2,如下图:

并发编程中原子性、可见性、有序性这些特性的起源?_面试_04


我们把一个或者多个操作在CPU执行过程中不被中断的特性称之为原子性。 注意:CPU仅仅能保证CPU指令执行的原子性,并不能保证高级语言的单条语句的原子性。 此处分享一道经典的面试题:Long类型的数据在32位操作系统中加减是否存在并发问题?答案:是,因为Long类型是64位,在32位的操作系统中执行加减肯定是要拆分成多个CPU指令,因此无法保证加减的原子性。

坑三:编译优化带来的有序性问题

编译优化算是最诡异的一个难题了,虽然高级语言规定了代码的执行顺序,但是编译器有时为了优化性能,则会改变代码执行的顺序,比如a=4;b=3;,在代码中可能给人直观的感受是a=4先执行,b=3后执行,但是编译器可能为了优化性能,先执行了b=3,这种对于我们肉眼是不可见的,上面例子中虽然不影响结果,但是有时候编译器的优化可能导致意想不到的BUG。双重校验锁实现单例不知大家有没有听说过,代码如下:

public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}

这里我去掉了volatile关键字,那么此时这个代码在并发的情况下有问题吗?上述代码看上去很完美,但是最大的问题就在new Singleton();这行代码上,预期中的new操作顺序如下:分配一块内存N在内存N上初始化Singleton对象将内存N的地址赋值给instance变量但是实际上编译优化后的执行顺序如下:分配一块内存N将内存N的地址赋值给instance变量在内存N上初始化Singleton对象很多人问了,优化后影响了什么?将内存N的地址提前赋值给instance变量意味着instance!=null是成立的,一旦是高并发的情况下,线程A执行第二步发生了任务切换,则线程B执行到了if (instance == null)这个判断,此时不成立,则直接返回了instance,但是此时的instance并没有初始化过,如果此时访问其中的成员变量则会发生空指针异常,执行流程如下图:

并发编程中原子性、可见性、有序性这些特性的起源?_缓存_05

总结

并发编程是区分高低手的门槛,只有深刻理解三大特性:可见性、原子性、有序性才能解决诡异的BUG。本文分析了带来这三大特性源头,如下:CPU缓存导致的可见性问题线程切换带来的原子性问题编译优化带来的有序性问题