首页 > 编程笔记 > Java笔记 阅读:1

什么是指令重排序和内存屏障?(新手必看)

指令重排序是编译器和处理器为了优化程序性能而采用的一种技术。这种技术能够改变程序指令执行的顺序,但保证在单线程环境中最终结果的一致性。

根据发生的层面,指令重排序可以分为 3 种,分别为编译器优化重排序、指令级并行重排序、内存系统重排序。重排序流程如下图所示,后面两种为处理器级别。


理解指令重排序有助于开发者预见和避免潜在的并发问题。编译器和处理器并非在任何场景下都会进行指令重排序的优化,而是会遵循一定的原则,as-if-serial 语义就是重排序都需要遵循的原则。

as-if-serial 语义规定在单线程中,只要不改变程序的最终执行结果,为了提升性能就可以改变指令执行的顺序。但是,在多线程程序中,指令重排序可能会导致一些问题。

例如,一个线程对共享变量的修改可能由于重排序而未按预期顺序对其他线程可见,从而导致数据竞争和不一致的问题。为了解决这些问题,在编译器方面使用 volatile 关键字可以禁止指令重排序,但在硬件方面,需要使用 JMM 定义的内存屏障(Memory Barrier)来实现禁止指令重排序。

内存屏障是什么

内存屏障也称为内存栅栏,是一种同步机制,可以确保指令执行的顺序满足特定的一致性要求。

内存屏障在编译器优化和处理器执行指令时发挥作用,防止这些环节中的指令重排序引发问题。内存屏障可确保在屏障之前的所有操作完成后才开始执行屏障之后的操作。

内存屏障在硬件层面和 JVM 层面都有实现,具体如下:

1) 硬件层内存屏障

硬件层内存屏障有加载屏障(Load Barrier)和存储屏障(Store Barrier)两种特定类型,这两种屏障主要用于编译器和处理器级别,避免由指令重排序导致的多线程程序中的数据不一致问题。

① 加载屏障:确保所有对内存的读取操作在屏障指令之后的读取操作执行前完成。这意味着,加载屏障后的读取操作必须等待所有先前的读取操作完成,确保得到的数据是最新的。加载屏障主要用于防止指令重排序中的读取操作被提前执行。
i = a;
LoadBarriers;
//其他操作
上述代码中,LoadBarriers 可以确保程序在执行其他操作之前,从主内存中读取 a 的变量值并且刷新到缓存中。

② 存储屏障:确保所有的写入操作在屏障指令之后的写入操作执行前完成。这确保了屏障之前的所有写入操作对接下来的写入操作可见。存储屏障用于防止写入操作重排序,确保按照程序的预期顺序执行写入操作。
a = 1;
b = 2;
c = 3;
StoreBarriers;
// 其他操作
上述代码中,StoreBarriers 可以确保在执行其他操作之前,写入缓存中的 a、b、c 这 3 个变量值同步到主内存中,并且其他线程可以观察到变量的变化。

在多处理器系统中,这两种屏障特别重要,因为它们帮助维护跨不同处理器的数据的一致性。例如,如果一个处理器更新了共享数据,通过使用适当的屏障,可以确保这些更新对在其他上运行的线程立即可见。

在实际应用中,这两种屏障经常与其他类型的内存屏障一起使用,如全屏障(Full Barrier),它同时包括加载屏障和存储屏障的功能,确保所有的读写操作都在屏障之后的操作之前完成。

2) JVM内存屏障

在 JVM 中,内存屏障是一种底层同步机制,用于实现 JMM 规定的内存可见性和有序性保证。这些屏障不是由 Java 语言直接提供的,而是由 JVM 实现的,并且通常在编译器生成的机器代码中插入,确保正确读写操作,以及锁的正确获取和释放。

JVM 内存屏障大致可以分为以下 4 种:
① LoadLoad 屏障:放在两个读取操作之间,确保第一个读取操作的结果在第二个读取操作开始之前必须被获取。
int i = a;
LoadLoad;
int j = b;
上述代码中,LoadLoad 可以确保 int i=a 读取操作在 int j=b 读取操作之前,禁止它们进行重排序。

② StoreStore 屏障:放在两个写入操作之间,确保第一个写入操作的结果在第二个写入操作开始之前必须被刷新到主内存。
a = 1;
StoreStore;
b = 10;
上述代码中,StoreStore 可以确保 a=1 写入操作的结果在 b=10 写入操作开始之前被刷新到主内存,禁止它们进行重排序。

③ LoadStore 屏障:放在读取操作之后、写入操作之前,确保读取操作的结果对接下来的写入操作可见。
int i = a;
LoadStore;
b = 10;
上述代码中,LoadStore 可以确保 int i=a 读操作在 int b=10 写操作之前,禁止它们进行重排序。

④ StoreLoad 屏障:最昂贵的屏障,确保之前的所有写入操作完成之后,才执行后续的读取操作。
a = 1;
StoreLoad;
int i = b;
上述代码中,StoreLoad 可以确保 a=1 写入操作在 int i =b 读取操作之前,禁止它们进行重排序。

这些 JVM 内存屏障在使用 volatile 关键字、synchronized 关键字和 java.util.concurrent 包中的锁时都会被用到。当定义一个 volatile 变量时,JVM 会在写操作之后插入一个 StoreStore 屏障,以确保这次写操作对其他线程立即可见;同时,可能还会插入一个 StoreLoad 屏障来保证写操作之后的读操作不会读取到旧值。

虽然我们在编写代码时不需要直接应用这些内存屏障,因为它们由 JVM 底层自动处理,但是理解它们的存在和作用对于编写并发和多线程程序是很关键的,特别是在调试和性能优化时。

相关文章