了解这个三个坑,不再怕诡异的BUG了

前言

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

幕后那些事

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

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

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

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

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

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

一个线程对共享变量的修改,另一个线程可以马上看到,咱们称为可见性。并发

由于全部的线程都是在同一个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 进阶免费获取。

相关文章
相关标签/搜索