有序性
有序性:在本线程内观察,所有操作都是有序的;在一个线程观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序
CPU层面指令重排
CPU 的基本工作是执行存储的指令序列,即程序,程序的执行过程实际上是不断地取出指令、分析指令、执行指令的过程,为了提高性能,编译器和处理器会对指令重排,一般分为以下三种:
1 | 源代码 -> 编译器优化的重排 -> 指令并行的重排 -> 内存系统的重排 -> 最终执行指令 |
现代 CPU 支持多级指令流水线,几乎所有的冯•诺伊曼型计算机的 CPU,其工作都可以分为 5 个阶段:取指令、指令译码、执行指令、访存取数和结果写回,可以称之为五级指令流水线。CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(每个线程不同的阶段,相当于CPU指令级别的并行),本质上流水线技术并不能缩短单条指令的执行时间,但变相地提高了指令地吞吐率,多条指令的执行时间缩短了。
处理器在进行重排序时,必须要考虑指令之间的数据依赖性
- 单线程环境也存在指令重排,由于存在依赖性,最终执行结果和代码顺序的结果一致
- 多线程环境中线程交替执行,由于编译器优化重排,会获取其他线程处在不同阶段的指令同时执行
补充知识:
- 指令周期是取出一条指令并执行这条指令的时间,一般由若干个机器周期组成
- 机器周期也称为 CPU 周期,一条指令的执行过程划分为若干个阶段(如取指、译码、执行等),每一阶段完成一个基本操作,完成一个基本操作所需要的时间称为机器周期
- 振荡周期指周期性信号作周期性重复变化的时间间隔
JVM指令重排
诡异的结果
1 | int num = 0; |
I_Result 是一个对象,有一个属性 r1 用来保存结果,问,可能的结果有几种?
情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1
情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)
但结果还有可能是 0
这种情况下是:线程2 执行 ready = true,切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行 num = 2
这种情况是小概率发生的,体现了Java层面的指令重排。
解决方案
用volatile 修饰变量,可以禁用指令重排
1 | public class ConcurrencyTest { |
注意第三行,在原先基础上给ready
添加了volatile修饰,可以避免指令重排。
双端检锁中涉及的指令重排问题
检锁机制
Double-Checked Locking:双端检锁机制
DCL(双端检锁)机制不一定是线程安全的,原因是有指令重排的存在,加入 volatile 可以禁止指令重排
1 | public final class Singleton { |
不锁 INSTANCE 的原因:
- INSTANCE 要重新赋值
- INSTANCE 是 null,线程加锁之前需要获取对象的引用,设置对象头,null 没有引用
实现特点:
- 懒惰初始化
- 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
- 第一个 if 使用了 INSTANCE 变量,是在同步块之外,但在多线程环境下会产生问题
问题分析
getInstance 方法对应的字节码为:
1 | 0: getstatic #2 // Field INSTANCE:Ltest/Singleton; |
- 17 表示创建对象,将对象引用入栈
- 20 表示复制一份对象引用,引用地址
- 21 表示利用一个对象引用,调用构造方法初始化对象
- 24 表示利用一个对象引用,赋值给 static INSTANCE
步骤 21 和 24 之间不存在数据依赖关系,而且无论重排前后,程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的
- 关键在于 0:getstatic 这行代码在 monitor 控制之外,可以越过 monitor 读取 INSTANCE 变量的值
- 当其他线程访问 INSTANCE 不为 null 时,由于 INSTANCE 实例未必已初始化,那么 t2 拿到的是将是一个未初始化完毕的单例返回,这就造成了线程安全的问题
解决方法
指令重排只会保证串行语义的执行一致性(单线程),但并不会关系多线程间的语义一致性
引入 volatile,来保证出现指令重排的问题,从而保证单例模式的线程安全性:
1 | private static volatile SingletonDemo INSTANCE = null; |