CPU三级缓存

要聊可见性,这事儿还得从计算机的组成开始说起,我们都知道,计算机由CPU、内存、磁盘、显卡、外设等几部分组成,对于我们程序员而言,写代码主要关注CPU和内存两部分。放几张马士兵老师的图:

再说CPU,众所周知,CPU同一时间点,只能执行一个线程,多个线程之间通过争抢CPU资源获得执行权,实现一种伪并发的效果。但这其实说的是上古CPU,那种单核CPU。现在的CPU,其实都是多核了。准确的说法,应该是一个核在同一时间点,只能执行一个线程。(题外话,这句话其实现在来说依然过时了,英特尔公司研发出的“超线程技术”,把一个物理核心模拟成两个逻辑核心,允许在每个内核上运行多个线程,就是我们常听到的什么4核8线程,8核16线程CPU。还有最新的大小核架构,这里不展开了)

CPU的运行速度是非常非常快的,大约是内存的100倍。什么意思呢?如果CPU的一个计算单元,去访问寄存器需要1ns,那么访问内存,就需要100ns。而且根据摩尔定律,CPU的速度每18个月就会翻倍,相当于每年增⻓ 60% 左右,内存的速度当然也会不断增⻓,但是增⻓的速度远小于 CPU,平均每年只增长7%左右。于是,CPU与内存性能的差距不断拉大。

如果我们访问内存中的一个数据A,那么很有可能接下来会再次访问到这个数据A,这叫时间局部性原理。要是CPU每次都是去内存取数据A,其实有99%的时间是等待浪费了。那么我们该如何提高性能呢?编程思想万变不离其宗,既然一个数据可能会被频繁使用,而每次去内存取数据太慢,那就加缓存好了。

现代CPU为了增加CPU读写内存性能,就在CPU和内存之间增加了多级cache,也称高速缓存L1、L2、L3,其中L3是多个核心共享的。这其实就是我们常听说的,CPU三级缓存。

CPU读内存时首先从L1找起(这里有个很细的细节,有的时候会直接去L2找,跳过L1),能找到直接返回,否则就要在L2中找,找不到就到L3中找,还找不到就去内存中找了(内存还没有的话就去磁盘找)。找到之后,会把找到的数据放到L3、L2、L1里,下次再找,直接就在L1里找到了。

至于为什么是三级缓存,而不是两级四级呢?其实很简单,越往上,虽然存储介质速度越快,但造价越高容量也越小;越往下,虽然存储介质速度越慢,但造价越低容量也越大。三级缓存,是一个工业实践后,平衡经济与效率的最佳方案。

缓存行

这里就有个问题了,如果CPU每次要取数据,都要走一遍上面这个流程,明显效率也不高啊。比如我要访问一个数组的数据,对数组做个遍历,去拿第一数据,走一遍上面的流程,去拿第二个,又走一遍,这太麻烦了。也许我们平时使用redis等缓存的时候,会觉得这么做没什么毛病,但CPU是要追求极致的性能的,这点性能损耗就是不能接受的。(类似懒汉式,用到的时候才去查,然后放到缓存里,第一次执行效率不高)

那么我们想提高效率又该怎么办呢?

如果我们访问内存中的一个数据A,还很有可能访问与数据A相邻的数据B、数据C,这叫空间局部性原理。那所幸我们在取A的时候,就把A周围的数据一次都取回来,一次取一批数据。这解决方案又是一个编程思想,批处理。也可以说是饿汉式,提前把可能要用到的数据一起加载到CPU缓存里。

既然是批处理,那就会有个问题,这个“一批”,多大合适呢?是 5 bytes,10 bytes,还是多少?很明显,大了不合适,浪费缓存空间,小了也不合适,缓存命中率不高。所以同样是经过大量工业实践,最终确定了一个最佳大小,64 bytes(最常见的缓存行大小,小部分cpu可能是别的大小)。我们把这一次读取的 64 bytes 数据,称之为 Cache Line(缓存行)(也有文章称之为缓存段,缓存块的)。CPU每次从内存中读取数据,会一次将 64 bytes 的数据一起读回去,并放到三级缓存中,大大提高了CPU读取数据的性能。

缓存一致性

到目前为止,为了尽可能的提高CPU的性能,我们引入了三级缓存,引入了缓存行,但还有一个问题没有解决,那就是缓存一致性问题。

最开始的时候我们就说了,线程和CPU的核,在同一时间点是一一对应的。如上图, X 和 Y 在内存里是挨着的,在多核情况下,或者叫多线程情况下,如果左边的核去获取内存中的数据 X=0,右边的去获取数据 Y=0 ,因为缓存行的存在,两个核内的L1、L2缓存里,都会缓存 X 和 Y 的值。

这个时候我们想一下,如果在左边的核里,我们将 X 的值给改成了1,右边的核它不知道啊,右边核的L1、L2里 X 的值还是0,这就会产生数据的不一致。这就是线程可见性的问题,当然即便不考虑缓存行,两个线程同时读写同一个数据,也会有可见性问题。所以一定要有一种机制,一个核将数据修改了,要通知其他核做相应的修改,这个机制我们就叫做缓存一致性协议。缓存一致性协议有好多种,每种CPU不一样,其中最著名的是英特尔的MESI,其他厂商的CPU用的未必是这个。

注意,这是硬件级别的机制,和软件层面无关,和 volatile 无关。以后要是有人和你聊缓存一致性协议,跟 volatile 一起聊,你心里一定要清楚,这事儿是不对的。

缓存行填充的编程技巧

我们来看个小程序:

public class CacheLine {

    static class T {
// long p1, p2, p3, p4, p5, p6, p7;
long x = 0L; // 8 bytes
// long p9, p10, p11, p12, p13, p14, p15;
} static T[] arr = new T[2]; static {
arr[0] = new T();
arr[1] = new T();
} public static void main(String[] args) throws InterruptedException {
CountDownLatch cdl = new CountDownLatch(2); Thread t0 = new Thread(() -> {
for (long i = 0L; i < 10_0000_0000L; i++) {
arr[0].x = i;
}
cdl.countDown();
}); Thread t1 = new Thread(() -> {
for (long i = 0L; i < 10_0000_0000L; i++) {
arr[1].x = i;
}
cdl.countDown();
}); long nanoTime = System.nanoTime();
t0.start();
t1.start();
cdl.await();
System.out.println((System.nanoTime() - nanoTime) / 1000000);
} }

我来解释下这个小程序是干什么的,很简单。有个静态内部类 T,其中有一个 long 类型的成员变量 x,占了8个bytes。然后我构建了一个 T 对象的 arr 数组,数组长度是2,数组里面放了两个 T 对象实例,数据准备完毕。现在我起了两个线程,第一个线程将 arr 数组中的第一个 T 对象的成员变量 x 修改10亿次。第二个线程将 arr 数组中的第二个 T 对象的成员变量 x 修改10亿次。然后统计两个线程改完共耗时多久。(注意下此处是两个T对象,也就分别有两个成员变量x,两个线程改的x不是同一个)

看结果:

耗时1195毫秒。

现在我们修改下程序,将上面静态内部类 T 中注释掉的那两行代码放开:

    static class T {
long p1, p2, p3, p4, p5, p6, p7;
long x = 0L; // 8 bytes
long p9, p10, p11, p12, p13, p14, p15;
}

现在和之前的区别是,之前静态内部类 T 中只有一个 long 类型的成员变量 x,占了8个bytes。现在静态内部类 T 中成员变量 x 前面有7个 long 类型的成员变量,共56个bytes,后面也有7个 long 类型的成员变量,共56个bytes,除此以外没有差别。第二种修改后的情况下,T 对象内部大概是长这样的:

我们再运行一下程序看结果:

耗时509毫秒!速度居然快了1倍,神奇不。第二种情况只是在 x 前后各放了56个bytes的数据,效率就提高,而没放数据,效率就变低。大家想想这是为什么呢?

我们想一下,在我们前后没放 56 bytes 数据的时候,一个 x 占 8 bytes 空间,数组中有两个 T 对象,那么两个 x 在内存中大概率是挨在一起的。而我们说由于缓存行的存在,一个缓存行是 64 bytes,装两个 8 bytes 肯定没问题,两个 x 也就大概率位于同一个缓存行内。这就意味着,线程1在取其中一个 T1.x 的时候,会将整行数据都读回来,线程2也是同理。两个线程都会将整行数据读到自己的L1、L2缓存中。

那么线程1在修改 T1.x 的时候,由于缓存一致性协议,就要通知线程2,T1.x 已经被修改了,这肯定需要时间,效率就低了。当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这被称之为伪共享

而当我在 x 的前后各加了 56 bytes 数据时,那么两个 x ,就绝对不会位于同一个缓存行内了。

既然两个 x 不会位于同一个缓存行内,那么两个线程分别修改两个 x ,每个 x 只会在自己线程的缓存内,也就不会有缓存一致性问题,也就不需要互相通知,效率就高了,避免了伪共享导致的性能损耗。这和编程语言没关系,什么语言测试都是如此,因为缓存一致性协议是硬件级别的机制。

问个问题,我要是只在 x 前面加 56 bytes 的数据,后面不加行不行?貌似这样两个 x 也不会在同一缓存行内啊?在这个demo小程序中是可以的,但如果我不是两个 x ,而是一个 x,一个 y 呢?你只在x前面加 56 bytes 数据,y 依然有可能和 x 位于同一缓存行,那一个线程修改 x ,一个线程修改 y ,就还是会有上面的问题了。所以,为了保证任何时候 x 都不会有这种问题,最好是在前后都加上 56 bytes 的数据,就可以保证万无一失。我们把这个编程技巧叫做缓存行填充,主要适用于频繁写共享数据上。

(题外话,Java8 之前可以给变量前后分别填充7个long类型进行缓存行填充,而 Java 8 中已经提供了官方的解决方案,Java 8 中新增了一个注解: @sun.misc.Contended。加上这个注解的字段会自动补齐缓存行,需要注意的是此注解默认是无效的,需要在 jvm 启动时设置 -XX:-RestrictContended 才会生效。)

肯定有人要说了,这也太底层了太细节了,感觉没必要啊,实际开发中真的有人这么用吗?

有一款开源软件,叫Disruptor,是一个高性能的异步处理框架,能够在无锁的情况下实现队列的并发操作,号称能够在一个线程里每秒处理6百万笔订单,可以说是单机最快的MQ。Disruptor中的一个核心结构,就是环形缓冲区,RingBuffer。我们去看下它的源码:

来,看到了什么,知道这7个变量是干啥的吗,如果没有上面的知识,你肯定看不懂,是不是就有人在实际开发中用到了呢。最上面的INITIAL_CURSOR_VALUE变量,就是环形缓冲区的指针起始位置。有人说不对啊,只有后面 56 bytes,没有前面的 56 bytes 呀。别急,这个类叫 RingBuffer ,它的父类叫 RingBufferFields,RingBufferFields 还有个父类叫 RingBufferPad,点进去看看:

RingBuffer 的爷爷类里,还有7个long类型数据,占了 56 bytes,所以INITIAL_CURSOR_VALUE变量的前面有 56 bytes 数据,后面也有 56 bytes 数据,所以这也是 Disruptor 性能非常高的原因之一。另一个原因是 Disruptor 底层用的是CAS自旋锁,这个这次就不展开了,后面讲原子性的时候再说。

并发bug之源(一)-可见性的更多相关文章

  1. 并发Bug之源有三,请睁大眼睛看清它们

    写在前面 生活中你一定听说过--能者多劳 作为 Java 程序员,你一定听过--这个功能请求慢,能加一层缓存或优化一下 SQL 吗? 看过中国古代神话故事的也一定听过--天上一天,地上一年 一切设计来 ...

  2. java 并发编程——Thread 源码重新学习

    Java 并发编程系列文章 Java 并发基础——线程安全性 Java 并发编程——Callable+Future+FutureTask java 并发编程——Thread 源码重新学习 java并发 ...

  3. 并发工具CyclicBarrier源码分析及应用

      本文首发于微信公众号[猿灯塔],转载引用请说明出处 今天呢!灯塔君跟大家讲: 并发工具CyclicBarrier源码分析及应用 一.CyclicBarrier简介 1.简介 CyclicBarri ...

  4. JDK中的并发bug?

    最近研究Java并发,无意中在JDK8的System.console()方法的源码中翻到了下面的一段代码: private static volatile Console cons = null; / ...

  5. java并发系列(四)-----源码角度彻底理解ReentrantLock(重入锁)

    1.前言 ReentrantLock可以有公平锁和非公平锁的不同实现,只要在构造它的时候传入不同的布尔值,继续跟进下源码我们就能发现,关键在于实例化内部变量sync的方式不同,如下所示: /** * ...

  6. Java并发包源码学习系列:基于CAS非阻塞并发队列ConcurrentLinkedQueue源码解析

    目录 非阻塞并发队列ConcurrentLinkedQueue概述 结构组成 基本不变式 head的不变式与可变式 tail的不变式与可变式 offer操作 源码解析 图解offer操作 JDK1.6 ...

  7. 不用修改nginx的高并发合并回源架构

    nginx的连接都是一对一的,想改成一对多,比较麻烦,所以曾经看完了Nginx代码想改成一对多,我还是没改成,后来改变了一下思路想到一个更简单的方案,而且不失并发性能,还容易控制,下面先给出下面的图: ...

  8. 《实战java高并发程序设计》源码整理及读书笔记

    日常啰嗦 不要被标题吓到,虽然书籍是<实战java高并发程序设计>,但是这篇文章不会讲高并发.线程安全.锁啊这些比较恼人的知识点,甚至都不会谈相关的技术,只是写一写本人的一点读书感受,顺便 ...

  9. 并发编程—— FutureTask 源码分析

    1. 前言 当我们在 Java 中使用异步编程的时候,大部分时候,我们都会使用 Future,并且使用线程池的 submit 方法提交一个 Callable 对象.然后调用 Future 的 get ...

随机推荐

  1. Wireshark查找与标记数据包

    查找数据包 按Ctrl-F. 查找数据包提供了4个选项: 显示过滤器(Display filter):该选项可以让你通过输入表达式进行筛选,并只找出那些满足该表达式的数据包.如:not ip, ip. ...

  2. python的蟒蛇绘制

    代码: #PythonDraw.py import turtle turtle.setup(650,350,200,200) turtle.penup() turtle.fd(-250) turtle ...

  3. I/O 引脚

    我们以网卡举例 引脚,芯片,pcb板之间的关系非常紧密 1.引脚,又叫管脚,英文叫Pin. 就是从集成电路(芯片)内部电路引出与外围电路的接线,所有的引脚就构成了这块芯片的接口.引线末端的一段,通过软 ...

  4. Java学习day42

    继续刷力扣题

  5. 论文解读(GCA)《Graph Contrastive Learning with Adaptive Augmentation》

    论文信息 论文标题:Graph Contrastive Learning with Adaptive Augmentation论文作者:Yanqiao Zhu.Yichen Xu3.Feng Yu4. ...

  6. PuddingSwap联合 ESBridge举办愚人节“币圈愚话”联合空投活动,完成任务即可获得惊喜奖励

    据官方消息,4月1日0:00- 4月2日23:59,PuddingSwap联合 ESBridge举办"币圈愚话"空投活动,完成任务即可获得惊喜奖励. 此次PuddingSwap联合 ...

  7. Java语言学习day28--8月03日

    ###10接口作为方法参数与返回值 * A:  接口作为方法参数 接口作为方法参数的情况是很常见的,经常会碰到.当遇到方法参数为接口类型时,那么该方法要传入一个接口实现类对象.如下代码演示. //接 ...

  8. 使用 sh -x 进行 shell 脚本调试

    转载请注明出处:   sh  -x 命令的执行,会将shell 命令的每一个执行步骤进行打印,可以查看到 整个命令或脚本的执行过程的 debug. sh -n 只读取shell脚本,检测语法错误,但不 ...

  9. js 修改页面样式的两种方式

    1.  element.style       行内样式操作 代码示例 : <!DOCTYPE html> <html lang="en"> <hea ...

  10. ps、top命令查找不到进程的解决方案

    netstat -anpt发现一个奇怪的连接,但是ps和top命令确查不到此进程,这很可能是因为因为ps和top命令被替换了导致这些进程被过滤掉了.因此我这里有个脚本专门查找出来隐藏的进程 #!/us ...