什么是线程安全?并发问题的源头
根据《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中的成员变量时就会抛出空指针异常。