什么是线程安全?并发问题的源头
什么是线程安全?
根据《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()
我们期望 count 返回值是 20000,但实际上结果是 10000 到 20000 之间的随机数。我们假设线程 a 和线程 b 同时执行,那么第一次都会将 count 读进各自 CPU 的缓存中。执行完 count+=1 之后,各自 CPU 缓存中的值都是 1,同时写入内存后,会发现内存中是 1,而不是期望的 2。之后,由于各自的 CPU 缓存中都有了 count 值,于是两个线程都是基于 CPU 缓存里的 count 值来计算的。这就最终导致 count 的值小于 20000。
编译优化带来的有序性。
编译器的优化有可能会将指令重排。下面是一段经典的双重检测创建单例对象。
在低版本的 Java 中,上面的写法是存在问题的(高版本的 Java 在 JDK 中解决了该问题,即将对象 new 操作和初始化操作设计为原子操作,自然就解决了重排序问题)。问题出在 new 上。我们期望的 NEW 操作应该是:
分配一块内存 M;
在内存 M 上初始化 Singleton 对象;
然后 M 的地址赋值给 instance 变量。
但经过编译优化的结果却是这样:
分配一块内存 M;
将 M 的地址赋值给 instance 变量;
最后在内存 M 上初始化 Singleton 对象。
这就会导致,假设线程 A 执行完指令 2 时发生线程切换,线程 B 进入 getInstance()方法,判断 instance!=null,于是直接返回 instance 对象。但此时的 instance 对象是还未初始化的。这个时候在引用时 instance 中的成员变量时就会抛出空指针异常。
评论