共计 5094 个字符,预计需要花费 13 分钟才能阅读完成。
本文已收录到 GitHub · AndroidFamily,有 Android 进阶常识体系,欢送 Star。技术和职场问题,请关注公众号 [彭旭锐] 进 Android 面试交换群。
前言
大家好,我是小彭。
在上一篇文章里,咱们聊到了 CPU 的缓存一致性问题,分为纵向的 Cache 与内存的一致性问题以及横向的多个外围 Cache 的一致性问题。咱们也探讨了 MESI 协定通过写流传和事务串行化实现缓存一致性。
不晓得你是不是跟我一样,在学习 MESI 协定的时候,天然地产生了一个疑难:在不思考写缓冲区和生效队列的影响下,在硬件层面曾经实现了缓存一致性,那么在 Java 语言层面为什么还须要定义 volatile
关键字呢?是多此一举吗?明天咱们将围绕这些问题开展。
学习路线图:
1. 回顾 MESI 缓存一致性协定
因为 CPU 和内存的速度差距太大,为了拉平两者的速度差,古代计算机会在两者之间插入一块速度比内存更快的高速缓存,CPU 缓存是分级的,有 L1 / L2 / L3 三级缓存。其中 L1 / L2 缓存是外围独占的,而 L3 缓存是多外围共享的。
在 CPU Cache 的三级缓存中,会存在 2 个缓存一致性问题:
- 纵向 – Cache 与内存的一致性问题:通过写中转或写回策略解决;
- 横向 – 多外围 Cache 的一致性问题:通过 MESI 等缓存一致性协定解决。
MESI 协定可能满足写流传和事务串行化 2 点个性,通过“已批改、独占、共享、已生效”4 个状态实现了 CPU Cache 的一致性;
古代 CPU 为了进步并行度,会在减少写缓冲区 & 生效队列将 MESI 协定的申请异步化,这其实是一种处理器级别的指令重排,会毁坏了 CPU Cache 的一致性。
Cache 不统一问题
MESI 协定在线模仿
网站地址:https://www.scss.tcd.ie/Jerem…
当初,咱们的问题是:既然 CPU 曾经实现了 MESI 协定,为什么 Java 语言层面还须要定义 volatile
关键字呢?岂不是多此一举?你可能会说因为写缓冲区和生效队列毁坏了 Cache 一致性。好,那不思考这个因素的话,还须要定义 volatile
关键字吗?
其实,MESI 解决数据一致性(Data Conherence)问题,而 volatile 解决程序一致性(Sequential Consistency)问题。 WC,这两个不一样吗?
2. 数据一致性 vs 程序一致性
2.1 数据一致性
数据一致性探讨的是同一份数据在多个正本之间的一致性问题, 你也能够了解为多个正本的状态一致性问题。例如内存与多外围 Cache 正本之间的一致性,或者数据在主从数据库之间的一致性。
当咱们从 CPU 缓存一致性问题开始,逐步探讨到 Cache 到内存的写中转和写回策略,再探讨到 MESI 等缓存一致性协定,从始至终咱们探讨的都是 CPU 缓存的“数据一致性”问题,只是为了简便咱们从没有刻意强调“数据”的概念。
数据一致性有强弱之分:
- 强数据一致性: 保障在任意时刻任意正本上的同一份数据都是雷同的,或者容许不同,然而每次应用前都要刷新确保数据统一,所以最终还是统一。
- 弱数据一致性: 不保障在任意时刻任意正本上的同一份数据都是雷同的,也不要求应用前刷新,然而随着工夫的迁徙,不同正本上的同一份数据总是向趋同的方向变动,最终还是趋势统一。
例如,MESI 协定就是强数据一致性的,但引入写缓冲区或生效队列后就变成弱数据一致性,随着缓冲区和生效队列被生产,各个外围 Cache 最终还是会趋势统一状态。
2.2 程序一致性
程序一致性探讨的是对多个数据的屡次操作程序在整个零碎上的一致性。在并发编程中,存在 3 种指令程序:
- 编码程序(Progrom Order): 指源码中指令的编写程序,是程序员视角看到的指令程序,不肯定是理论执行的程序;
- 执行程序(Memory Order): 指单个线程或处理器上理论执行的指令程序;
- 全局执行程序(Global Memory Order): 每个线程或处理器上看到的零碎整体的指令程序,在弱程序一致性模型下,每个线程看到的全局执行程序可能是不同的。
程序一致性模型是计算机科学家提出的一种现实参考模型,为程序员形容了一个极强的全局执行程序一致性,由 2 个个性组成:
- 个性 1 – 执行程序与编码程序统一: 保障每个线程中指令的执行程序与编码程序统一;
- 个性 2 – 全局执行程序统一: 保障每个指令的后果会同步到主内存和各个线程的工作内存上,使得每个线程上看到的全局执行程序统一。
举个例子,线程 A 和线程 B 并发执行,线程 A 执行 A1 → A2 → A3,线程 B 执行 B1 → B2 → B3。那么,在程序一致性内存模型下,尽管程序整体执行程序是不确定的,然而线程 A 和线程 B 总会依照 1 → 2 → 3 编码程序执行,而且两个线程总能看到雷同的全局执行程序。
程序一致性内存模型
2.3 弱程序一致性(肯定要了解)
尽管程序一致性模型对程序员十分敌对,然而对编译器和处理器却不见得脍炙人口。如果程序齐全依照程序一致性模型来实现,那么处理器和编译器的很多重排序优化都要被禁止,这对程序的 “并行度” 会有影响。例如:
- 1、重排序问题: 编译器和处理器不能重排列没有依赖关系的指令;
- 2、内存可见性问题: CPU 不能应用写回策略,也不能应用写缓冲区和生效队列机制。其实,从内存的视角看也是指令重排问题。
所以,在 Java 虚拟机和处理器实现中,实际上应用的是弱程序一致性模型:
- 个性 1 – 不要求执行程序与编码程序统一: 不要求单线程的执行程序与编码程序统一,只要求执行后果与强程序执行的后果统一,而指令是否真的按编码程序执行并不关怀。因为后果不变,从程序员的视角看程序就是按编码程序执行的假象;
- 个性 2 – 不要求全局执行程序统一: 容许每个线程看到的全局执行程序不统一,甚至容许看不到其余线程已执行指令的后果。
举个单线程的例子: 在这段计算圆面积的代码中,在弱程序一致性模型下,指令 A 和 指令 B 能够不按编码程序执行。因为 A 和 B 没有数据依赖,所以对最终的后果也没有影响。然而 C 对 A 和 B 都有数据依赖,所以 C 不能重排列到 A 或 B 的后面,否则会改变程序后果。
伪代码
double pi = 3.14; // A
double r = 1.0;// B
double area = pi * r * r; // C(数据依赖于 A 和 B,不能重排列到后面执行)
指令重排
再举个多线程的例子: 咱们在 ChangeThread 线程批改变量,在主线程察看变量的值。在弱程序一致性模型下,容许 ChangeThread 线程 A 指令的执行后果不及时同步到主线程,在主线程看来就像没执行过 A 指令。
这个问题咱们个别会了解为内存可见性问题,其实咱们能够对立了解为程序一致性问题。 主线程看不到 ChangeThread 线程 A 指令的执行后果,就如同两个线程看到的全局执行程序不统一:ChangeThread 线程看到的全局执行程序是:[B],而主线程看到的全局执行程序是 []。
可见性示例程序
public class VisibilityTest {public static void main(String[] args) {ChangeThread thread = new ChangeThread();
thread.start();
while (true) {if (thread.flag) { // B
System.out.println("Finished");
return;
}
}
}
public static class ChangeThread extends Thread {
private boolean flag = false;
@Override
public void run() {
try {Thread.sleep(1000);
} catch (InterruptedException e) {e.printStackTrace();
}
flag = true; // A
System.out.println("Change flag =" + flag);
}
}
}
程序输入
Change flag = true
// 有限期待
后面你说到编译器和处理器的重排序,为什么指令能够重排序,为什么重排序能够晋升性能,重排序不会出错吗?
3. 什么是指令重排序?
3.1 重排序类型
从源码到指令执行一共有 3 种级别重排序:
- 1、编译器重排序: 例如将循环内反复调用的操作提前到循环外执行;
- 2、处理器零碎重排序: 例如指令并行技术将多条指令重叠执行,或者应用分支预测技术提前执行分支的指令,并把计算结果放到重排列缓冲区(Reorder Buffer)的硬件缓存中,当程序真的进入分支后间接应用缓存中的结算后果;
- 3、存储器零碎重排序: 例如写缓冲区和生效队列机制,即是可见性问题,从内存的角度也是指令重排问题。
指令重排序类型
3.2 什么是数据依赖性?
编译器和处理器在重排序时,会遵循数据依赖性准则,不会试图扭转存在数据依赖关系的指令程序。如果两个操作都是拜访同一个数据,并且其中一个是写操作,那么这两个操作就存在数据依赖性。此时一旦扭转程序,程序最终的执行后果肯定会产生扭转。
数据依赖性分为 3 种类型::
数据依赖性 | 形容 | 示例 |
---|---|---|
写后读 | 写一个数据,再读这个数据 | a = 1; // 写 b = a; // 读 |
写后写 | 写一个数据,再写这个数据 | a = 1; // 写 a = 2; // 写 |
读后写 | 读一个数据,再写这个数据 | b = a; // 读 a = 1; // 写 |
3.3 指令重排序平安吗?
须要留神的是:数据依赖性准则只对单个处理器或单个线程无效,因而即便在单个线程或处理器上遵循数据依赖性准则,在多处理器或者多线程中仍然有可能改变程序的执行后果。
举例说明吧。
例子 1 – 写缓冲区和生效队列的重排序: 如果是在一个处理器上执行“写后读”,处理器不会重排这两个操作的程序;但如果是在一个处理器上写,之后在另一个处理器上读,就有可能重排序。对于写缓冲区和生效队列引起的重排序问题,上一篇文章曾经解释过,不再反复。
写缓冲区造成指令重排
例子 2 – 未同步的多线程程序中的指令重排: 在未同步的两个线程 A 和 线程 B 上别离执行这两段程序,程序的预期后果应该是 4
,但理论的后果可能是 0
。
线程 A
a = 2; // A1
flag = true; // A2
线程 B
while (flag) { // B1
return a * a; // B2
}
状况 1:因为 A1 和 A2 没有数据依赖性,所以编译器或处理器可能会重排序 A1 和 A2 的程序。在 A2 将 flag
改为 true
后,B1 读取到 flag
条件为真,并且进入分支计算 B2 后果,但 A1 还未写入,计算结果是 0
。此时,程序的运行后果就被重排列毁坏了。
状况 2:另一种可能,因为 B1 和 B2 没有数据依赖性,CPU 可能用分支预测技术提前执行 B2,但 A1 还未写入,计算结果还是 0
。此时,程序的运行后果就被重排列毁坏了。
多线程的数据依赖性不被思考
小结一下: 重排序在单线程程序下是平安的(与预期统一),但在多线程程序下是不平安的。
4. 答复最后的问题
到这里,尽管咱们的探讨还未完结,但曾经足够答复题目的问题:“曾经有 MESI 协定,为什么还须要 volatile 关键字?”
即便不思考写缓冲区或生效队列,MESI 也只是解决数据一致性问题,并不能解决程序一致性问题。在理论的计算机系统中,为了进步程序的性能,Java 虚拟机和处理器会应用弱程序一致性模型。
在单线程程序下,弱程序一致性与强程序一致性的执行后果完全相同。但在多线程程序下,重排序问题和可见性问题会导致各个线程看到的全局执行程序不统一,使得程序的执行后果与预期不统一。
为了纠正弱程序一致性的影响,编译器和处理器都提供了 “内存屏障指令” 来保障程序要害节点的执行程序可能与程序员的预期统一。在高级语言中,咱们不会间接应用内存屏障,而是应用更高级的语法,即 synchronized、volatile、final、CAS 等语法。
那么,什么是内存屏障?synchronized、volatile、final、CAS 等语法和内存屏障有什么关联,这个问题咱们在下一篇文章展开讨论,请关注。
参考资料
- Java 并发编程的艺术(第 1、2、3 章)—— 方腾飞 魏鹏 程晓明 著
- 深刻了解 Android:Java 虚拟机 ART(第 12.4 节)—— 邓凡平 著
- 深刻了解 Java 虚拟机(第 5 局部)—— 周志明 著
- 深入浅出计算机组成原理(第 55 讲)—— 徐文浩 著,极客工夫 出品
- CPU 有缓存一致性协定(MESI),为何还须要 volatile —— 一角钱技术 著
- 一文读懂 Java 内存模型(JMM)及 volatile 关键字 —— 一角钱技术 著
- MESI protocol —— Wikipedia
- Cache coherence —— Wikipedia
- Sequential consistency —— Wikipedia
- Out-of-order execution —— Wikipedia
- std::memory_order —— cppreference.com