JUC-有序性

有序性

有序性:在本线程内观察,所有操作都是有序的;在一个线程观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序

CPU层面指令重排

CPU 的基本工作是执行存储的指令序列,即程序,程序的执行过程实际上是不断地取出指令、分析指令、执行指令的过程,为了提高性能,编译器和处理器会对指令重排,一般分为以下三种:

1
源代码 -> 编译器优化的重排 -> 指令并行的重排 -> 内存系统的重排 -> 最终执行指令

现代 CPU 支持多级指令流水线,几乎所有的冯•诺伊曼型计算机的 CPU,其工作都可以分为 5 个阶段:取指令、指令译码、执行指令、访存取数和结果写回,可以称之为五级指令流水线。CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(每个线程不同的阶段,相当于CPU指令级别的并行),本质上流水线技术并不能缩短单条指令的执行时间,但变相地提高了指令地吞吐率,多条指令的执行时间缩短了。

处理器在进行重排序时,必须要考虑指令之间的数据依赖性

  • 单线程环境也存在指令重排,由于存在依赖性,最终执行结果和代码顺序的结果一致
  • 多线程环境中线程交替执行,由于编译器优化重排,会获取其他线程处在不同阶段的指令同时执行

补充知识:

  • 指令周期是取出一条指令并执行这条指令的时间,一般由若干个机器周期组成
  • 机器周期也称为 CPU 周期,一条指令的执行过程划分为若干个阶段(如取指、译码、执行等),每一阶段完成一个基本操作,完成一个基本操作所需要的时间称为机器周期
  • 振荡周期指周期性信号作周期性重复变化的时间间隔

JVM指令重排

诡异的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int num = 0;
boolean ready = false;
// 线程1 执行此方法
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
// 线程2 执行此方法
public void actor2(I_Result r) {
num = 2;
ready = true;
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class ConcurrencyTest {
int num = 0;
volatile boolean ready = false;
@Actor
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
@Actor
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}

注意第三行,在原先基础上给ready添加了volatile修饰,可以避免指令重排。

双端检锁中涉及的指令重排问题

检锁机制

Double-Checked Locking:双端检锁机制

DCL(双端检锁)机制不一定是线程安全的,原因是有指令重排的存在,加入 volatile 可以禁止指令重排

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;

public static Singleton getInstance() {
if(INSTANCE == null) { // t2,这里的判断不是线程安全的
// 首次访问会同步,而之后的使用没有 synchronized
synchronized(Singleton.class) {
// 这里是线程安全的判断,防止其他线程在当前线程等待锁的期间完成了初始化
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}

不锁 INSTANCE 的原因:

  • INSTANCE 要重新赋值
  • INSTANCE 是 null,线程加锁之前需要获取对象的引用,设置对象头,null 没有引用

实现特点:

  • 懒惰初始化
  • 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
  • 第一个 if 使用了 INSTANCE 变量,是在同步块之外,但在多线程环境下会产生问题

问题分析

getInstance 方法对应的字节码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
0: 	getstatic 		#2 		// Field INSTANCE:Ltest/Singleton;
3: ifnonnull 37
6: ldc #3 // class test/Singleton
8: dup
9: astore_0
10: monitorenter
11: getstatic #2 // Field INSTANCE:Ltest/Singleton;
14: ifnonnull 27
17: new #3 // class test/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:Ltest/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Ltest/Singleton;
40: areturn
  • 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;
文章作者: GeYu
文章链接: https://nuistgy.github.io/2023/04/23/JUC-指令重排/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Yu's Blog