本文已收录到  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/Jeremy.Jones/VivioJS/caches/MESI.htm

现在,我们的问题是:既然 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 等语法和内存屏障有什么关联,这个问题我们在下一篇文章展开讨论,请关注。


参考资料

已经有 MESI 协议,为什么还需要 volatile 关键字?的更多相关文章

  1. JMM 内存模型 与 volatile 关键字

    内存模型 线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory). 本地内存中存储了该线程以读/写共享变量的副本. 不同线程之间无法相互 ...

  2. volatile关键字?MESI协议?指令重排?内存屏障?这都是啥玩意

    一.摘要 三级缓存,MESI缓存一致性协议,指令重排,内存屏障,JMM,volatile.单拿一个出来,想必大家对这些概念应该有一定了解.但是这些东西有什么必然的联系,或者他们之间究竟有什么前世今生想 ...

  3. 线程基础:多任务处理——MESI协议以及带来的问题:伪共享

    1.概述 本文和后续文章将着眼CPU的工作原理阐述伪共享的解决方法和volatile关键字的应用. 2.复习CPU工作原理2.1.CPU工作原理要清楚理解本文后续内容,就需要首先重新概述一下JVM的内 ...

  4. 并发编程二、CPU多级缓存架构与MESI协议的诞生

    ​前言: 文章内容:线程与进程.线程生命周期.线程中断.线程常见问题总结 本文章内容来源于笔者学习笔记,内容可能与相关书籍内容重合 偏向于知识核心总结,非零基础学习文章,可用于知识的体系建立,核心内容 ...

  5. J.U.C JMM. pipeline.指令重排序,happen-before(续MESI协议)

    缓存(Cache)       CPU的读/写(以及取指令)单元正常情况下甚至都不能直接访问内存——这是物理结构决定的:CPU都没有管脚直接连到内存.相反,CPU和一级缓存(L1 Cache)通讯,而 ...

  6. CPU高速缓存行与内存关系 及并发MESI 协议

    先来一个整体图 一. 大致关系: CPU Cache --> 前端总线 FSB (下图中的Bus) --> Memory 内存 CPU 为了更快的执行代码.于是当从内存中读取数据时,并不是 ...

  7. CPU高速缓存行与内存关系 及并发MESI 协议(转载)

    原文链接 http://www.cnblogs.com/jokerjason/p/9584402.html 先来一个整体图 一. 大致关系: CPU Cache --> 前端总线 FSB (下图 ...

  8. Java内存模型(MESI、内存屏障、volatile和锁及final内存语义)

    JMM (Java内存模型) Java线程的实现 实现线程主要有三种方式,Java线程从JDK1.3后采用第一种方式实现: 使用内核线程实现(1:1实现) 使用用户线程实现(1:N实现) 使用用户线程 ...

  9. 既然synchronized是"万能"的,为什么还需要volatile呢?

    在我的博客和公众号中,发表过很多篇关于并发编程的文章,之前的文章中我们介绍过了两个在Java并发编程中比较重要的两个关键字:synchronized和volatile 我们简单回顾一下相关内容: 1. ...

  10. CUP的MESI协议

    MESI协议中的状态 CPU中每个缓存行(caceh line)使用4种状态进行标记(使用额外的两位(bit)表示): M: 被修改(Modified) 该缓存行只被缓存在该CPU的缓存中,并且是被修 ...

随机推荐

  1. helm安装csi-driver-smb-v1.9.0

    Application version v1.9.0 Chart version v1.9.0 获取chart包 helm repo add csi-driver-smb https://raw.gi ...

  2. 超详细 VS Code 配置C/C++教程

    写在前面 如果您使用的电脑内存 \(\leq 4 \texttt{GB}\),建议您使用Dev-C++,否则会到时内存占用爆满,体验感不佳. 网上的很多教程都不够详细,这里我把每一步.每一个操作都详细 ...

  3. 如何自动清理 KingbaseES SYS_LOG

    KingbaseES 初始化完成后,默认不会对 sys_log进行清理.如果需要对sys_log进行自动清理,需要设置相关参数. 与日志自动清理有关的参数(默认值)如下: log_filename | ...

  4. 深度学习库 SynapseML for .NET 发布0.1 版本

    2021年11月 微软开源一款简单的.多语言的.大规模并行的机器学习库 SynapseML(以前称为 MMLSpark),以帮助开发人员简化机器学习管道的创建.具体参见[1]微软深度学习库 Synap ...

  5. 分布式链路追踪体验-skywalking入门使用

    背景 旁友,你的线上服务是不是偶尔来个超时,或者突然抖动一下,造成用户一堆反馈投诉.然后你费了九牛二虎之力,查了一圈圈代码和日志才总算定位到问题原因了.或者公司内部有链路追踪系统,虽然可以很轻松地通过 ...

  6. Openstack neutron:SDN现状

    目录 - SDN现状 - (一)SDN现状 - SDN诞生的背景 - SDN的介绍 - (二)SDN领域的相关组织和发展现状 - 1.ONF - 2.OpenDaylight - 3. IETF -  ...

  7. Java SE 19 新增特性

    Java SE 19 新增特性 作者:Grey 原文地址: 博客园:Java SE 19 新增特性 CSDN:Java SE 19 新增特性 源码 源仓库: Github:java_new_featu ...

  8. flutter系列之:Material中的3D组件Card

    目录 简介 Card详解 Card的使用 总结 简介 除了通用的组件之外,flutter还提供了两种风格的特殊组件,其中在Material风格中,有一个Card组件,可以很方便的绘制出卡片风格的界面, ...

  9. centos yum安装docker

    cd /etc/yum.repos.d/ mv CentOS-Base.repo CentOS-Base.repo_bak wget -O /etc/yum.repos.d/CentOS-Base.r ...

  10. WPF绘制圆形调色盘

    本文使用writeableBitmap类和HSB.RGB模式来绘制圆形的调色盘. 开源项目地址:https://github.com/ZhiminWei/Palette RGB为可见光波段三个颜色通道 ...