文章目录
- 有序性与内存屏障
- 1.重排序
- 1.1.编译器重排序
- 1.2.CPU重排序
- 1.2.1.指令级重排序
- 1.2.2.内存系统重排序
- 1.3.As-if-Serial规则
- 2.内存屏障
- 2.1.硬件层面的内存屏障
- 2.1.2.写屏障
- 2.1.3.读屏障
- 2.1.4.全屏障
- 2.2.硬件层的内存屏障作用
- 2.3.案例
有序性与内存屏障
有序性 与 可见性 是两个完全不同的概念,虽然两者都是CPU不断升级迭代的产物,但是由于CPU的技术不断发展,为了重复释放硬件的高性能,编译器、CPU会优化待执行的指令序列,包括调整某些执行的执行顺序。优化的结果, 指令执行顺序会与代码顺序有所不同,可能导致代码出现有序性问题。
内存屏障又称为 内存栅栏,是一系列CPU指令,他的主要作用是保证特定操作的执行顺序,保证并发行的有序性。
在编译器 和 CPU都进行指令的重排序优化时,可以通过在指令间插入一个内存屏障指令,高速编译器 和 CPU,进制在内存屏障指令(前后)执行重排序。
重排序主要时分为两类
- 编译器重排序
- CPU重排序
1.重排序
1.1.编译器重排序
编译器重排序是指,在代码编译的阶段进行指令重排,不改变程序执行结果的情况下为了提升效率,编译器对指令进行乱序(Out-of-Order)的编译。
例如,在代码中,A操作需要获取其他资源进入等待状态,而B操作 和 A操作并没有数据依赖关系,如果编译器一直等待A操作执行的话,效率会慢很多,此时就可以先编译B的代码,这样的乱序可以提升编译速度。
编译器进行重排序(Re-Order)目的:与其等待阻塞指令,不如先去执行其他指令,和CPU的乱序相比编译器重排序能够更大范围的,效果更好的乱序优化。
1.2.CPU重排序
流水线(Pipline)和乱序执行(Out-of-Order Execution)是现代CPU基本具有的特性,及机器指令在流水线经理过 取指令、译码、执行、写回等操作。为了CPU的执行效率,流水线都是并行处理的,在不影响语义的情况下,处理器次序(机器指令在CPU实际执行的顺序)和程序次序(程序代码的执行顺序)是允许不一致的,只要满足As-if-Serial规则即可。(但是不影响语义只能把保证指令之间的显示关系,并不能保证程序之间的隐式关系)
其实乱序,实际上也遵循一定的规则:只要两个指令之间不存在【数据依赖】,就可以对这两个指令乱序。
1.2.1.指令级重排序
在不影响程序执行结果的情况下,CPU内核采用了ILP(指令级并行运算)技术将多条指令重叠执行,如果指令之间不存在数据依赖,那么处理器可以改变语句对应机器指令的执行顺序。
1.2.2.内存系统重排序
对于现在CPU来说,在CPU内核 和 主存之间都具备一个高速缓存,高速缓存主要为了减少CPU内核 和主存之间的交互,在CPU内核进行读操作时,也是先从高速缓存中读取,在CPU内核写的时候,也是先写入高速缓存,最后统一写入主存。无论是读还是写,都优先考虑高速缓存。
在内存系统重排序中,会有类似的优化措施,但是这些措施更多地涉及到如何合理地利用缓存、预取数据等方面,而不是对指令执行顺序的显式调整。
所以内存重排序 和 指令重排序不同,他时一种伪重排序,看起来像乱序执行而已。
1.3.As-if-Serial规则
在单核CPU场景下,当指令被重排序后,如何保证运行的一个正确性呢?其实也很简单,编译器 和CPU都需要遵守As-if-Serial原则。
As-if-Serial规则具体内容为:不管如何重排序,都必须保证代码在单线程下运行正确。
为了遵守As-if-Serial规则,编译器和CPU不会对存在数据依赖关系的操作进行重排序,因为这种重排序回改变执行结果,但是如果指令之间不存在数据依赖关系。这些指令可能会被编译器和CPU进行重排序。
public class AsIfSerialRuleDemo {
public static void main(String[] args) {
int a = 1; // a
int b = 2; // b
int c = a + b; // c
}
}
例如上面这段代码, c 和 a之间存在依赖关系,c 和 b之间也存在依赖关系,因此在最终需要执行的指令序列中,c 不能重排序到 a 和 b的前面,因为 c 和 a,b有数据依赖,会导致程序执行的不正确。
但是 a 和 b没有依赖关系,编译器 和 CPU可以重排序 a 和 b的一个执行顺序
为了保证 As-if-Serial的规则,Java异常处理机制也会为指令重排序做一些特殊处理
public class AsIfSerialRuleDemo {
public static void main(String[] args) {
int x,y;
x = 1;
try {
x = 2;
y = 0 / 0;
}catch (Exception e) {
throw new RuntimeException("处理数据发生异常",e);
}finally {
System.out.println("x = " + x);
}
}
}
在上面这段代码中,语句 x = 2
和 y= 0 /0
之间没有数据依赖关系 所以 y = 0 / 0
可能会被重排序在 x = 2
之前执行,重排序后,x = 2
并未能执行,但是y = 0 /0
已经抛出异常,那么最终的结果 x = 1
这显然是不对的。
所以为了保证,最终不输出 x = 1
的错误结果,JIT
会在重排序时,会在catch语句中插入错误补偿代码,补偿执行语句 x = 2
, 将程序恢复到发生异常时应有的状态,这样做法的确将异常的捕获和底层逻辑变得非常复杂,但是JIT
的原则就是,尽力保证正确运行逻辑,哪怕以catch块的逻辑变得非常复杂也要保证。
但是,As-if-Serial规则只能保证单内核指令重排序之后的执行结果正确,不能保证多内核以及跨CPU指令重排序之后的执行结果正确
2.内存屏障
2.1.硬件层面的内存屏障
多核情况下,所有CPU操作都会涉及缓存一致性协议(MESI协议)校验,该协议用于保障内存可见性,但是缓存一致性协议仅仅保证内存弱可见(高速缓存失效),没有保证共享变量的强可见,而且缓存一致性更不能禁止CPU重排序,也就是不能保证跨CPU指令的有序执行。
如果保证CPU执行重排序之后的程序结果正确呢?需要使用到内存屏障
内存屏障又称为内存栅栏,是让一个CPU高速缓存的内存状态对其他CPU内核可见的一种技术,也是一项跨CPU保证有序性执行指令的技术。、
硬件层常用的内存屏障分为三种:写屏障
,读屏障
,全屏障
2.1.2.写屏障
在指令后插入写屏障指令能够将寄存器、高速缓存中的最新数据更新到主存,让其他线程可见,并且写屏障会告诉CPU和编译器,在写屏障之前的写指令必须要先于写屏障执行,不能进行指令重排序。
写屏障的作用是确保内存写操作的顺序性和可见性。在多线程并发编程中,当一个线程对共享变量进行写操作时,其他线程需要能够及时看到这些写操作的结果,而不是在缓存中读取到旧的数值。因此,插入写屏障指令可以保证这一点。
写屏障的插入不仅会将数据同步到主存,还会阻止编译器和CPU对写操作进行重排序。这样做的目的是为了避免在多线程环境下出现不一致的情况,例如数据竞争或者并发执行时的意外结果。
总之,指令后面插入写屏障指令,有两个作用:
- 能让寄存器、告诉缓存中最新的数据写回到主内存。
- 在写屏障之前的写指令,必须先于写屏障执行,不能进行指令重排。
2.1.3.读屏障
读屏障是将高速缓存中相应的数据失效,在指令前插入读屏障,可以让高速缓存中的数据失效,强制重新从主存中加载数据,并且读屏障会告诉CPU和编译器,后于这个屏障的读指令必须后执行,不能对后面的读操作进行指令重排
总之,在指令前插入的读屏障指令,有两个作用:
- 让高速缓存中的数据失效,从主存中加载数据
- 后于读屏障的读指令必须后执行,不能对后面的读操作进行指令重排
2.1.4.全屏障
全屏障的作用是确保在多线程环境下,对共享变量的读写操作符合程序员的预期。它可以防止指令重排序和缓存一致性等问题带来的可见性和有序性问题,从而提供了较强的线程间通信和同步保证。
在Java中,全屏障的使用可以通过volatile
关键字、synchronized
关键字、Lock
接口等方式实现。这些机制都能够保证全屏障的效果,使得多线程程序能够正确地共享数据和进行同步操作。
全屏障具有以下特性:
- 在全屏障之前的指令必须在全屏障之前执行完成,全屏障之后的指令必须在全屏障之后执行。
- 在全屏障之前的读操作必须在全屏障之前完成,全屏障之后的读操作必须在全屏障之后完成。
- 在全屏障之前的写操作必须在全屏障之前完成,全屏障之后的写操作必须在全屏障之后完成。
- 全屏障会使得所有处理器的缓存无效,强制从主内存中重新加载数据。
2.2.硬件层的内存屏障作用
- 阻止屏障两侧的指令重排序
- 编译器和CPU可能为让性能得到优化而进行指令重排,但是插入一个硬件层的内存屏障相当于告诉CPU和编译器先于这个屏障的指令必须先执行,后于这个屏障的指令必须后执行
- 强制把新数据写回主存,并让高速缓存的数据失效
- 硬件层的内存屏障强制把高速缓存中的最新数据写回主内存,让高速缓存中的相应脏数据失效,一旦写入完成,然和访问这个变量的线程都将会得到最新的值。
2.3.案例
package com.hrfan.java_se_base.base.thread.cas.rule;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@Slf4j
public class OutOfOrderExample {
public int x = 0;
public Boolean flag = false;
public void update() throws InterruptedException {
for (int i = 0; i < 10; i++) {
x = 8; // 语句 1
new BigDecimal("3.121654894").multiply(new BigDecimal("1565.45664")).multiply(new BigDecimal("5656.565666")).add(new BigDecimal("56464660.66")).divide(new BigDecimal("1"));
flag = true; // 语句 2
}
}
public void show() {
if (flag) {
log.error(" x = {}", x);
// 输出完成 重新恢复 x = 0,不然每次输出的都是 8
}
}
}
class TestThread {
@Test
public void test() {
OutOfOrderExample outOfOrderExample = new OutOfOrderExample();
startTestThread(outOfOrderExample);
}
private static void startTestThread(OutOfOrderExample outOfOrderExample) {
CountDownLatch latch = new CountDownLatch(10);
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 10; i++) {
threads.add(new Thread(() -> {
for (int j = 0; j < 2000; j++) {
try {
outOfOrderExample.update();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
outOfOrderExample.show();
}
latch.countDown();
}));
}
// 启动全部线程
threads.forEach(Thread::start);
// 等待全部线程执行完毕
try {
latch.await();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
OutOfOrderExample
并发之后之后,控制台的x
的值控可能为0
或者 8
,为什么 x会输出0 呢,主要原因是 update()
和 show()
方法可能在两个CPU内核之间并发执行,如果 语句1
和 语句2
发生了重排序,那么show()
方法可能输出的就是 0
那么如何保证并发运行结果的正确呢?
Java语言没有办法直接使用硬件层的内存屏障,只能使用含有JMM内存屏障语义的的Java关键字,我们直接使用volatile关键字 修饰变量在来观察结果。
public volatile int x = 0;
public volatile Boolean flag = false;
修改后的 OutOfOrderExample
代码使用关键字volatile
关键字对成员变量进行修饰,volatile含有JMM全屏障的语义
,要求JVM编译器在语句1前后插入全屏障指令
,该全屏障确保x的最新值对所有的后续操作是可见的(含跨CPU场景),并且禁止编译器 和CPU对语句1 和 语句2进行重排序
。