volatile禁止重排序的原理-内存屏障

it2026-03-31  6

目录

JMM中volatile内存语义的实现volatile写的内存屏障volatile读的内存屏障内存屏障的优化内存屏障手动添加

程序中,为了更高效的执行,会对代码或者指令进行重排序,在java中程序运行过程中可能会发生以下情况的重排序:

编译器优化重排序;指令级并行重排序;内存系统重排序;

为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。 下图是JMM针对编译器制定的volatile重排序规则表。 举例来说:

第三行最后一个单元格的意思是:在程序中,当第一个操作为普通变量的读或 写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。从上图可以看出: 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile写之前的操作不会被编译器重排序到volatile写之后。当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

JMM中volatile内存语义的实现

通过上面的表格我们大致了解了JMM对于volatile重排序的禁止规则,那么他是怎么实现的呢?就是通过编译器在编译生产字节码文件时,,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数 几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。

在每个volatile写操作的前面插入一个StoreStore屏障。在每个volatile写操作的后面插入一个StoreLoad屏障。在每个volatile读操作的后面插入一个LoadLoad屏障。在每个volatile读操作的后面插入一个LoadStore屏障

上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。

volatile写的内存屏障

在保守策略下,volatile写插入内存屏障后生成的指令序列示意图: 上图中StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。 这里比较有意思的是,volatile写后面的StoreLoad屏障。此屏障的作用是避免 volatile写与后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面 是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方 法立即return)。为了保证能正确 实现volatile的内存语义,JMM在采取了保守策略:在每个volatile写的后面,或者在每个volatile 读的前面插入一个StoreLoad屏障。从整 体执行效率的角度考虑,JMM最终选择了在每个 volatile写的后面插入一个StoreLoad 屏障。因为volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里可以看到JMM 在实现上的一个特点:首先确保正确性,然后再去追求执行效率。

volatile读的内存屏障

下图是在保守策略下,volatile读插入内存屏障后生成的指令序列示意图: 上图中LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore 屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。

内存屏障的优化

上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不 改变 volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。下面通过 具体的示例代码进行说明。

public class VolitileBarrierDemo { int a; volatile int v1 = 1; volatile int v2 = 2; void readWrite() { int i = v1; // 第一个volitile读 分两步 读volitile变量v1, 给i赋值 这两步是有序的 int j = v2; // 第二个volitile读 分两步 读volitile变量v2, 给j赋值 这两步是有序的 a = i + j; // 普通写 分两步 读 i 和 j的值 v1 = i + 1; // 第一个volitile写 两步 读i加1 给volitile v1赋值 volitile写 v2 = j * 2; // 第二个volitile写 两步 读j*2 给volitile v2赋值 volitile写 } }

针对readAndWrite()方法,编译器在生成字节码时可以做如下的优化。 注意,最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即 return。此时编译器可能无法准确断定后面是否会有volatile读或写,为了安全起见, 编译器通常会在这里插入一个StoreLoad屏障。

处理器优化: 上面的优化针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内 存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以X86处理器为例,下图中除最后的StoreLoad屏障外,其他的屏障都会被省略。 前面保守策略下的volatile读和写,在X86处理器平台可以优化成如下图所示。前文 提到过,X86处理器仅会对写-读操作做重排序。X86不会对读-读、读-写和写-写操作 做重排序,因此在X86处理器中会省略掉这3种操作类型对应的内存屏障。X86中,JMM仅需 在volatile写后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义。这意味着在 X86处理器中,volatile写的开销比volatile读的开销会大很多(因为执行StoreLoad屏障开销会比较大)。

内存屏障手动添加

在java提供的API中有一个名称为Unsafe的类,在该类中有三个native方法:

public native void loadFence(); // 添加读屏障 public native void storeFence(); // 添加写屏障 public native void fullFence(); // 读写屏障

这三个本地方法就是添加内存屏屏障的,但是由于Unsafe是在rt.jar包中并且是终态的无法通过继承来调用,同时存在安全检查机制,我们无法获取其实例,只能通过反射获取到其类中的一个属性theUnsafe从而获取其实例:

public class UnsafeInstance { public static Unsafe unsafeInstance() { try { Field field = Unsafe.class.getDeclaredField("theUnsafe"); field.setAccessible(true); Object unsafe = field.get(null); field.setAccessible(false); return (Unsafe) unsafe; } catch (Exception ex) { ex.printStackTrace(); } return null; } public static void main(String[] args) { Unsafe unsafe = unsafeInstance(); int a = 2; unsafe.storeFence(); // 添加写屏障 int b = 3; } }
最新回复(0)