浅谈"内存屏障"-语言层面
阅读本系列带着几个问题:
- 了解 volatile 如何保证可见性的?
- CAS 是如何保证对同一个地址操作的原子性?
- volatile 如何避免指令重排序的?
- MESI 协议与内存屏障有何种关联?
0、前言
从不同角度来理解内存屏障,谈论“内存屏障”共有三篇文章,分别从开发角度、语言角度和硬件角度。
本文是系列第二篇文章,主要从语言角度了解与内存屏障(memory barrier)的关联。
这里主要是从 Java 语言角度
,语言层面主要介绍其中最重要的 内存模型
。
1、内存模型(memory model)
1.1 系统架构为了提高执行效率,会做哪些事情?
Architectures
(eg:x86、ARM) 为了提升效率,会做许多的事情,这里借用《C++ and Beyond 2012: Herb Sutter — atomic<> Weapons》的图:
从上图可知,有些优化表达
会影响到程序的执行结果:
- CPU 会调整执行的执行顺序(out-of-order execution)
- CPU 会从 Cache 读取资料
- CPU 各自有 store buffer,CPU 之前不会立即看到其他 CPU 的更新
上述操作和 Memory model) 有关,程序设计语言的 Memory model 规范 thread(或者 CPU)如何同步数据并提供 API ,让开发者在写 multi-thread 程序的时候,可以保证程序执行结果的正确性。
大部分程序设计语言都定义了 memory model,让使用该语言的和编写 compiler 的开发者可以写出跨平台的 multi-thread
的程序。Java 语言在 Java 5 版本完善了内存模型。
1.2 内存模型个人理解定义:
Java 内存模型是在硬件内存模型基础上面更高层的抽象,它屏蔽了各种硬件和操作系统对内存访问的差异性
,从而实现让 Java 程序在各种平台上都能达到一致的并发效果。
2、内存屏障和内存模型的关联
内存模型定义的 happens-before 原则 是需要依赖内存屏障来实现的,比如其中的 volatile 语义 。
下面还是借用 DCL
来说明:
public class Singleton {
private static volatile Singleton instance; // 使用 volatile 修饰
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // 保证完全初始化和可见性
}
}
}
return instance;
}
}
Java 5 之前的 volatile 关键字只能保证变量的可见性,但不能保证变量的有序性。也就是说,volatile 变量可能会被重排序,导致对象的部分初始化。Java 5 之后,volatile 关键字增加了一个内存语义,即禁止指令重排序优化。这样就可以保证 volatile 变量的有序性,从而避免对象的部分初始化。
2.1 硬件层的内存屏障
内存屏障有两个作用:
- 阻止屏障两侧的指令重排序
- 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中的相应数据失效。
Load Barrier 和 Store Barrier 即读屏障和写屏障。
Load Barrier:在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据;
Store Barrier:在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。
2.2 Java 语言层面的内存屏障^参考:[5]^
由于内存屏障是硬件层面
的概念,不同的硬件平台实现内存屏障的方式不一样,加上Java 是跨平台的语言,所以 Java 需要自己提供一套语言层面的内存屏障操作来屏蔽硬件层面的差异。共有 4 种型的指令:
LoadLoad barrier
对于这样的语句:
# Load1、Load2 仅仅是加载数据操作指令,后面类似指令也是。
Load1;
LoadLoad; # 内存屏障指令
Load2;
在 Load2 及后续读取操作要读取的数据被访问前,保证 Load1 要读取的数据被读取完毕。
StoreStore barrier
对于这样的语句 Store1; StoreStore; Store2,在 Store2 及后续写入操作执行前,保证 Store1 的写入操作对其它处理器可见。
LoadStore barrier
对于这样的语句 Load1; LoadStore; Store2,在 Store2 及后续写入操作被刷出前,保证 Load1 要读取的数据被读取完毕。
StoreLoad barrier
对于这样的语句 Store1; StoreLoad; Load2,在 Load2 及后续所有读取操作执行前,保证 Store1 的写入对所有处理器可见。
2.3 内存屏障在语言层面具体应用:volatile
volatile
的内存屏障策略非常严格保守,非常悲观且毫无安全感的心态:
在每个 volatile 写操作前插入StoreStore屏障,在写操作后插入 StoreLoad 屏障;
在每个 volatile 读操作前插入LoadLoad屏障,在读操作后插入 LoadStore屏障;
下面举一个例子来说明 volatile 变量为什么要使用这4个指令。
假设有两个线程 A 和 B,线程 A 执行以下操作:
x = 1;
volatileVar = 2;
线程 B 执行以下操作:
int y = volatileVar;
int z = x;
在这个例子中,我们希望当线程 B 读取到 volatileVar 的值为2时,z 的值也应该为 1。但是,由于编译器和处理器可能会对指令进行重排序,因此不能保证这一点。
为了解决这个问题,Java 可以在每个 volatile 写操作前插入 StoreStore 屏障,在写操作后插入 StoreLoad 屏障;在每个 volatile 读操作前插入 LoadLoad 屏障,在读操作后插入LoadStore 屏障。
在上面的例子中,线程A的代码将变为:
x = 1;
// StoreStore 屏障
volatileVar = 2;
// StoreLoad 屏障
线程B的代码将变为:
// LoadLoad 屏障
int y = volatileVar;
// LoadStore 屏障
int z = x;
首先屏障指令会禁用指令重排序,在这种情况下,由于 StoreStore 屏障保证了 x = 1 操作在 volatileVar = 2 操作之前完成,并且 x = 1 的写入操作对其他线程可见,而 StoreLoad 屏障保证了 volatileVar = 2 操作对其他线程可见。
由于 LoadLoad 屏障在读操作前面,禁止了重排序,而 LoadStore 屏障保证了 y = volatileVar 读取操作在 z = x 之前已经读取完毕
因此,当线程 B 读取到 volatileVar 的值为 2 时,z 的值也一定为 1。