浅谈"内存屏障"-开发者角度
浅谈"内存屏障"-开发者角度
阅读本系列带着几个问题:
- 了解 volatile 如何保证可见性的?
- CAS 是如何保证对同一个地址操作的原子性?
- volatile 如何避免指令重排序的?
- MESI 协议与内存屏障有何种关联?
0、前言
推荐一篇有趣的文章:不想当作家的程序员写不出 Redis
从不同角度来理解内存屏障,谈论“内存屏障”共有三篇文章,分别从开发角度、语言角度和硬件角度。
本文主要是从开发角度了解
与内存屏障(memory barrier)的关联。
1、内存屏障(memory barrier)基础知识
1.1 什么是内存屏障?
这里只是简单了解下概念,后续会更加详细解释内存屏障是什么
内存屏障是一类同步屏障指令,它使得 CPU 或编译器在对内存进行操作的时候,严格按照一定的顺序来执行。内存屏障的作用是防止 CPU 对内存的乱序访问
,从而保证共享数据在多线程并行执行下的可见性。内存屏障有不同的类型,例如读内存屏障、写内存屏障、全功能内存屏障等,它们分别约束了不同的内存操作顺序。
1.2 为什么要有内存屏障?
单例模式经典的 dobule check lock
错误写法:
public class Singleton {
private static Singleton instance = null;
public static Singleton getInstance() {
if (instance == null) { // a
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
上面这段代码,初看没有问题,但是在并发模型下,可能会出错,因为 instance = new Singleton();
不是一个原子操作,实际上包含三个动作:
memory = allocate(); // 1:分配对象的内存空间
ctorInstance(memory); // 2:初始化对象
instance = memory; // 3:设置 instance 指向刚分配的内存地址
上面的操作 2 依赖操作 1,但是操作 3 不依赖于操作 2,但是为了加快执行效率,JVM 是可以针对他们进行指令的重排序。经过重排序后假设如下:
memory = allocate(); // 1:分配对象的内存空间
instance = memory; // 3:设置 instance 指向刚分配的内存地址
ctorInstance(memory); // 2:初始化对象
由于重排序影响,instance 已经指向了 memory,这时在多线程的情况下,线程 A 执行到了操作 3,线程 B 执行到了操作 a,发现不为空,就直接返回 instance 继续执行,由于还未初始化对象,线程 B 可能发生执行出错。
内存屏障在这里的作用
// 正确方式
private static volatile Singleton instance = null;
从开发者角度看,使用 volatile 保证了获取到正确的数据。在 Java 中可以利用 volatile
关键字防止重排序,而防止重排序与 内存屏障
有关。
这里的 instance = memory;
赋值操作是针对 volatile 类型的变量,这样就可以保证操作 1、操作 2 一定在操作 3 前面执行完成。
volatile
还有另一个作用:可见性
,但这里 synchronzied
关键字就能保证可见性。
1.3 为什么指令需要重排序?
程序到执行结果的转换
出自《C++ and Beyond 2012: Herb Sutter — atomic<> Weapons》
由上图可知,从程序源代码到具体的执行中间多了很多步骤,类似于在 Java 中也一样,其中每一层的转换,保证在 single thread
有一样的结果,这个由 JVM 的JSR-133规范中定义了 as-if-serial
语义来保证。但是在 multi-thread
情况下,需要开发者自己处理有 data race(数据争用) 的部分。
比如下面两个计算:
a = b + c;
d = e + f;
编译后,产生的汇编语句如下:
load R1, b
load R2, c
add R3, R1, R2 # (1)
store a, R3
load R4, e # (2)
load R5, f # (3)
add R6, R4, R5
store d, R6
- R1 ~ R6 是 register。
- a ~ f 表示变量的内存中的位置。
- load/add/store 第一个参数是目的地。
由于 CPU 访问 cache 和访问主存的时间相差很大(差了百倍以上,也可以看看这篇文章),
加上 CPU 指令执行的流水线技术、分支预测技术
等,所以会尽可能优化执行的指令顺序。
例如:由于 cpu 的流水线技术
,上面代码的 (2) 和 (3) 两条指令 可能会移动到指令 (1) 之前,可以尽可能减少访问主存的时间,缩短整体执行时间。
参考文章
- CPU扫盲-CPU如何执行指令以及流水线技术 - 知乎
- Latency Numbers Every Programmer Should Know
- CPU内部各个部件的时延大概是多少?(皮秒,纳秒)?- 知乎
- 以 double-checked locking 為例,了解 memory barrier 的作用以及thread 之間何時會同步資料
- 浅论Lock 与X86 Cache 一致性
- Java内存模型-volatile的内存语义 - 玉树临枫 - 博客园
- 如何理解 JAVA 中的 volatile 关键字 - 腾讯云开发者社区-腾讯云
- 软件角度:簡介 C++11 atomic 和 memory order