关于java:关于这个知识点我被读者骂到回家种田

74次阅读

共计 9112 个字符,预计需要花费 23 分钟才能阅读完成。

前言

事件是这样的,一位读者看了我的一篇文章,不认同我文章外面的观点,于是有了上面的交换。

可能是我发的那个狗头的表情,让这位读者认为我不尊重他。于是,这位读者一气之下把我删掉了,在删好友之前,还叫我回家种田。

说实话,你说我菜我是抵赖的,但你要我回家种田,我不了解。为什么要回家种田呢?养猪不比种田赚钱吗?

我想了很久没有想明确,忽然,我看到了这个新闻,霎时明确了读者的用心良苦。

于是,我决定写下这篇文章,好好地剖析一下读者提出的几个问题。

读者的观点

针对这位读者的几个观点:

  1. volatile 关键字的底层实现就是 lock 指令
  2. lock 指令触发了缓存一致性协定
  3. JMM 靠缓存一致性协定保障

我先给出我的认识:

  1. 第一点我认为是对的,这个我在 volatile 那篇文章也说过,volatile 的底层实现就是 lock 前缀指令
  2. 第二点我认为是错的
  3. 第三点我认为是错的

至于为什么我会这么认为,我会说出我的理由,毕竟,咱们都是是讲道理的人,对不对?

读者的观点围绕“缓存一致性协定”,OK,那咱们就从 缓存一致性协定 讲起!

从字面意思来看,缓存一致性协定就是“用来解决 CPU 的缓存不统一问题的协定”。将这句话拆开,就会有几个问题:

  1. 为什么 CPU 运行过程中须要缓存呢?
  2. 为什么缓存会呈现不统一呢?
  3. 有哪些办法去解决缓存不统一的问题呢?

咱们一一剖析。

为什么须要缓存

CPU 是个运算器,次要负责运算;

内存是个存储介质,负责存储数据和指令;

在没有缓存的年代,CPU 和内存是这样配合工作的:

一句话总结就是:CPU 高速运转,但取数据的速度十分慢,重大节约了 CPU 的性能。

那怎么办呢?

在工程学上,解决速度不匹配的形式次要有两种,别离是 物理适配 空间缓冲

物理适配很容易了解,多级机械齿轮就是物理适配的典型例子。

至于 空间缓冲,更多的被使用在软硬件上,CPU 多级缓存就是经典的代表。

什么是 CPU 多级缓存?

简略来说就是基于 工夫 = 间隔 / 速度 这个公式,通过在 CPU 和内存之间设置多层缓存来缩小取数据的间隔,让 CPU 和内存的速度可能更好的适配。

因为缓存离 CPU 近,而且构造更加正当,CPU 取数据的速度就缩短了,从而进步了 CPU 的利用率。

同时,因为 CPU 取数据和指令满足工夫局部性和空间局部性,有了缓存之后,对同一数据进行屡次操作时,两头过程能够用缓存暂存数据,进一步摊派 工夫 = 间隔 / 速度 中的间隔,更好地进步了 CPU 利用率。

工夫局部性:如果一个信息项正在被拜访,那么在近期它很可能还会被再次拜访

空间局部性:如果一个存储器的地位被援用,那么未来他左近的地位也会被援用

缓存为什么会不统一

缓存的呈现让 CPU 的利用率失去了大幅地进步。

在单核时代,CPU 既享受到了缓存带来的便当,又不必放心数据会呈现不统一。但这所有的前提建设在“单核”。

多核时代的到来突破了这种均衡。

进入多核时候之后,须要面临的第一个问题就是:多个 CPU 是共享一组缓存还是各自领有一组缓存呢?

答案是“各自领有一组缓存”。

为什么呢?

咱们无妨做个假如,假如多个 CPU 共享一组缓存,会呈现什么状况呢?

如果共享一组缓存,因为低级缓存(离 CPU 近的缓存)的空间十分小,多个 CPU 的工夫会都花在期待应用低级缓存下面,这意味着多个 CPU 变成了串行工作,如果变成串行,那就失去了多核的实质意义——并行。

咱们用反证证实了多个 CPU 共享一组缓存是行不通的,所以只能让多个 CPU 各自领有一组公有缓存。

于是,多个 CPU 的缓存构造就成了这样(简化了多级缓存):

这样的设计尽管解决了多个处理器抢占缓存的问题,但也带来了一个新的问题,那就是令人头疼的数据一致性问题:

具体来说,就是如果多个 CPU 同时用到某份数据,因为多组缓存的存在,数据可能会呈现不统一。

咱们能够看上面这个例子:

  1. 假如内存外面存着 age=1
  2. CPU0 执行 age+1 操作
  3. CPU1 也执行 age+1 操作

如果有多组缓存,在并发场景下,可能会呈现如下状况:

能够看到,两个 CPU 同时对 age=1 进行加一操作,因为多组缓存的起因,CPU 之间无奈感知对方的批改,数据呈现了不统一,导致最初的后果不是预期的值。

没有缓存也会呈现数据一致性问题,只是有缓存会变得尤其重大。

数据不统一的问题对于程序来说是致命的。所以须要有一种协定,可能让多组缓存看起来就像只有一组缓存那样。

于是,缓存一致性协定就诞生了。

缓存一致性协定

缓存一致性协定就是为了解决缓存一致性问题而诞生,它旨在通过保护多个缓存空间中 缓存行 的统一视图来治理数据一致性。

这里先补充一下缓存行的概念:

缓存行(cache line)是缓存读取的最小单元,缓存行是 2 的整数幂个间断字节,个别为 32-256 个字节,最常见的缓存行大小是 64 个字节。

Linux 零碎能够通过 cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size 命令查看缓存行大小。

Mac 零碎能够通过 sysctl hw.cachelinesize 查看缓存行的大小。

缓存行也是缓存一致性协定治理的最小单位。

实现缓存一致性协定的机制次要有两种,别离是 基于目录 总线嗅探

基于目录

什么是基于目录?

说直白一点就是用一个目录去记录缓存行的应用状况,而后 CPU 要应用某个缓存行的时候,先通过查目录获取此缓存行的应用状况,用这样的形式保证数据一致性。

目录的格局有六种:

  1. 全位向量格局(Full bit vector format)
  2. 粗位向量格局(Coarse bit vector format)
  3. 稠密目录格局(Sparse directory format)
  4. 数字均衡二叉树格局(Number-balanced binary tree format)
  5. 链式目录格局(Chained directory format)
  6. 无限的指针格局(Limited pointer format)

这些目录的名字花里胡哨的,实际上并没有那么简单,只是数据结构和优化形式不一样罢了。

比方全位向量格局就是用 比特位(bit)去记录每个缓存行是否被某个 CPU 缓存。

前面几种格局的目录,无非是在存储和可扩大等方面做了一些优化。

记目录绝对于间接音讯通信来说是比拟耗时的,所以基于目录这种机制实现的缓存一致性协定提早相对来说会偏高。但也有一个益处,第三方目录的存在让通信过程变得简略,通信对总线带宽的占用也会绝对偏少。

所以,基于目录实用于 CPU 外围数量多的大型零碎。

总线嗅探

基于目录依赖实现的缓存一致性协定尽管带宽占用小,然而提早高,并不适宜作为小型的零碎的缓存一致性解决方案,小型零碎更多的是用基于 总线嗅探 的缓存一致性协定。

总线是 CPU 和内存地址和数据交互的桥梁,总线嗅探也就是监听着这座交互桥梁,及时的感知数据的变动。

当 CPU 批改公有缓存外面的数据时,会给总线发送一个事件音讯,通知总线上的其余监听者这个数据被批改了。

其余 CPU 感知到本人公有的缓存中存在某个被批改的数据正本时,能够将缓存的正本更新,也能够让缓存的正本生效。

将缓存的正本更新会产生微小的总线流量,影响零碎的失常运行。所以,在监听到更新事件时,更多的是将公有的缓存正本生效解决,也就是抛弃这个数据正本。

将被批改的数据正本生效的这种形式,有个业余的术语,叫做“写有效”(Write-invalidate),基于“写有效”实现的缓存一致性协定,叫做“写有效”协定,常见的 MSI、MESI、MOSI、MOESI 和 MESIF 协定都属于这一类。

MESI

MESI 协定是一个基于生效的缓存一致性协定,是反对写回(write-back)缓存的最罕用协定,也是当初一种应用最宽泛的缓存一致性协定,它基于总线嗅探实现,用额定的两位给每个缓存行标记状态,并且保护状态的切换,达到缓存一致性的目标。

MESI 状态

MESI 是四个单词的缩写,每个单词别离代表缓存行的一个状态:

  1. M:modified,已批改。缓存行与主存的值不同。如果别的 CPU 内核要读主存这块数据,该缓存行必须回写到主存,状态变为共享状态(S)。
  2. E:exclusive,独占的。缓存行只在以后缓存中,但和主存数据统一。当别的缓存读取它时,状态变为共享;以后写数据时,变为已批改状态(M)。
  3. S:shared,共享的。缓存行也存在于其它缓存中且是洁净的。缓存行能够在任意时刻摈弃。
  4. I:invalid,有效的。缓存行是有效的。

MESI 音讯

MESI 协定中,缓存行状态的切换依赖音讯的传递,MESI 有以下几种音讯:

  1. Read: 读取某个地址的数据。
  2. Read Response: Read 音讯的响应。
  3. Invalidate: 申请其余 CPU invalid 地址对应的缓存行。
  4. Invalidate Acknowledge: Invalidate 音讯的响应。
  5. Read Invalidate: Read + Invalidate 音讯的组合音讯。
  6. Writeback: 该音讯蕴含要回写到内存的地址和数据。

MESI 通过音讯的传递保护了一个 缓存状态机,实现共享内存,至于细节是怎么样的,这里不做过多表述。

如果你对 MESI 不理解的话,我倡议你去这个网站入手试验,能够模仿各种场景,能实时生成动画,比拟好了解。

如果你打不开这个网站,不必焦急,源代码给你扒下来了,回复“MESI”自取,解压在本地运行就行了。

上面就用这个网站演示一个简略的例子:

  1. CPU0 读取 a0
  2. CPU1 写 a0

简略剖析一下:

  1. CPU0 读取 a0,读到 Cache0 之后,因为独占,所以缓存行的状态是 E。
  2. CPU2 写 a0,先把 a0 读到 Cache2,因为共享,所以状态是 S。而后批改 a0 的值,缓存行的状态变成了 E,最初告诉 CPU0,将 a0 所在的缓存行生效。

MESI 的存在保障了缓存一致性,让多核 CPU 可能更好地进行数据交互,那是否意味着 CPU 是否被压迫到极致了呢?

答案是否定的,咱们接着往下看。

文章的后半段依赖前文的知识点,如果我的表述没让你了解前半段的知识点,你能够间接翻到总结局部,那里有我筹备好的思路总结。

如果你筹备好了,咱们持续开车,看看还能怎么压迫 CPU。

Store Buffer

从上文得悉:如果 CPU 对某个数据进行写操作,且这个数据不在公有缓存里,那么 CPU 就会发送一个 Read Invalidate 音讯去读取对应的数据,并让其余的缓存正本生效。

但有一个问题你思考了没有,那就是从发送音讯之后,到接管到所有的响应音讯,两头期待过程对于 CPU 来说是漫长的。

能不能缩小 CPU 期待音讯的工夫呢?

能!store buffer 就是干这个的。

具体怎么干的呢?

store buffer 是 CPU 和缓存两头的一块构造

CPU 在写操作时,能够不期待其余 CPU 响应音讯就间接写到 store buffer,后续收到响应音讯之后,再把 store buffer 外面的数据写入缓存行。

CPU 读数据的时候,也会先判断一下 store buffer 外面有没有数据,如果存在,就优先应用 store buffer 外面的数据(这个机制,叫做“store forwarding”)。

从而进步了 CPU 的利用率,也能保障了在 同一CPU,读写都能程序执行。

留神,这里的读写程序执行说的是 同一 CPU,为什么要强调 同一 呢?

因为,store buffer 的引入并不能保障多 CPU 全局程序执行。

咱们看上面这个例子:

// CPU0 执行
void foo() { 
    a = 1;
    b = 1;
}

// CPU1 执行
void bar() {while(b == 0) continue;
    assert(a == 1);
}

假如 CPU0 执行 foo 办法,CPU1 执行 bar 办法,如果在执行之前,缓存的状况是这样的:

  1. CPU0 缓存了 b,因为独占,所以状态是 E。
  2. CPU1 缓存了 a,因为独占,所以状态是 E。

那么,在有了 store buffer 之后,有可能呈现这种状况(简化了与内存交互的过程):

用文字表述就是:

  1. CPU0 执行 a=1,因为 a 不在 CPU0 的缓存中,有 store buffer 的存在,间接写将 a=1 写到 store buffer,同时发送一个 read invalidate 音讯。
  2. CPU1 执行 while(b==1),因为 b 不在 CPU1 的缓存中,所以 CPU1 发送一个 read 音讯去读。
  3. CPU0 收到 CPU1 的 read 音讯,晓得 CPU1 想要读 b,于是返回一个 read response 音讯,同时将对应缓存行的状态改成 S。
  4. CPU1 收到 read response 音讯,晓得 b=1,于是将 b=1 放到缓存,同时完结 while 循环。
  5. CPU1 执行 assert(a==1),从缓存中拿到 a=0,执行失败。

咱们站在不同的角度剖析剖析:

  1. 站在 CPU0 的角度看本人:a = 1 先于 b = 1,所以 b = 1 的时候 a 肯定曾经等于 1 了。
  2. 站在 CPU0 的角度看 CPU1:因为 b = 1 的时候 a 肯定等于 1,所以 CPU1 因为 b == 1 跳出循环的时候,接下来执行 assert 肯定为胜利,然而实际上失败了,也就是说站在 CPU0 的角度,CPU1 产生了重排序。

那如何解决 store buffer 的引入带来的全局程序性问题呢?

硬件设计师给开发者提供了内存屏障(memory-barrier)指令,咱们只须要应用内存屏障将代码革新一下,在 a = 1 前面加上 smp_mb(),就能打消 store buffer 的引入带来的影响。

// CPU0 执行
void foo() { 
    a = 1;
    smp_mb();
    b = 1;
}

// CPU1 执行
void bar() {while(b == 0) continue;
    assert(a == 1);
}

内存屏障是如何做到全局程序性的呢?

有两种形式,别离是 等 store buffer 失效 进 store buffer 排队

等 store buffer 失效

等 store buffer 失效就是内存屏障后续的写必须期待 store buffer 外面的值都收到了对应的响应音讯,都被写到缓存行外面。

进 store buffer 排队

进 store buffer 排队就是内存屏障后续的写间接写到 store buffer 排队,等 store buffer 后面的写全副被写到缓存行。

从动图上能够看出,两种形式都须要等,然而 等 store buffer 失效 是在 CPU 等,而 进 store buffer 排队 是进 store buffer 等。

所以,进 store buffer 排队 也会绝对高效一些,大多数的零碎采纳的也是这种形式。

Invalidate Queue

内存屏障能解决 store buffer 带来的全局程序性问题。但有一个问题,store buffer 容量十分小,如果在其余 CPU 忙碌的时候响应音讯的速度变慢,store buffer 会很容易地被填满,会间接的影响 CPU 的运行效率。

怎么办呢?

这个问题的本源是响应音讯慢导致 store buffer 被填满,那能不能进步音讯响应速度呢?

能!invalidate queue 呈现了。

invalidate queue 的次要作用就是进步 invalidate 音讯的响应速度。

有了 invalidate queue 之后,CPU 在收到 invalidate 音讯时,能够先不讲对应的缓存行生效,而是将音讯放入 invalidate queue,立刻返回 Invalidate Acknowledge 音讯,而后在要对外发送 invalidate 音讯时,先查看 invalidate queue 中有无该缓存行的 Invalidate 音讯,如果有的话这个时候才解决 Invalidate 音讯。

invalidate queue 尽管能放慢 invalidate 音讯的响应速度,然而也带了全局程序性问题,这个和 store buffer 带来的全局性问题相似。

看上面这个例子:

// CPU0 执行
void foo() { 
    a = 1;
    smp_mb();
    b = 1;
}

// CPU1 执行
void bar() {while(b == 0) continue;
    assert(a == 1);
}

下面这段代码,还是假如 CPU0 执行 foo 办法,CPU1 执行 bar 办法,如果在执行之前,缓存的状况是这样的:

那么,在有了 invalidate queue 之后,有可能呈现这种执行状况:

  1. CPU0 执行 a=1。对应的缓存行在 cpu0 的缓存中是只读的,所以 cpu0 把新值 a=1 放在了它的 store buffer 中,并发送了一个 invalidate 音讯,以便从 cpu1 的 cache 中刷新对应的缓存行。
  2. 当 (b==0) 继续执行时,CPU1 执行,然而蕴含 b 的缓存行不在它的缓存中。因而,它发送一个 read 音讯。
  3. CPU0 执行 b=1。因为曾经缓存了这个缓存行,所以间接更新缓存行,将 b=0 更新成 b=1。
  4. CPU0 接管到 read 音讯,并将蕴含 b 的缓存行发送给 CPU1,同时 b 所在缓存行的状态改成 S。
  5. CPU1 接管到 a 的 invalidate 音讯,将其放入本人的 invalidate 队列,并向 CPU0 发送一个 invalidate 确认音讯。留神,原来的值“a”依然保留在 CPU1 的缓存中。
  6. CPU1 接管到蕴含 b 的缓存行,并将其写到它的缓存中。
  7. CPU1 当初能够在 (b==0) 持续时实现执行,因为它发现 b 的值是 1,它继续执行下一条语句。
  8. CPU1 执行 assert(a==1),因为原来的值 a 依然在 CPU1 的缓存中,所以断言失败了。
  9. cpu1 解决队列中 invalidate 的音讯,并从本人的 cache 中使蕴含 a 的 cache 行生效。然而曾经太迟了。

从这个例子能够看出,在引入 invalidate queue 之后,全局程序性又得不到保障了。

怎么解决呢,和 store buffer 的解决办法是一样的,用内存屏障革新代码:

// CPU0 执行
void foo() { 
    a = 1;
    smp_mb();
    b = 1;
}

// CPU1 执行
void bar() {while(b == 0) continue;
    smp_mb();
    assert(a == 1);
}

革新之后的运行过程不做过多表述,但总结来说就是内存屏障能够解决 invalidate queue 带来的全局程序性问题。

内存屏障和 Lock 指令

内存屏障

从上文得悉,内存屏障有两个作用,解决 store buffer 和 invalidate queue,放弃全局程序性。

但很多状况下,只须要解决 store buffer 和 invalidate queue 中的其中一个即可,所以很多零碎将内存屏障细分成了读屏障(read memory barrier)和写屏障(write memory barrier)。

读屏障用于解决 invalidate queue,写屏障用于解决 store buffer。

以场景的 X86 架构下,不同的内存屏障对应的指令别离是:

  • 读屏障:lfence
  • 写屏障:sfence
  • 读写屏障:mfence

Lock 指令

咱们再回顾一下,在上一篇讲 volatile 的文章中,我提到了 volatile 关键字的底层实现是 lock 前缀指令。

lock 前缀指令和内存屏障到底有什么关系呢?

我认为是没有什么关系的。

只不过 lock 前缀指令一部分性能能达到内存屏障的成果罢了。

这一点在《IA-32 架构软件开发人员手册》上也能找到对应的形容。

手册上给 lock 前缀指令的定义是总线锁,也就是 lock 前缀指令是通过锁住总线保障可见性和禁止指令重排序的。

尽管“总线锁”的说法过于老旧了,当初的零碎更多的是“锁缓存行”。但我想表白的是,lock 前缀指令的核心思想还是“锁”,这和内存屏障有着实质的区别。

回顾问题

咱们再来回顾读者的这两个观点:

  1. 读者:lock 指令触发了缓存一致性协定
  2. 读者:JMM 靠缓存一致性协定保障

对于第一个观点,我的认识是:

lock 前缀指令的作用是锁住缓存行,能起到和读写屏障一样的成果,而读写屏障解决的问题是 store buffer 和 invalidate queue 带来的 全局 程序性问题。

缓存性一致性问题是用来解决多核零碎下的缓存一致性问题,是由硬件来保障的,对软件来说是通明的,伴生于多核零碎,是一个客观存在的货色,并不需要触发。

对于第二个观点,我的认识是:

JMM 是一个虚构的内存模型,它形象了 JVM 的运行机制,让 Java 开发人员能更好的了解 JVM 的运行机制,它封装了 CPU 底层的实现,让 Java 的开发人员能够更好的进行开发,不被底层的实现细节折磨。

JMM 想表白的是,在某种程度上,你能够通过一些 Java 关键字让 Java 的内存模型达到一种强一致性。

所以 JMM 和缓存一致性协定并不挂钩,实质上就没什么分割。举个例子,你不能因为你独身,而后刘亦菲也独身,你就说刘亦菲独身是因为在等你。

总结

本文对一些没有根底的同学来说,了解起来会略微吃力一点,所以咱们总结一下全文的一个思路,应酬应酬一般面试是没什么问题的。

  1. 因为内存的速度和 CPU 匹配不上,所以在内存和 CPU 之间加了多级缓存。
  2. 单核 CPU 独享不会呈现数据不统一的问题,然而多核状况下会有缓存一致性问题。
  3. 缓存一致性协定就是为了解决多组缓存导致的缓存一致性问题。
  4. 缓存一致性协定有两种实现形式,一个是基于目录的,一个是基于总线嗅探的。
  5. 基于目录的形式提早高,然而占用总线流量小,适宜 CPU 核数多的零碎。
  6. 基于总线嗅探的形式提早低,然而占用总线流量大,适宜 CPU 核数小的零碎。
  7. 常见的 MESI 协定就是基于总线嗅探实现的。
  8. MESI 解决了缓存一致性问题,然而还是不能将 CPU 性能压迫到极致。
  9. 为了进一步压迫 CPU,所以引入了 store buffer 和 invalidate queue。
  10. store buffer 和 invalidate queue 的引入导致不满足全局有序,所以须要有写屏障和读屏障。
  11. X86 架构下的读屏障指令是 lfenc,写屏障指令是 sfence,读写屏障指令是 mfence。
  12. lock 前缀指令间接锁缓存行,也能达到内存屏障的成果。
  13. x86 架构下,volatile 的底层实现就是 lock 前缀指令。
  14. JMM 是一个模型,是一个便于 Java 开发人员开发的形象模型。
  15. 缓存性一致性协定是为了解决 CPU 多核零碎下的数据一致性问题,是一个客观存在的货色,不须要去触发。
  16. JMM 和缓存一致性协定没有一毛钱关系。
  17. JMM 和 MESI 没有一毛钱关系。

写在最初

这篇文章,次要参考了维基百科和 Linux 内核大牛 Paul E. McKenney 的论文以及书籍,如果你想对并发编程的底层有更深刻的钻研,Paul E. McKenney 的论文和书籍十分值得一看,有须要的后盾回复“MESI”自取。

因为笔者程度无限,文章中难免会有谬误,如果你发现了,欢送指出!

好了,明天的文章就到这里完结了,我是小汪,咱们下期再见!

欢送关注我的集体公众号:CoderW

参考资料

  • 《深刻了解并行编程》
  • 《IA-32+ 架构软件开发人员手册》
  • 《Memory Barriers: a Hardware View for Software Hackers》
  • 《Is Parallel Programming Hard, And, If So, What Can You Do About It?》

正文完
 0