《Java 并发编程实战》学习笔记 Day 02

77 阅读5分钟

并发Bug的源头是什么?

可见性原子性有序性问题是并发编程Bug的源头。那可见性、原子性、有序性又是怎么产生的呢?这就不得不说计算机硬件的发展。随着CPU、内存、I/O 设备都在不断迭代,不断朝着更快的方向努力。在这个快速发展的过程中,有一个核心矛盾一直存在,就是这三者的速度差异。CPU>内存>I/O 设备。为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:

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

计算机体系结构、操作系统、编译程序做出的贡献,提高了CPU 的利用率,均衡了三者的速度差异。但是也带了并发程序的很多诡异问题。

缓存带来的可见性问题

什么是可见性呢?一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。在单核时代,所有线程都操作同一个CPU的缓存,一个线程对缓存的写,对另一个线程一定是可见的。但在多核时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了。可见性问题,代码如下所示:

public class Test {

    private static int num=0;

    private static void add(){
        num=num+1;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread th1 = new Thread(new Runnable() {
            public void run() {
                for (int i = 0; i < 10000; i++) {
                    add();
                }
            }
        });
        Thread th2 = new Thread(new Runnable() {
            public void run() {
                for (int i = 0; i < 10000; i++) {
                    add();
                }
            }
        });
        th1.start();
        th2.start();
        //等待两个线程结束
        th1.join();
        th2.join();
        System.out.println(num);
    }
}

单核cpu,结果为20000。多核cpu,结果就是一个在10000~20000的随机数。

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

刚刚说完可见性问题,接着说原子性问题,什么是原子性问题呢?一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。有了这个概念,我们接着往下看。CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符。而我们程序员所面对的是高级语言层面的,在理解起来,稍微有一下困难了。例如代码count += 1;,对程序员就一条语句。而对应cpu指令却又三条:

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

接着,我们说说线程切换,操作系统允许某个进程执行一小段时间,而这"一小段时间"我们称之为时间片。也就是分时复用 CPU,在这个过程中伴随这任务切换(也就是线程的切换)。操作系统做任务切换,可以发生在任何一条 CPU 指令执行完成。

结合cpu指令和线程切换。对于上面的三条指令来说,我们假设 count=0,如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的序列执行,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。

因此,很多时候我们需要在高级语言层面保证操作的原子性。举一个最简单的例子,在32位操作系统中,操作非volatile类型的long和double型变量,会将CPU指令分为两个32的CPU指令,也就是说在32位操作系统中,操作非volatile类型的long和double型变量,是非原子的。

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

编译器为了优化性能,有时候会改变程序中语句的先后顺序。以一个经典的例子为例,利用双重检查创建单例对象。代码如下:

public class Singleton {

    private static Singleton singleton;

    private Singleton (){}

    public static Singleton getSingleton() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton(); //问题
                }
            }
        }
        return singleton;
    }

}

从代码上看,一切都很完美,无懈可击,但实际上这个 getInstance() 方法并不完美。问题出在哪里呢?出在 new 操作上,我们以为的 new 操作应该是:

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

编译器优化后却是这样的:

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

假设现在有两个线程A和B,线程A执行到步骤2,将 M 的地址赋值给 instance 变量。发生线程切换,线程B执行singleton == null为false,直接返回singleton。而此时的 instance 是没有初始化过的,在调用singleton时,可能触发空指针异常。

参考

摘自极客时间 - 王宝令老师的《Java 并发编程实战》