并发编程 | 了解这些坑再也不会出现诡异的BUG了~

833 阅读7分钟

前言

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

幕后那些事

CPU内存I/O设备的速度差异越来越大,这也是程序性能的瓶颈,根据木桶理论,最终决定程序的整体性能取决于最慢的操作-读写I/O设备,单方面的提高CPU的性能是无用的。

为了平衡三者的差距,大牛前辈们不断努力,最终做出了卓越的贡献:

  1. CPU增加了缓存,平衡与内存之间的速度差异
  2. 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPUI/O 设备的速度差异;
  3. 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

注意:正是硬件前辈们做的这些贡献,额外的后果需要软件工程师来承担,太坑了。

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

在单核CPU的时代,所有的线程都在单个CPU上执行,不存在CPU数据和内存的数据的一致性。

一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。

因为所有的线程都是在同一个CPU缓存中读写数据,一个线程对缓存的写,对于另外一个线程肯定是可见的。如下图:

单核CPU与内存关系

从上图可以很清楚的了解,线程A对于变量的修改都是在同一个CPU缓存中,则线程B肯定是可见的。

但是多核时代的到来则意味着每个CPU上都有一个独立的缓存,信息不再互通了,此时保证内存和CPU缓存的一致性就很难了。如下图:

双核CPU与内存关系

从上图可以很清楚的了解,线程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的电脑上运行的结果则是在10000~20000之间,为什么呢?

原因很简单,第一次在两个线程启动后,会将total=0读取到各自的CPU缓存中,执行total+1=0后,各自将得到的结果total=1写入到内存中(理想中应该是total=2),由于各自的CPU缓存中都有了值,因此每个线程都是基于各自CPU缓存中的值来计算,因此最终导致了写入内存中的值是在10000~20000之间。

注意:如果循环的次数很少,这种情况不是很明显,如果次数设置的越大,则结果越明显,因为两个线程不是同时启动的。

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

早期的操作系统是基于进程调度CPU,不同进程间是共享内存空间的,比如你在IDEA写代码的同时,能够打开QQ音乐,这个就是多进程。

操作系统允许某个进程执行一段时间,比如40毫秒,过了这个时间则会选择另外一个进程,这个过程称之为任务切换,这个40毫秒称之为时间片,如下图:

任务切换

在一个时间片内,如果一个进程进行IO操作,比如读文件,这个时候该进程可以把自己标记为休眠状态并让出CPU的使用权,待文件读进内存,操作系统会将这个休眠的进程唤醒,唤醒后的进程就有机会重新获得CPU的使用权。

现代的操作系统更加轻量级了,都是基于线程调度,现在提到的任务切换大都指示线程切换

注意:操作系统进行任务切换是基于CPU指令

基于CPU指令是什么意思呢?Java作为高级编程语言,一条简单的语句可能底层就需要多条CPU指令,例如total+=1这条语句,至少需要三条CPU指令,如下:

  1. 指令1:将total从内存读到CPU寄存器中
  2. 指令2:在寄存器中执行+1
  3. 指令3:将结果写入内存(缓存机制可能导致写入的是CPU缓存而不是内存)

基于CPU指令是什么意思呢?简单的说就是任务切换的时机可能是上面的任何一条指令完成之后。

我们假设在线程A执行了指令1后做了任务切换,此时线程B执行,虽然执行了total+1=1,但是最终的结果却不是2,如下图:

非原子操作

我们把一个或者多个操作在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操作顺序如下:

  1. 分配一块内存N
  2. 在内存N上初始化Singleton对象
  3. 将内存N的地址赋值给instance变量

但是实际上编译优化后的执行顺序如下:

  1. 分配一块内存N
  2. 将内存N的地址赋值给instance变量
  3. 在内存N上初始化Singleton对象

很多人问了,优化后影响了什么?

将内存N的地址提前赋值给instance变量意味着instance!=null是成立的,一旦是高并发的情况下,线程A执行第二步发生了任务切换,则线程B执行到了 if (instance == null)这个判断,此时不成立,则直接返回了instance,但是此时的instance并没有初始化过,如果此时访问其中的成员变量则会发生空指针异常,执行流程如下图:

单例NPE

总结

并发编程是区分高低手的门槛,只有深刻理解三大特性:可见性原子性有序性才能解决诡异的BUG

本文分析了带来这三大特性源头,如下:

  1. CPU缓存导致的可见性问题
  2. 线程切换带来的``原子性问题
  3. 编译优化带来的有序性问题

另外,作者已经完成了两个专栏的文章Mybatis进阶Spring Boot 进阶 ,已经将专栏文章整理成书,有需要的公众号回复关键词Mybatis 进阶Spring Boot 进阶免费获取。