Bootstrap

什么是线程安全?并发问题的源头

根据《Java 并发编程实战》一书中的定义,线程安全是一个多线程环境下正确性的概念,也就是保证多线程环境下共享的、可修改的状态的正确性,这里的状态反映在程序中其实可以看作是数据。因此,如果状态不是共享的,或者是不可修改的,那也就不存在线程安全问题。

具体说,线程安全需要保证几个特性:原子性、可见性、有序性。

我们将一个或多个操作在CPU上执行的过程中不被中断的特性称为“原子性”。CPU能保证的原子性是指令级别的,而我们使用的高级语言中,一条语句往往对应多条CPU指令。比如count += 1 至少需要这三条CPU指令:

  • 指令 1:首先,把变量 count 从内存加载到 CPU 的寄存器;

  • 指令 2:接着,在寄存器中执行 +1 操作;

  • 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

 

但由于发生线程切换的时机可以发生在任何一条CPU指令执行完(时间片结束)。那就可能出现问题了。举个例子,线程A在执行完指令1后发生线程切换(count=0),线程B执行完了上面3条指令,此时count=1,之后切换回线程A继续执行指令2和3。最后会发现count=1而不是期望的2。

我们将一个线程对共享变量的修改,另一个线程能立即看到的特性称为“可见性”。不过,在多核CPU时代,每个核上都有自己的缓存,多个线程可能并行的运行在CPU的不同核上,对共享变量的读写都是在缓存中。此时,就存在可见性问题。

举个例子,我们分别启动两个线程执行add10K()

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();
    Thread th1 = new Thread(()->{
      test.add10K();
    });
    Thread th2 = new Thread(()->{
      test.add10K();
    });

    th1.start();
    th2.start();

    // 等待th1、th2执行结束
    th1.join();
    th2.join();
    return count;
  }
}

我们期望count返回值是20000,但实际上结果是10000 到 20000 之间的随机数。我们假设线程a和线程b同时执行,那么第一次都会将count读进各自CPU的缓存中。执行完 count+=1 之后,各自 CPU 缓存中的值都是 1,同时写入内存后,会发现内存中是 1,而不是期望的2。之后,由于各自的CPU缓存中都有了count值,于是两个线程都是基于 CPU 缓存里的 count 值来计算的。这就最终导致count的值小于20000。

编译器的优化有可能会将指令重排。下面是一段经典的双重检测创建单例对象。

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

在低版本的Java中,上面的写法是存在问题的(高版本的Java在JDK中解决了该问题,即将对象new操作和初始化操作设计为原子操作,自然就解决了重排序问题)。问题出在new上。我们期望的NEW操作应该是:

 

但经过编译优化的结果却是这样:

 

这就会导致,假设线程A执行完指令2时发生线程切换,线程B进入getInstance()方法,判断instance!=null,于是直接返回instance对象。但此时的instance对象是还未初始化的。这个时候在引用时instance中的成员变量时就会抛出空指针异常。