02.有序性
有序性
顾名思义,有序性指的是程序按照代码的先后顺序执行。现代编译器的代码优化和编译器指令重排可能会影响到代码的执行顺序。编译期指令重排是通过调整代码中的指令顺序,在不改变代码语义的前提下,对变量访问进行优化。从而尽可能的减少对寄存器的读取和存储,并充分复用寄存器。但是编译器对数据的依赖关系判断只能在单执行流内,无法判断其他执行流对竞争数据的依赖关系。就拿无锁环形队列来说,如果 Writer 做的是先放置数据,再更新索引的行为。如果索引先于数据更新,Reader 就有可能会因为判断索引已更新而读到脏数据。
禁止编译器对该类变量的优化,解决了编译期的重排序并不能保证有序性,因为 CPU 还有乱序执行(Out-of-Order Execution)的特性。流水线(Pipeline)和乱序执行是现代 CPU 基本都具有的特性。机器指令在流水线中经历取指、译码、执行、访存、写回等操作。为了 CPU 的执行效率,流水线都是并行处理的,在不影响语义的情况下。处理器次序(Process Ordering,机器指令在 CPU 实际执行时的顺序)和程序次序(Program Ordering,程序代码的逻辑执行顺序)是允许不一致的,即满足 As-if-Serial 特性。显然,这里的不影响语义依旧只能是保证指令间的显式因果关系,无法保证隐式因果关系。即无法保证语义上不相关但是在程序逻辑上相关的操作序列按序执行。从此单核时代 CPU 的 Self-Consistent 特性在多核时代已不存在,多核 CPU 作为一个整体看,不再满足 Self-Consistent 特性。
简单总结一下,如果不做多余的防护措施,单核时代的无锁环形队列在多核 CPU 中,一个 CPU 核心上的 Writer 写入数据,更新 index 后。另一个 CPU 核心上的 Reader 依靠这个 index 来判断数据是否写入的方式不一定可靠。index 有可能先于数据被写入,从而导致 Reader 读到脏数据。
在 Java 中与有序性相关的经典问题就是单例模式,譬如我们会采用静态函数来获取某个对象的实例,并且使用 synchronized 加锁来保证只有单线程能够触发创建,其他线程则是直接获取到实例对象。
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null){
instance = new Singleton();
}
}
}
不过虽然我们期望的对象创建的过程是:内存分配、初始化对象、将对象引用赋值给成员变量,但是实际情况下经过优化的代码往往会首先进行变量赋值,而后进行对象初始化。假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null
,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。