干掉面试官2-CPU中的缓存、缓存一致性、伪共享和缓存行填充

文章目录:

1、 CPU缓存

2、 总线锁和缓存锁

3、 缓存行

4、 缓存一致性协议(如:MESI)

5、 伪共享(false sharing)问题

6、 伪共享解决方案(如:缓存行填充)

6.1 Disruptor为什么这么快?

6.2 实验证明

6.3 Jdk8中自带注解@Contended

7、 总结

本篇文章主要介绍CPU缓存相关

的内容。 亦是上一篇文章干掉面试官-volatile底层原理详解的延伸和补充。

并发编程为何如此复杂?并发编程为什么会产生可见性、有序性、原子性的线程或内存问题?

归根结底,还是计算机硬件高速发展的原因。如果是单核的cpu,肯定不会出现多线程并发的安全问题。正是因为多核CPU架构,以及CPU缓存才导致一系列的并发问题。

1、 CPU缓存

相信大家都见过下面这张图或类似的图,计算机的存储层次结构像一座金字塔。越往上访问速度越快、成本更高,所以空间也越小。越往下访问速度越慢、成本越低,空间也就越大。

干掉面试官2-CPU中的缓存、缓存一致性、伪共享和缓存行填充

CPU的运算速度最快,内存的读写速度无法和其速度匹配。假如定义cpu的一次存储或访问为一个时钟周期,那么内存的一次运算通常需要几十甚至几百个始终周期。如果在CPU直接读取内存进行运算,那么CPU大部分时间都在等在内存的访问,利用率仅有几十分之一甚至几百分之一。为了解决CPU运算速度与内存读写速度不匹配的矛盾,在CPU和内存之间,引入了L1高速缓存、L2高速缓存、L3高速缓存,通过每一级缓存中所存储的数据全部都是下一级缓存中的一部分,当CPU需要数据时,就从缓存中获取,从而加快读写速度,提高CPU利用率、提升整体效率。

  • L1高速缓存:也叫一级缓存。一般内置在内核旁边,是与CPU结合最为紧密的CPU缓存。一次访问只需要2~4个时钟周期
  • L2高速缓存:也叫二级缓存。空间比L1缓存大,速度比L1缓存略慢。一次访问约需要10多个时钟周期
  • L3高速缓存:也叫三级缓存。部分单CPU多核心的才会有的缓存,介于多核和内存之间。存储空间已达Mb级别,一次访问约需要数十个时钟周期。

当CPU要读取一个数据时,首先从L1缓存查找,命中则返回;若未命中,再从L2缓存中查找,如果还没有则从L3缓存查找(如果有L3缓存的话)。如果还是没有,则从内存中查找,并将读取到的数据逐级放入缓存。

干掉面试官2-CPU中的缓存、缓存一致性、伪共享和缓存行填充

2、 总线锁和缓存锁

上一篇文章讲到过lock前缀

lock前缀,会保证某个处理器对共享内存(一般是缓存行cacheline,这里记住缓存行概念,后续重点介绍)的独占使用。它将本处理器缓存写入内存,该写入操作会引起其他处理器或内核对应的缓存失效。通过独占内存、使其他处理器缓存失效,达到了“指令重排序无法越过内存屏障”的作用

总线锁 :顾名思义就是,锁住总线。通过处理器发出lock指令,总线接受到指令后,其他处理器的请求就会被阻塞,直到此处理器执行完成。这样,处理器就可以独占共享内存的使用。但是,总线锁存在较大的缺点,一旦某个处理器获取总线锁,其他处理器都只能阻塞等待,多处理器的优势就无法发挥

于是,经过发展、优化,又产生了缓存锁。

缓存锁:不需锁定总线,只需要“锁定”被缓存的共享对象(实际为:缓存行)即可,接受到lock指令,通过

缓存一致性协议,维护本处理器内部缓存和其他处理器缓存的一致性。相比总线锁,会提高cpu利用率。

但是缓存锁也不是万能,有些场景和情况依然必须通过总线锁才能完成。

这里又出现了两个新概念:缓存行缓存一致性协议

3、 缓存行

上一小章节中提到,缓存锁会“锁定”共享对象,如果仅锁定所用对象,那么有大有小、随用随取,对于CPU来说利用率还达不到最大化。所以采用,一次获取一整块的内存数据,放入缓存。那么这一块数据,通常称为缓存行(cache line)。缓存行(cache line)是CPU缓存中可分配、操作的最小存储单元。与CPU架构有关,通常有32字节、64字节、128字节不等。目前64位架构下,64字节最为常用。

4、 缓存一致性协议(如:MESI)

每个处理器都有自己的高速缓存,而又共享同一主内存。当多个处理器都涉及同一块主内存区域的更改时,将导致各自的的缓存数据不一致。那同步到主内存时该以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,来保证处理器间缓存的一致性。这类协议有MSI、MESI、MOSI等。

下面重点介绍应用较为广泛的MESI协议。MESI是Modified(修改)、Exclusive(独占)、Shared(共享)、Invaild(失效)四种状态的缩写,是用来修饰缓存行的状态。在每个缓存行前额外使用2bit,来表示此四种状态。

  • Modified(修改):该缓存行仅出现在此cpu缓存中,缓存已被修改,和内存中不一致,等待同步至内存。
  • Exclusive(独占):该缓存行仅出现在此cpu缓存中,缓存和内存中保持一致。
  • Shared(共享):该缓存行可能出现在多个cpu缓存中,且多个cpu缓存的缓存行和内存中的数据一致。
  • Invalid(失效):由于其他cpu修改了缓存行,导致本cpu中的缓存行失效。

在MESI协议中,每个缓存行不仅知道自己的读写操作,而且也监听其它缓存行的读写操作。每个缓存行的状态根据本cpu和其它cpu的读写操作在4个状态间进行迁移。

干掉面试官2-CPU中的缓存、缓存一致性、伪共享和缓存行填充

它的监听(嗅探)机制:

  • 当缓存行处于Modified状态时,会时刻监听其他cpu对该缓存行对应主内存地址的读取操作,一旦监听到,将本cpu的缓存行写回内存,并标记为Shared状态
  • 当缓存行处于Exclusive状态时,会时刻监听其他cpu对该缓存行对应主内存地址的读取操作,一旦监听到,将本cpu的缓存行标记为Shared状态
  • 当缓存行处于Shared状态时,会时刻监听其他cpu对使缓存行失效的指令(即其他cpu的写入操作),一旦监听到,将本cpu的缓存行标记为Invalid状态(其他cpu进入Modified状态)
  • 当缓存行处于Invalid状态时,从内存中读取,否则直接从缓存读取

总结:当某个cpu修改缓存行数据时,其他的cpu通过监听机制获悉共享缓存行的数据被修改,会使其共享缓存行失效。本cpu会将修改后的缓存行写回到主内存中。此时其他的cpu如果需要此缓存行共享数据,则从主内存中重新加载,并放入缓存,以此完成了缓存一致性

5、 伪共享(false sharing)问题

缓存一致性协议针对的是最小存取单元:缓存行。依照64字节的缓存行为例,内存中连续的64字节都会被加载到缓存行中,除了目标数据还会有其他数据。

如下图所示,假如变量x和变量y共处在同一缓存行中,core1需要操作变量x,core2需要操作变量y。

  • core1修改缓存行内的变量x后,按照缓存一致性协议,core2需将缓存行置为失效,core1将最新缓存行数据写回内存。
  • core2需重新从内存中加载包含变量y的缓存行数据,并放置缓存。如果core2修改变量y,需要core1将缓存行置为失效,core2将最新缓存写回内存。
  • core1或其他处理器如需操作同一缓存行内的其他数据,同上述步骤。

上述例子,就是缓存行的伪共享问题。总结来说,就是多核多线程并发场景下,多核要操作的不同变量处于同一缓存行,某cpu更新缓存行中数据,并将其写回缓存,同时其他处理器会使该缓存行失效,如需使用,还需从内存中重新加载。这对效率产生了较大的影响。

干掉面试官2-CPU中的缓存、缓存一致性、伪共享和缓存行填充

6、 伪共享解决方案(如:缓存行填充)

伪共享问题的解决思路有也很典型:空间换时间

以64字节的缓存行为例,伪共享问题产生的前提是,并发情况下,不同cpu对缓存行中不同变量的操作引起的。那么,如果把缓存行中仅存储目标变量,其余空间采用“无用”数据填充补齐64字节,就不会才产生伪共享问题。这种方式就是:

缓存行填充(也称缓存行对齐)

Talk is cheap,show me the code.

下面,从三个实例去给大家解释完缓存行填充,让大家也能应用到自己的代码中去。

6.1 Disruptor为什么这么快?

Disruptor是一个性能极强的开源的无锁并发框架,基于Disruptor的LMAX架构交易平台,号称单线程内每秒可处理600万笔订单。简直是一个不折不扣的性能小钢炮。

Disruptor框架的核心是它的Ringbuffer环形缓冲。这里不做框架的具体分析,有兴趣可在github下载源码(传送门)。推荐大家阅读并发编程网对Disruptor框架的介绍(传送门)。

它的定位是高性能并发框架,肯定也会遇到我们上述的缓存伪共享问题,我们看一下Disruptor是怎么解决的?

public long p1, p2, p3, p4, p5, p6, p7; // cache line padding

private volatile long cursor = INITIAL_CURSOR_VALUE;

public long p8, p9, p10, p11, p12, p13, p14; // cache line padding

干掉面试官2-CPU中的缓存、缓存一致性、伪共享和缓存行填充


干掉面试官2-CPU中的缓存、缓存一致性、伪共享和缓存行填充

Disruptor源码中,有大量类似于上述的代码,在目标变量前后定义多个"无实际含义的"变量进行缓存行填充(cache line padding)。

基础类型long在java中占用8字节,在额外填充7个long类型的变量,这样在从内存中获取目标变量放入缓存行时,可以达到缓存行中除了目标变量,剩下都是填充变量(由于无业务含义,其他cpu不会对其进行修改)。曲线救国,解决了缓存行伪共享的问题。思想:空间换时间

6.2 实验证明

看了上述代码,可能还有人心有存疑,搞出这么多无用的字段,效率能提高?我不信。

下面我们就自己写一个demo来证明:缓存行填充能提高并发效率

例1:不填充缓存行

public class CacheLinePaddingBefore {

private static class Entity {

public volatile long x = 1L;

}

public static Entity[] arr = new Entity[2];

static {

arr[0] = new Entity();

arr[1] = new Entity();

}

public static void main(String[] args) throws InterruptedException {

Thread threadA = new Thread(() -> {

for (long i = 0; i < 1000_0000; i++) {

arr[0].x = i;

}

}, "ThreadA");

Thread threadB = new Thread(() -> {

for (long i = 0; i < 1000_0000; i++) {

arr[1].x = i;

}

}, "ThreadB");

final long start = System.nanoTime();

threadA.start();

threadB.start();

threadA.join();

threadB.join();

final long end = System.nanoTime();

System.out.println("耗时:" + (end - start) / 100_0000);

}

}

例1思路:

1、定义一个长度为2的数组arr,数组中是一个仅有一个long类型变量的对象;

2、定义两个线程A和B,线程A修改arr[0],线程B修改arr[1]。线程A和线程B并发修改1千万次;

3、此处定义数组的目的是:保证线程A和线程B修改的变量尽可能是连续的,即两个变量在同一缓存行中,以模拟伪共享问题。

测试结果:多次运行上述demo,平均耗时:240ms左右。

例2:填充缓存行

public class CacheLinePaddingAfter {

// 定义7个long类型变量,进行缓存行填充

private static class Padding{

public volatile long p1, p2, p3, p4, p5, p6, p7;

}

private static class Entity extends Padding{

// 使用@sun.misc.Contended注解,必须添加此参数:-XX:-RestrictContended

// @sun.misc.Contended

public volatile long x = 0L;

}

public static Entity[] arr = new Entity[2];

static {

arr[0] = new Entity();

arr[1] = new Entity();

}

public static void main(String[] args) throws InterruptedException {

Thread threadA = new Thread(() -> {

for (int i = 0; i < 1000_0000; i++) {

arr[0].x = i;

}

}, "ThreadA");

Thread threadB = new Thread(() -> {

for (int i = 0; i < 1000_0000; i++) {

arr[1].x = i;

}

}, "ThreadB");

final long start = System.nanoTime();

threadA.start();

threadB.start();

threadA.join();

threadB.join();

final long end = System.nanoTime();

System.out.println("耗时:" + (end - start)/100_0000);

}

}

例2思路:

​ 1、定义一个包含7个long类型的“无实际意义”字段的填充对象;

​ 2、实际对象Entity继承填充对象,达到7+1=8个long类型字段,可以填充一整个64字节的缓存行;

​ 3、重复例1中的动作。

测试结果:多次运行上述demo,平均耗时:70ms左右。

大家也可以直接拿上述两个例子在自己的电脑进行测试。例2的执行效率远超超例1的执行效率。我们通过实践证明:缓存行填充显著提高并发效率

6.3 Jdk8中自带注解@Contended

Jdk8中引入了@sun.misc.Contended这个注解来解决缓存伪共享问题。使用此注解有一个前提,必须开启JVM参数-XX:-RestrictContended,此注解才会生效。

此注解在一定程度上同样解决了缓存伪共享问题。但底层原理并非缓存行填充,而是通过对对象头内存布局的优化,将那些可能会被同一个线程几乎同时写的字段分组到一起,避免形成竞争,来达到避免缓存伪共享的目的。此处不再铺开讲述,有兴趣的可阅读文章:并发编程网-有助于减少伪共享的@Contended注解和此文开头提及Aleksey Shipilev的这封邮件

Jdk内部也大量使用了此注解

例1:java.lang.Thread类,修饰字段

干掉面试官2-CPU中的缓存、缓存一致性、伪共享和缓存行填充

例2:java.util.concurrent.ConcurrentHashMap类,修饰类

干掉面试官2-CPU中的缓存、缓存一致性、伪共享和缓存行填充

例3:使用注解@Contended改造上一章中的例2:缓存行填充demo

public class CacheLinePaddingAfter {

private static class Entity{

// 使用@sun.misc.Contended注解,必须添加此参数:-XX:-RestrictContended

@sun.misc.Contended

public volatile long x = 0L;

}

public static Entity[] arr = new Entity[2];

static {

arr[0] = new Entity();

arr[1] = new Entity();

}

public static void main(String[] args) throws InterruptedException {

Thread threadA = new Thread(() -> {

for (int i = 0; i < 1000_0000; i++) {

arr[0].x = i;

}

}, "ThreadA");

Thread threadB = new Thread(() -> {

for (int i = 0; i < 1000_0000; i++) {

arr[1].x = i;

}

}, "ThreadB");

final long start = System.nanoTime();

threadA.start();

threadB.start();

threadA.join();

threadB.join();

final long end = System.nanoTime();

System.out.println("耗时:" + (end - start)/100_0000);

}

}

测试结果:多次运行上述demo,平均耗时:70ms左右。

7、 总结

文章开头提及本篇文章是上一篇文章volatile底层原理详解(上) 的延伸和补充。所以下面带大家整体回顾下这两张的内容。如果没看上篇文章或对volatile暂无兴趣,请直接看下面的第3点总结即可。

我们对volatile关键字的作用和原理的了解,从Java代码层面一路聊到计算机的硬件层面,硬件层面从cpu缓存到缓存行、缓存锁,再到缓存一致性协议,最后分析了缓存行的伪共享问题以及它的解决方案。希望看完整篇之后再从头到尾串一遍,将其中的“点”串成“线”,做到举一反三,能对日后的工作有所帮助。最后,通过几个问题,帮助大家回顾文章内容:

1、并发编程中的三大特性:原子性、可见性、有序性。volatile修饰的变量如何保证可见性、有序性?为何不能保证原子性?

2、volatile的底层实现:

a. Java代码中如何使用volatile关键字?

b. volatile修饰的变量,编译成class字节码后,变量的访问标志有什么变化?

c. JVM运行时,是判断变量是被的volatile修饰的?赋值和取值同普通变量有何区别的?orderAccess.hpp头文件中是对内存屏障的定义和作用的描述是怎样的?lock前缀指令的作用是什么?

d. 打印汇编输出,可以看到JVM级别的实现:lock前缀指令。

3、可见性问题,有序性问题的产生一部分是cpu硬件架构引起的,那么就有必要了解它的硬件原理,以及如何利用硬件写出高性能的并发程序。

a. 金字塔的cpu存储结构要能直观呈现在脑子中。Cpu为什么要有一、二、三级缓存?

b. Lock前缀的指令,能保证某个处理器对共享内存的独占使用,并且达到指令重排序无法越过内存屏障的目的。它是通过对缓存加锁来实现。

缓存锁了解一下。

c. 缓存锁针对的是对缓存行加锁。缓存行是什么?缓存一致性协议是什么?

d. 缓存行伪共享问题是如何出现的?

e. 缓存行伪共享问题的解决方案:缓存行填充JDK8中自带的@Contented注解。并发编程中,我们可以尝试应用,来提高程序运行效率。


分享到:


相關文章: