CPU 的乱序执行
为什么要乱序执行
- CPU 的乱序执行本质上是为了提升效率,比如有这样两行命令
int a = new OtherClass().method(); int b = 0
- 在这种情况下,a 的结果可能需要很长时间才可以返回,而 b 的值则可以直接得出,同时 b 的值又不依赖于 a ,在这种情况下 CPU 就会乱序执行,这其实是为了提升效率
- 比如有如下的场景
洗水壶 > 烧水 > 洗茶壶 > 洗茶杯 > 拿出茶叶 > 泡茶 //但是我们可以通过合理的设计达到如下顺序 洗水壶 > 烧水 ========================== > 泡茶 洗茶壶 > 洗茶杯 > 拿出茶叶 // 这其实就是一种乱序的操作,但是是为了提高效率,CPU 也是如此 // 技术源于生活,高于生活
don’t talk me ,show me your code !
- 我们来看看下面的代码,
- 如果都是顺序执行,是不是永远都不会出现 x = 0 && y = 0 的情况 ?
public class cpu {
private static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
int i = 0;
boolean flag = true;
while (flag) {
i++; x = 0; y = 0; a = 0; b = 0;
Thread one = new Thread(() ->{
a = 1; x = b;
});
Thread other = new Thread(() ->{
b = 1; y = a;
});
one.start(); other.start();
one.join(); other.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if (x == 0 && y == 0) {
System.err.println(result);
flag = false;
}
}
}
}
但是技术总是要虐虐人才体现自己的难度的
经过测试居然出现了 x = 0 && y = 0 的情况,笔者测试了几次,时间都不是很长,这也就证明了 CPU 的乱序是真实存在的,而且不是很难遇到的现象
有兴趣的小伙伴可以看看这篇文章 Memory Reordering Caught in the Act
但是为什么我们写的代码好像从来没出现过乱序的现象呢?
- 这是因为,乱序执行的前提是:
- 下面的指令不受上面的指令影响,就是说如果存在逻辑关系,是不会发生乱序的,而如果两个对象之间不互相影响,其实乱序执行最后的结果也是正确的。这就是 as-if-serial
- as-if-serial : 不管硬件什么顺序,单线程执行的结果不变,看上去像是serial
CPU 执行乱序主要有以下几种
- 读读乱序(load load): load(a);load(b); -----------> load(b);load(a);
- 写写乱序(store store):a=1;b=2-------------> b=2;a=1;
- 写读乱序(store load): a=1;load(b); ------------> load(b);a=1;
- 读写乱序(load store): load(a);b=2; ------------> b=2;load(a);
如何避免CPU 乱序执行?
使用 volatile
java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁更加方便。如果一个字段被声明成volatile,java线程内存模型确保所有线程看到这个变量的值是一致的。
- 一句话即, volatile 声明的变量可以保证多线程对这个变量的可见性,它被称为轻量级的 synchronized, 它比synchronized的使用和执行成本会更低,因为它不会引起线程的阻塞从而导致线程上下文的切换和调度。
回顾下happens-before对volatile规则的定义 : volatile变量的写,先发生于后续对这个变量的读.
这句话的含义有两层
- volatile 的写操作, 需要将线程 本地内存 值 立马刷新到 主内存的 共享变量 中
- volatile 的读操作, 需要从 主内存 的 共享变量 中读取,更新 本地内存变量 的值
由此引出 volatile 的内存语义:
- 当写一个volatile变量时,JVM会把该线程对应的本地内存中的共享变量值刷新到主内存.
- 当读一个volatile变量时,JVM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量,并更新本地内存的值
volatile 的特性
- 可见性:对一个volatile的变量的读,总是能看到任意线程对这个变量最后的写入
- 单个读或者写具有原子性:对于单个 volatile 变量的读或者写具有原子性,复合操作不具有.(如i++)
- 互斥性:同一时刻只允许一个线程对变量进行操作.(互斥锁的特点)
volatile 是怎么实现的呢?
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
volatile 为什么可以禁止重排序?
volatile 禁止指令重排序的一些规则:
- 当第二个操作是voaltile写时,无论第一个操作是什么,都不能进行重排序
- 当地一个操作是volatile读时,不管第二个操作是什么,都不能进行重排序
- 当第一个操作是volatile写时,第二个操作是volatile读时,不能进行重排序