示例代码
int[] arr = new int[64 * 1024 * 1024];
// 循环 1
for (int i = 0; i < arr.length; i++) arr[i] *= 3;
// 循环 2
for (int i = 0; i < arr.length; i += 16) arr[i] *= 3
循环1执行了50ms,循环2执行了46ms,两个循环差值在15%之内。这是因为CPU Cache的原因。
为什么需要高速缓存
一次内存的访问,大于需要120个CPU Cycle,意味着CPU和内存的访问速度有了120倍的差距。类比生活中350公里/h的高铁和3公里/h的老太太。
为了弥补两者之间的差异,引入了高速缓存。
- CPU Cache被加入到CPU中之后,内存中的指令、数据会被加载到L1-L3的Cache中,不再直接由CPU访问内存去拿。
- 95%的情况下,CPU只需要访问L1-L3 Cache,从里面读取指令和数据,无需访问内存,
- CPU Cache是指特定的由SRAM组成的物理芯片
示例代码中,运行程序的时间主要花在了将对应的数据从内存中读取出来,加载到CPU Cache里。
CPU从内存读取到CPU Cache的过程中,是一小块一小块来读取数据的,不是按照单个数组元素来读取数据的。
一小块一小块的数据在CPU Cache里,叫做Cache Line(缓存块)。
日常使用的Intel服务器中,Cache Line的大小通常是64字节。示例代码中循环2每隔16个整型计算一次,16个整型正好是64个字节。所以循环1和循环2,需要把同样数据量的Cache Line数据从内存读取到CPU Cache中,最终两个程序花费的时间就差别不大了。
Cache的数据结构和读取过程
无论数据是否已经存储在Cache中,CPU始终首先访问Cache。
只有当CPU在Cache中找不到数据的时候,才会去访问内存,并将读取到的数据写入Cache之中,根据时间局部性,很快会再被访问。
这样的访问机制中,和开发应用系统时,使用内存作为硬盘的缓存的逻辑是一致的。
各类基准测试和实际场景中,CPU Cache的命中率能达到95%以上。
直接映射Cache
CPU如何知道要访问的内存数据,存储在Cache 的具体位置呢?
Cache的数据结构
CPU访问的数据是一小块一小块来读取的,对于读取内存中的数据,首先拿到的是数据所在的内存块地址。
直接映射Cache的策略,确保任何一个内存块的地址,始终映射到一个固定的CPU Cache地址,这个映射关系,通常用mod运算(求余运算)来实现。
举例如下:
比如主内存被分成0-31号这样32个块,一共有8个缓存块,用户想要访问第21号内存块,如果21号内存块在缓存块中的话,一定在5号缓存块(21%8=5)中。
实际计算中,有一个小小的技巧,通常我们会把缓存块的数量设置成 2 的 N 次方。这样在计算取模的时候,可以直接取地址的低 N 位,也就是二进制里面的后几位。比如这里的 8 个缓存块,就是 2 的 3 次方。那么,在对 21 取模的时候,可以对 21 的 2 进制表示 10101 取地址的低三位,也就是 101,对应的 5,就是对应的缓存块地址。
Cache的访问逻辑
取Block地址的低位,得到对应的Cache Line地址,除了21号内存块外,13号、5号等很多内存块的数据,都对应着5号缓存块,如何判断5号缓存块里的数据对应的是内存块的哪一个呢?组标记。
-
组标记:对应的缓存块中,会存储一个组标记,组标记会记录当前缓存块对应的内存块,缓存块本身的地址表示访问地址的低N位,比如21的低3位101,缓存块本身的地址已经覆盖了对应的信息,对应的组标记,只需要记录21剩余的高2位的信息,也就是10就可以了。
-
有效位:用来标记对应的缓冲块中的数据是否有效,确保不是机器刚刚启动时候的空数据,有效位是0,无论其中的组标记和Cache Line里的数据内容是什么,CPU不会管这些数据直接去访问内存,重新加载数据。
-
数据:从主内存加载进来的实际存放的数据
-
CPU在读取数据时候,不是读取整个Block,而是读取一个他需要的整数,这样的数据叫做CPU的一个字,这个字在整个Block里面的位置叫做偏移量。
一个内存地址访问地址,最终包括高位代表的组标记、低位代表的索引,以及在对应的Data Block中定位对应字的位置偏移量。
内存地址对应到Cache里的数据结构,多了一个有效位和对应的数据,由“
索引+有效位+组标记+数据”组成,如果内存中的数据已经在CPU Cache里了,那一个内存地址的访问,就会经历4个步骤。
- 根据内存的低位,计算在Cache中的索引
- 判断有效位,确认Cache的数据有效
- 对比内存访问的高位和Cache里的组标记,确认Cache中的数据就是要访问的内存数据,从Cache Line读取到对应的数据块
- 根据内存低的孩子的Offset位,从Block Data更新到Cache Line中,同时更新对应的有效位和组标记对应的数据。
如果在 2、3 这两个步骤中,CPU 发现,Cache 中的数据并不是要访问的内存地址的数据,那 CPU 就会访问内存,并把对应的 Block Data 更新到 Cache Line 中,同时更新对应的有效位和组标记的数据。
除了直接映射 Cache 之外,我们常见的缓存放置策略还有全相连 Cache(Fully Associative Cache)、组相连 Cache(Set Associative Cache)。这几种策略的数据结构都是相似的,理解了最简单的直接映射 Cache,其他的策略你很容易就能理解了。