摘录自 极客时间

Java并发BUG的来源

CPU、内存、I/O设备的速度不匹配问题一直存在,为解决速度的差异,计算机有如下三个优化:

  • CPU增加了缓存(均衡CPU和内存)

  • 操作系统增加了线程和进程(缓解CPU和I/O)

  • 编译程序优化指令执行次序(使缓存更有效执行)

  • 这三个解决方案也为并发带来了很多麻烦。并发编程的BUG来自于以下三部分:

  • 缓存可见性问题
    单核时代,所有线程在一颗CPU上执行,CPU缓存与内存的数据一致性容易解决。因为所有线程操作同一个CPU的缓存,一个线程对缓存的写,对另外一个线程是可见的,这就是可见性。
    但是多核时代每颗CPU有自己的缓存,这时数据一致性就很难解决,这些线程操作的是不同的CPU缓存。这时如果两个线程同时操作自己的缓存,两线程之间就不具备可见性。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    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;
    }
    }

    结果本来应该是20000,但实际情况是10000到20000之间的随机数,这个差异是因为两个线程启动时间不同造成的。

  • 线程切换带来的原子性问题

    在java中,对一个count += 1这样的语句,CPU需要执行如下三条语句:

    • 把变量count从内存加载到CPU寄存器
    • 在寄存器中执行+1操作
    • 将结果写入内存

    操作系统做任务切换,可以发生在任何一条CPU指令执行完,如果两个线程同时执行count+=1,那么结果极有可能还是1,这是因为因为非原子性的操作可能发生如下情况

    image-20190404230048449

  • 编译优化带来的有序性问题

    编译器为了优化性能,有时候会改变程序中语句的先后执行顺序,可能a=6,b=7最后会变成a=7,b=6,这也可能导致bug。

    例如下列代码:在获取实例 getInstance() 的方法中,我们首先判断 instance 是否为空,如果为空,则锁定 Singleton.class 并再次检查 instance 是否为空,如果还为空则创建 Singleton 的一个实例。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public class Singleton {
    static Singleton instance;
    static Singleton getInstance(){
    if (instance == null) {
    synchronized(Singleton.class) {
    if (instance == null)
    instance = new Singleton();
    }
    }
    return instance;
    }
    }

    假设有两个线程同时执行getInstance()方法,他们会同时发现instance == null,于是同时对Singleton加锁,此时JVM保证只有一个线程能够加锁成功,另外一个会等待。线程A会创建一个Singleton实例,之后释放锁。锁被释放后,另外一个线程B被唤醒,他尝试再次加锁,加锁成功,他检查发生instance == null是不对的,因为已经创建过实例了,所以线程B不会创建实例。看起来逻辑没问题的操作执行起来却有问题:

    我们以为的new的操作应该是:

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

    但是实际上优化后的执行路径是:

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

    优化后导致的问题是,假设线程A先执行getInstance()方法,当执行完指令2时恰好发生了线程切换,切换到了线程B;如果此时线程B也执行getInstance()方法,那么线程B在执行第一个判断的时候回发现instance != null,所以直接返回instance,如果我们这个时候访问它,就可能除法空指针异常。