CPU Cache与缓存行
编译环境:windows10+Idea+x86 CPU。
1、CPU Cache
CPU 访问内存时,首先查询 cache 是否已缓存该数据。如果有,则返回数据,无需访问内存;如果不存在,则需把数据从内存中载入 cache,最后返回给理器。在处理器看来,缓存是一个透明部件,旨在提高处理器访问内存的速率,所以从逻辑的角度而言,编程时无需关注它,但是从性能的角度而言,理解其原理和机制有助于写出性能更好的程序。Cache 之所以有效,是因为程序对内存的访问存在一种概率上的局部特征:
- Spatial Locality:对于刚被访问的数据,其相邻的数据在将来被访问的概率高。
- Temporal Locality:对于刚被访问的数据,其本身在将来被访问的概率高。
下图是计算机存储的基本结构。L1、L2、L3分别表示一级缓存、二级缓存、三级缓存。越靠近CPU的缓存,速度越快,容量也越小。L1缓存小但很快,并且紧靠着在使用它的CPU内核。分为指令缓存和数据缓存;L2大一些,也慢一些,并仍然只能被一个单独的CPU核使用;L3更大、更慢,并且被单个插槽上的所有CPU核共享;最后是主存,由全部插槽上的所有CPU核共享。
当CPU执行运算的时候,它先去L1查找所需的数据、再去L2、然后是L3,如果最后这些缓存中都没有,所需的数据就要去主内存拿。走得越远,运算耗费的时间就越长。所以要尽量确保数据在L1缓存中。
Martin和Mike的 QCon presentation 演讲中给出了一些缓存未命中的消耗数据,也就是从CPU访问不同层级数据的时间概念:
可见CPU读取主存中的数据会比从L1中读取慢了近60-80倍。
2、Cache Line
Cache是由很多个 Cache line 组成的。Cache line 是 cache 和 RAM 交换数据的最小单位,通常为 64 Byte。当 CPU 把内存的数据载入 cache 时,会把临近的共 64 Byte 的数据一同放入同一个Cache line,因为空间局部性:临近的数据在将来被访问的可能性大。
由于缓存行的特性,当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享(下面会介绍到)。有人将伪共享描述成无声的性能杀手,因为从代码中很难看清楚是否会出现伪共享问题。
需要注意,数据在缓存中不是以独立的项来存储的,它不是我们认为的一个独立的变量,也不是一个单独的指针,它是有效引用主存中的一块地址。一个Java的long类型是8字节,因此在一个缓存行中可以存8个long类型的变量。
以大小为 32 KB,cache line 的大小为 64 Byte 的L1级缓存为例,对于不同存放规则,其硬件设计也不同,下图简单表示一种设计:
缓存行的这种特性也决定了在访问同一缓存行中的数据时效率是比较高的。比如当你访问java中的一个long类型的数组,当数组中的一个值被加载到缓存中,它会额外加载另外7个,因此可以非常快速的遍历这个数组。实际上,你可以非常快速的遍历在连续的内存块中分配的任意数据结构。
3、False Sharing(伪共享)
处理器为了提高处理速度,不直接和内存进行通讯,而是先将系统内存的数据读到内部缓存(L1,L2,L3)后再进行操作,但操作完之后不知道何时会写到内存;如果对声明了volatile 变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在的缓存行的数据写回到系统内存。但就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读取到处理器缓存里。
为了说明伪共享问题,下面举一个例子进行说明:两个线程分别对两个变量(刚好在同一个缓存行)分别进行读写的情况分析。
在core1上线程需要更新变量X,同时core2上线程需要更新变量Y。这种情况下,两个变量就在同一个缓存行中。每个线程都要去竞争缓存行的所有权来更新对应的变量。如果core1获得了缓存行的所有权,那么缓存子系统将会使core2中对应的缓存失效。相反,如果core2获得了所有权然后执行更新操作,core1就要使自己对应的缓存行失效。这里需要注意:整个操作过程是以缓存行为单位进行处理的,这会来来回回的经过L3缓存,大大影响了性能,每次当前线程对缓存行进行写操作时,内核都要把另一个内核上的缓存块无效掉,并重新读取里面的数据。如果相互竞争的核心位于不同的插槽,就要额外横跨插槽连接,效率可能会更低。
3、缓存对齐
基于以上问题的分析,在一些情况下,比如会频繁进行操作的数据,可以根据缓存行的特性进行缓存行对齐(即将要操作的数据凑一个缓存行进行操作)下面使用一个示例进行说明:
public final class T { public static class X{
//8字节
private volatile long x = 0L;
}
private static X[] arr = new X[2]; static {
arr[0] = new X();
arr[1] = new X();
} public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(()->{
for(long i = 0;i < 1000_0000L;i++){
//volatile的缓存一致性协议MESI或者锁总线,会消耗时间
arr[0].x = i;
}
}); Thread thread2 = new Thread(()->{
for(long i = 0;i< 1000_0000L;i++){
arr[1].x = i;
}
});
long startTime = System.nanoTime();
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("总计消耗时间:"+(System.nanoTime()-startTime)/100_000);
}
}
运行结果如下:
总计消耗时间:4645
升级改造运行,实现缓存行对齐,重点代码如下:
private static class Padding{
//7*8字节
public volatile long p1,p2,p3,p4,p5,p6,p7;
}
public static class T extends Padding{
//8字节
private volatile long x = 0L;
}
通过上述代码做缓存对齐,每次都会有初始的7*8个占位,加上最后一个就是独立的一块缓存行,整理后代码如下:
public final class T {
private static class Padding{
//7*8字节
public volatile long p1,p2,p3,p4,p5,p6,p7;
}
public static class X extends Padding{
//8字节
private volatile long x = 0L;
}
private static X[] arr = new X[2]; static {
arr[0] = new X();
arr[1] = new X();
} public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(()->{
for(long i = 0;i < 1000_0000L;i++){
//volatile的缓存一致性协议MESI或者锁总线,会消耗时间
arr[0].x = i;
}
}); Thread thread2 = new Thread(()->{
for(long i = 0;i< 1000_0000L;i++){
arr[1].x = i;
}
});
long startTime = System.nanoTime();
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("总计消耗时间:"+(System.nanoTime()-startTime)/100_000);
}
}
运行结果如下:
总计消耗时间:868
从上面可以看到,使用缓存对齐,相同操作情况下对齐后的时间比没对齐的时间有飞跃的提速。
这种缓存行填充的方法在早期是比较流行的一种解决办法,比较有名的Disruptor框架就采用了这种解决办法提高性能,Disruptor是一个线程内通信框架,用于线程里共享数据。与LinkedBlockingQueue类似,提供了一个高速的生产者消费者模型,广泛用于批量IO读写,在硬盘读写相关的程序中应用十分广泛,Apache旗下的HBase、Hive、Storm等框架都有使用Disruptor。
4、Cache Line伪共享解决方案
处理伪共享的两种方式:
- 字节填充:增大元素的间隔,使得不同线程存取的元素位于不同的cache line上,典型的空间换时间。
- 在每个线程中创建对应元素的本地拷贝,结束后再写回全局数组。
Java6 中实现字节填充:
public class PaddingObject{
public volatile long value = 0L; // 实际数据
public long p1, p2, p3, p4, p5, p6; // 填充
}
PaddingObject 类中需要保存一个 long 类型的 value 值,如果多线程操作同一个 CacheLine 中的 PaddingObject 对象,便无法完全发挥出 CPU Cache 的优势。
实际数据 value + 用于填充的 p1~p6 总共只占据了 7 * 8 = 56 个字节,而 Cache Line 的大小应当是 64 字节,这是有意而为之,在 Java 中,对象头还占据了 8 个字节,所以一个 PaddingObject 对象可以恰好占据一个 Cache Line。
Java7 中实现字节填充:
在 Java7 之后,一个 JVM 的优化给字节填充造成了一些影响,上面的代码片段 public long p1, p2, p3, p4, p5, p6;
会被认为是无效代码被优化掉,有回归到了伪共享的窘境之中。
为了避免 JVM 的自动优化,需要使用继承的方式来填充。
abstract class AbstractPaddingObject{
protected long p1, p2, p3, p4, p5, p6;// 填充
} public class PaddingObject extends AbstractPaddingObject{
public volatile long value = 0L; // 实际数据
}
Java8 中实现字节填充:
//JDK 8中提供的注解
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.TYPE})
public @interface Contended { /**
* The (optional) contention group tag.
* This tag is only meaningful for field level annotations.
*
* @return contention group tag.
*/
String value() default "";
}
在 JDK 8 里提供了一个新注解@Contended,可以用来减少false sharing的情况。JVM在计算对象布局的时候就会自动把标注的字段拿出来并且插入合适的大小padding。
因为这个功能暂时还是实验性功能,暂时还没到默认普及给用户代码用的程度。要在用户代码(非bootstrap class loader或extension class loader所加载的类)中使用@Contended注解的话,需要使用 -XX:-RestrictContended 参数。
代码优化如下:
public final class T { @sun.misc.Contended
public static class X {
//8字节
private volatile long x = 0L;
}
private static X[] arr = new X[2]; static {
arr[0] = new X();
arr[1] = new X();
} public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(()->{
for(long i = 0;i < 1000_0000L;i++){
//volatile的缓存一致性协议MESI或者锁总线,会消耗时间
arr[0].x = i;
}
}); Thread thread2 = new Thread(()->{
for(long i = 0;i< 1000_0000L;i++){
arr[1].x = i;
}
});
long startTime = System.nanoTime();
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("总计消耗时间:"+(System.nanoTime()-startTime)/100_000);
}
}
运行结果如下:
总计消耗时间:870
比如在JDK 8 的ConcurrentHashMap 源码中,使用 @sun.misc.Contended
对静态内部类 CounterCell 进行了修饰。
/* ---------------- Counter support -------------- */ /**
* A padded cell for distributing counts. Adapted from LongAdder
* and Striped64. See their internal docs for explanation.
*/
@sun.misc.Contended
static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}
Thread 线程类的源码中,使用 @sun.misc.Contended 对成员变量进行修饰。
// The following three initially uninitialized fields are exclusively
// managed by class java.util.concurrent.ThreadLocalRandom. These
// fields are used to build the high-performance PRNGs in the
// concurrent code, and we can not risk accidental false sharing.
// Hence, the fields are isolated with @Contended. /** The current seed for a ThreadLocalRandom */
@sun.misc.Contended("tlr")
long threadLocalRandomSeed; /** Probe hash value; nonzero if threadLocalRandomSeed initialized */
@sun.misc.Contended("tlr")
int threadLocalRandomProbe; /** Secondary seed isolated from public ThreadLocalRandom sequence */
@sun.misc.Contended("tlr")
int threadLocalRandomSecondarySeed;
一款优秀的开源框架 Disruptor 中的一个数据结构 RingBuffer使用字节填充和继承的方式来避免伪共享。
abstract class RingBufferPad {
protected long p1, p2, p3, p4, p5, p6, p7;
} abstract class RingBufferFields<E> extends RingBufferPad{}
CPU Cache与缓存行的更多相关文章
- 程序与CPU,内核,寄存器,缓存,RAM,ROM、总线、Cache line缓存行的作用和他们之间的联系?
目录 缓存 什么是缓存 L1.L2.L3 为什么要设置那么多缓存.缓存在cup内还是cup外 MESI协议----主流的处理缓存和主存数据不一样问题 Cache line是什么已经 对编程中数组的影响 ...
- 伪共享(False Sharing)和缓存行(Cache Line)
转载:https://www.jianshu.com/p/a9b1d32403ea https://www.toutiao.com/a6644375612146319886/ 前言 在上篇介绍Long ...
- java并发编程(三)cpu cache & 缓存一致性
一 cpu cache 1. cache的意义 为什么需要CPU cache?因为CPU的频率太快了,快到主存跟不上,这样在处理器时钟周期内,CPU常常需要等待主存,浪费资源.所以cache的出 ...
- 从Java视角理解CPU缓存(CPU Cache)
从Java视角理解系统结构连载, 关注我的微博(链接)了解最新动态众所周知, CPU是计算机的大脑, 它负责执行程序的指令; 内存负责存数据, 包括程序自身数据. 同样大家都知道, 内存比CPU慢很多 ...
- 关于CPU Cache -- 程序员需要知道的那些事
本文将介绍一些作为程序猿或者IT从业者应该知道的CPU Cache相关的知识.本章从"为什么会有CPU Cache","CPU Cache的大致设计架构",&q ...
- 关于CPU Cache:程序猿需要知道的那些
天下没有免费的午餐,本文转载于:http://cenalulu.github.io/linux/all-about-cpu-cache/ 先来看一张本文所有概念的一个思维导图: 为什么要有CPU Ca ...
- 关于CPU Cache -- 程序猿需要知道的那些事
本文将介绍一些作为程序猿或者IT从业者应该知道的CPU Cache相关的知识 文章欢迎转载,但转载时请保留本段文字,并置于文章的顶部 作者:卢钧轶(cenalulu) 本文原文地址:http://ce ...
- 剖析Disruptor:为什么会这么快?(二)神奇的缓存行填充
原文链接:http://mechanitis.blogspot.com/2011/07/dissecting-disruptor-why-its-so-fast_22.html 需FQ 计算机入门 ...
- 读书笔记:7个示例科普CPU Cache
本文转自陈皓老师的个人博客酷壳:http://coolshell.cn/articles/10249.html 7个示例科普CPU Cache (感谢网友 @我的上铺叫路遥 翻译投稿) CPU cac ...
随机推荐
- 使用.NET 6开发TodoList应用(11)——使用FluentValidation和MediatR实现接口请求验证
系列导航及源代码 使用.NET 6开发TodoList应用文章索引 需求 在响应请求处理的过程中,我们经常需要对请求参数的合法性进行校验,如果参数不合法,将不继续进行业务逻辑的处理.我们当然可以将每个 ...
- C++函数参数的传递顺序
C++编译器默认使用的是 __cdecl 模式,参数是通过栈传递的,因此是从右到左的传参顺序. int f(int a, int b, int c) { return 0; } int main(){ ...
- 代码质量管理sonarqube部署使用
一.sonarqube的部署 1.下载sonaqube:https://www.sonarqube.org/downloads/ 根据需要下载特定版本: 2.如果通过sonar-scanner进行代码 ...
- [git]git重连
使用以下两个命令清理缓存进行ssh清除:$ssh-keygen -f "/home/leoxae/.ssh/known_hosts" -Rxxx.xxx.xxx.xxx(指定IP) ...
- CS5211与PS8625参数差异|CS5211完全兼容PS8625|普瑞PS8625替代
PS8625是一个DP显示端口 到LVDS转换器芯片,利用GPU和显示端口(DP) 或嵌入式显示端口(eDP) 输出和接受LVDS输入的显示面板.PS8625实现双通道DP输入,双链路LVDS输出.P ...
- PIC18 bootloader之CAN bootloader
了解更多关于bootloader 的C语言实现,请加我Q扣: 1273623966 (验证信息请填 bootloader),欢迎咨询或定制bootloader(在线升级程序). PIC18 ...
- SpringBoot集成Actuator端点配置
1.说明 Actuator端点可以监控应用程序并与之交互. Spring Boot包括许多内置的端点, 比如health端点提供基本的应用程序运行状况信息, 并允许添加自定义端点. 可以控制每个单独的 ...
- 简单的制作ssl证书,并在nginx和IIS中使用
2020年最后一篇博文收官,提前祝各位园友新年快乐 现在的后端开发,动不动就是需要https,或者说是需要ssl证书,既然没有正版的证书,那么我们只能自己制作ssl的证书了. 说明:证书的制作采用的是 ...
- CSS基础 装饰 元素本身隐藏和显示效果及案例
1.visibility:hidden; 2.display: none: 区别: 1.visibility:hidden 隐藏元素本身,且在网页中 占位置 2.display:none; 隐藏元素本 ...
- 第10组 Beta冲刺 总结
1.基本情况 组长博客链接:https://www.cnblogs.com/cpandbb/p/14050808.html 答辩总结: ·因为alpha阶段的产品做得偏离了方向,所以beta冲刺大家非 ...