伪共享

CPU 缓存架构

CPU 是计算机的核心,所有运算和程序最终都由它来执行;主内存 (RAM) 是数据存储的地方

随着技术的发展,CPU 的计算速度越来越快,但它和主内存的交互速度 (I/O) 相比于它的计算速度来说相差太大

所以才有了中间媒介:高速缓存,它很好的解决了 CPU 与主内存速度之间的矛盾,它们三者之间的关系如下图所示:

1

从图中可以看出 IO 速度和存储大小成反比,IO 速度越快的缓存也越小;IO 速度越慢的缓存也越大

一级缓存 (L1) 最小也最快,它紧靠着使用它的 CPU 内核,只能被一个单独的 CPU 内核使用

二级缓存 (L2) 大一点,也慢一点,它紧靠着一级缓存,同样的只能被一个单独的 CPU 内核使用

三级缓存 (L3) 在现代多核机器中更普遍,更大,也更慢,它可以被单个插槽上的所有 CPU 内核共享

最后,主内存保存着程序运行的所有数据,最大,也最慢 (除硬盘外),它可以被所有插槽上的所有 CPU 内核共享

当 CPU 执行运算时,先去 L1 查找所需的数据,如果没有找到再去 L2 查找,如果还没有找到再去 L3 查找,最后如果缓存中都没有找到才会去主内存中拿,同时也会把从主内存中拿到的数据进行缓存,方便下次直接从缓存中访问

CPU 缓存行

介绍了缓存和 CPU、主内存的关系以及它的特点,再来看看它内部的构造

缓存是由缓存行构成,通常是 64 字节 (常用处理器缓存行是 64 字节,比较老的处理器缓存行是 32 字节),并且它有效地引用主内存中的一块地址

Java 中 long 类型的变量是 8 字节,因此一个缓存行中可以存 8 个 long 类型的变量,如下图所示:

2

CPU 和缓存交互的单位是缓存行,换句话说,CPU 每次从缓存中加载数据,必须一次加载一整个缓存行,也就是 64 字节

这种免费加载对于处理内存中连续的元素有好处,如:累加数组中的元素和,如下面代码所示:

对于上面的代码,CPU 只需执行一次加载操作即可将所需数据全部加载到缓存中;但这种好处仅限于连续内存的情况,比如链表就无法享受这种好处,因为链表中的数据在内存中不连续

伪共享

上面介绍了缓存的内部结构,即由缓存行构成,而且内存和缓存之间交互的最小单位是缓存行

现在假设一种场景:有两个 long 类型的变量在内存中连续存储,CPU1 只对变量 a 有写操作,CPU2 只对变量 b 有写操作,具体如下图所示:

3

注意:由于 L1 是 CPU 核心独占,所以这里把 L1 和 CPU 画到一起了

CPU1 和 CPU2 均从主内存中一次性加载了 64 字节,因为这 64 字节中包含了各自需要操作的变量 a 和 b

如果 CPU1 对变量 a 进行了写操作,还未写回主内存,这时会将其它包含了变量 a 的缓存行设置为失效状态,如 CPU2 中的缓存行

如果此时 CPU2 要对变量 b 进行写操作,首先会发现自己加载的缓存行已经失效,所以会丢弃已拥有的缓存,重新从主内存中加载,具体过程如下图所示:

4

关于这个过程推荐一个可视化网站 VivioJS MESI animation help

这样就出现了问题,变量 b 和变量 a 完全不相干,每次却因为变量 a 的更新导致更新变量 b 时需要从主内存重新读取,它被缓存未命中拖慢了

当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享

下面用一个具体的例子来感受一下伪共享对性能的影响:

根据上面的介绍可以知道对象Pointer中的两个字段xy在一个缓存行中,耗时为 62474!!

避免伪共享

方法一:填充字段

通过在xy之间填充 7 个 long 类型字段,让它们俩不在同一个缓冲行中

修改后的耗时为 15222,有了很明显的提升!!

方法二:自创 Long 类型

创建一个属于自己的 Long 类型,它刚好 64 字节,自己创建的 Long 类型一定独占一个缓存行,就是有点费空间

同时把pointer.x++;修改为pointer.x.value++;;把pointer.y++;修改为pointer.y.value++;

修改后的耗时为 18280,相比于原始版本也有了很明显的提升!!

方法三:使用注解

使用注解@sun.misc.Contended也可以达到和方法一或方法二一样的效果

但是默认使用这个注解是无效的,需要在 JVM 启动参数中加上-XX:-RestrictContended才会生效

修改后的耗时为 13285,有了很明显的提升!!

注意:以上三种方法中的前两种是通过添加字段的形式实现的,加的字段并没有使用,所以可能会被 JVM 优化调,所以建议使用第三种方法

参考文章