回炉重造-并发理论基础(可见性、原子性、有序性)问题

作为程序员应该都知道,想要写好正确的并发编程代码是一件比较困难的事,因为并发编程涉及到的底层知识非常多,诸如操作系统、编译原理等。如果对这些底层知识一知半解,想要写好并发、排查并发问题难上加难,因为并发的bug通常都会诡异的出现又诡异的消失。但要快速而又精准地解决“并发”类的疑难杂症,你就要理解这件事情的本质,追本溯源,深入分析这些 Bug 的源头在哪里。
那为什么并发编程容易出问题呢?它是怎么出问题的?今天我们就重点聊聊这些 Bug 的源头。

核心矛盾

这些年,我们的 CPU、内存、I/O 设备都在不断迭代,不断朝着更快的方向努力。但是,在这个快速发展的过程中,有一个核心矛盾一直存在,就是这三者的速度差异。
程序里大部分语句都要访问内存,有些还要访问 I/O,根据木桶理论(一只水桶能装多少水取决于它最短的那块木板),程序整体的性能取决于最慢的操作——读写 I/O 设备,也就是说单方面提高 CPU 性能是无效的。
为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:

1.CPU 增加了缓存,以均衡与内存的速度差异;
2.操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
3.编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
就是因为这三点优化,导致我们并发编程的时候一不留神就会出现诡异问题,因为直觉一直在欺骗我们。

诡异源头一:缓存导致的可见性问题

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

在单核时代,所有的线程都是在一颗 CPU 上执行,CPU 缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个 CPU 的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。然而在多核时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。这个就属于硬件程序员给软件程序员挖的“坑”。
请看如下代码:

public class ThreadDemo1 {

    private long count = 0;

    private void add10000() {
        int idx = 0;
        while (idx++ < 10000) {
            count += 1;
        }
        System.out.println(Thread.currentThread().getName()+",count:"+count);
    }

    public static void main(String[] args) throws InterruptedException {
        final ThreadDemo1 test = new ThreadDemo1() {
        };
        // 创建两个线程,执行add()操作
        Thread th1 = new Thread(test::add10000,"线程1");
        Thread th2 = new Thread(test::add10000,"线程2");
        // 启动两个线程
        th1.start();
        th2.start();
        // 等待两个线程执行结束
        th1.join();
        th2.join();
    }
}
    打印的结果如下:
    线程1,count:11215
    线程2,count:20000

    直觉告诉我们应该是 20000,然而打印出来确实10000-20000之间的随机数,原因是两个线程不是同时启动的,有一个时差,这就是缓存的可见性问题。

诡异源头二:线程切换带来的原子性问题

Java 并发程序都是基于多线程的,自然也会涉及到任务切换,也许你想不到,任务切换竟然也是并发编程里诡异 Bug 的源头之一。任务切换的时机大多数是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条 CPU 指令完成,例如上面代码中的count += 1,至少需要三条 CPU 指令。

指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
指令 2:之后,在寄存器中执行 +1 操作;
指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

操作系统做任务切换,可以发生在任何一条 CPU 指令执行完,是的,是 CPU 指令,而不是高级语言里的一条语句。我们潜意识里面觉得 count+=1 这个操作是一个不可分割的整体,就像一个原子一样,线程的切换可以发生在 count+=1 之前,也可以发生在 count+=1 之后,但就是不会发生在中间。所以并发编程时就会出现诡异问题。

诡异源头三:编译优化带来的有序性问题

编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”,在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的 Bug。

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

老生常谈的单例双重检查机制,在jdk1.5之前没有volitile关键字时,这个问题很难处理,1.5之后添加volitile后,有序性问题得以解决。

暂无评论