java缓存(一)——高速缓存

前段时间参加技术晋升答辩评审,其中大部分人都林林总总的提到了一些对于缓存的使用,所以想系统性的梳理下java相关的缓存技术的整个技术体系和知识点。
缓存并不是互联网的大流量和数据量兴起后出现的,其实从计算器系统建立之初缓存就一直存在,其目的就是为了弥补处理器和存储器之前相差巨大的处理能力。这一篇主要将介绍计算机系统高速缓存和其和java相关的一些技术知识。

什么是高速缓存

对于一个一般的计算机系统来说,其CPU通过总线接口联接到主板的IO桥,通过IO桥建立的IO总线,再和主存储器(内存)以及其他存储器(磁盘,网络设备,图形设备,控制器等)进行数据传输。
而所有的CPU计算,都要通过CPU的寄存器进行数据读写。但是CPU的寄存器空间又是及其有限的(4~8个字节),所以为了不让CPU频繁的等待,所以在主存储器和CPU的计算单元之间,又会存在多级高速缓存(集成在CPU中),距离最近的叫L1高速缓存,再依次有L2,L3.。。。这样从CPU的寄存器到主内存之间,每一层缓存的容量越来越大,速度越来越慢,成本越来越底。


image.png

对于高速缓存来说,因为指令和数据的特性是不一样的,指令是只读的只会入栈出栈比较简单(缓存的写比较复杂,后面会介绍),而数据会读取也会更新,所以在靠近CPU的高速缓存会把指令和数据单独存储,指令的叫做i-cache而数据叫做d-cache。
对于Core i7处理器的高速缓存和特性:

高速缓存类型 访问时间(周期) 高速缓存大小 相联度 块大小 组数
L1 i-cache 4 32KB 8 64B 64
L1 d-cache 4 32KB 8 64B 64
L2统一的高速缓存 11 256KB 8 64B 512
L2统一的高速缓存 30-40 8M 16 64B 8192

关于相联度,块大小和组数后面的存储结构中会介绍。因为CPU最终还是集成电路,其中一次电路信号作为一个时钟周期,而CPU会在这个周期内通过流水线处理多个模式化的任务,所以所用的时钟周期越短,造成CPU等待的时间越少。

高速缓存的存储结构

因为CPU寄存器的容量很小,所以计算机在运行中需要高频的读写高速缓存,那么其读写效率就是非常重要的了。为了快速定位缓存,高速缓存也使用了非常精妙的方式来进行设计。
一个计算机系统其存储器地址有m位,那么将总共有个地址;高速缓存会被分割为独立的个高速缓存组(cache set);每个组包含E个高速缓存行(cache line);每个行是有一个字节的数据块(block)组成的,一个有效位(valid bit)代表这个行是否包含有效缓存,还有个标记位(tag bit)来唯一的标示存储在这个高速缓存行中的块:

image.png

而对于几个要被缓存的地址,其t、s和b直接取自其地址的不同的位:
image.png

其中需要注意的是块是缓存最小存储单位,也就是系统会把一段大小内的连续地址的字节都缓存在一个块里。例如一个行缓存大小是8B,需要存储一个地址为a[0]的2B大小的数据,那么同时会把相邻的a[1] 2B a[2] 2B a[3] 2B共8B的数据全部缓存到一个块里。
仔细理解上面的设计,会发现使用地址的中间位作为组索引,会使得相邻的地址依次放入相邻的行中;而通过不同组索引和标记t,又能唯一的确定出一个行,而地址的末尾几位正好是在一个行中的偏移量了。这样通过很简单的运算,就能够使用一个地址本身直接在高速缓存中定位出是否命中,以及命中的位置。
而因为每一级缓存的大小都小于等于下一级缓存的大小,那么在小于的情况下,就有可能造成下一级中不同的地址映射到上一级缓存中相同的行,造成覆盖,会造成冲突不命中的情况出现。
这里一个组中会有多个行的设计正是为了避免冲突不命中时造成的影响,这样在不同的地址命中同一个组时,也能放入空闲的行中,但是同时在所有行都被使用的情况下,也需要根据最不常使用(LFU)或者最近最少使用(LRU)原则进行覆盖动作,会增加复杂度;再加上当查找缓存时需要依次遍历组中的所有行标记,所以一个组中的行数量也不是越多越好。
image.png

这种一个组中包含多个行的设计又叫做组相联高速缓存,所以组中的行数量又叫做相联度,可以看到Core i7处理器中L1缓存的相联度是8,也就是一个组中有8个行,每个行中存储有64字节的数据,而一共有64个组,这样L1总的缓存大小就是也就是总共32K。

高速缓存的读

理解了高速缓存的存储结构后,我们来看下高速缓存的读取。
首先我们看下一次数据读取的过程:

  • 寄存器中未命中,请求L1缓存
  • L1缓存命中直接返回,未命中查询L2缓存
  • L2缓存命中直接返回,未命中查询L3缓存
  • L3缓存命中直接返回,未命中查询主内存
    ……
  • 主内存命中,返回L3缓存,并写入
  • 返回L2缓存并写入
  • 返回L1缓存并写入
  • 返回寄存器并写入,进行指令运算

可以看到,每次k级缓存的查询,当不命中时都是查询k+1级缓存,返回后写入k级缓存。这样就是逐级查询和逐级写入的过程。

我们再来了解关于缓存使用效率的局部性的概念:

  • 时间局部性:同一个数据对象可能被多次使用。一段一个块被放入缓存,那么如果后续能够不断的访问命中,那么它的时间局部性就更好。
  • 空间局部性:因为一个块包含有连续的一段地址内的字节,那么我们希望在放入一个块后,后面的访问也能不断的命中块中放入的其他字节数据,那么它的空间局部性就更好。

其中为了得到更好的空间局部性,根据缓存中的块总是写入连续地址的特性,所以我们在编写代码时如果能过在运行中连续不断的步长为1的访问内存地址,那么可能得到最佳的空间局部性;而当我们访问的步长以及读取的字节正好跨越了所有的行,造成每下一次访问都命中同一个行(也就是冲突不命中),频繁的使缓存刷入刷出,那么就得到最差的空间局部性。
虽然高速缓存的缓存策略都是由系统所管理,似乎程序员没有可以干涉的地方,但是如果能够合理使用高速缓存的特性,我们还是能够写出更高效的代码来。
一个实际的例子,我们需要给一个二维数组进行求和,首先我们使用行优先的方式进行计算:

    private int hitCache() {
        int[][] array = new int[2048][2048];
        int i, sum = 0;   h h
        int iBoundary = array[0].length;
        int jBoundary = array[1].length;

        for (i = 0; i < iBoundary; i += 64) {
            for (int j = 0; j < jBoundary; j++) {
                sum += array[i][j];
            }
        }

        return sum;
    }

我们再使用列优先的方式进行计算:

    private int missCache() {
        int[][] array = new int[2048][2048];
        int i, sum = 0;
        int iBoundary = array[0].length;
        int jBoundary = array[1].length;

        for (i = 0; i < iBoundary; i += 64) {
            for (int j = 0; j < jBoundary; j++) {
                sum += array[j][i];
            }
        }

        return sum;
    }

编写main函数运行:

    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        TestCache testCache = new TestCache();
        System.out.println(testCache.hitCache());
        System.out.println("hitCache: " + (System.currentTimeMillis() - start));
        start = System.currentTimeMillis();
        System.out.println(testCache.missCache());
        System.out.println("missCache: " + (System.currentTimeMillis() - start));
    }

基本上会有70%左右的性能差距:

0
hitCache: 9
0
missCache: 15

Process finished with exit code 0

从程序的角度讲这两段代码的时间复杂度完全一致,都是,但为什么性能差距会如此之大呢?
这是因为根据高速缓存行存储的策略,总是一次性将连续的一段地址字节载入。而对于二维数组来说,在内存里是根据行优先的方式展开存储的,也就是相邻行的数据地址是连续的。因此,根据行优先的访问方式会具有更好的空间局部性(循环每下一次要读取的数据可能已经被上次一的缓存加载写入到了同一个块中),从而取得良好的性能。
所以我们能够利用高速缓存的空间局部性,连续的访问相邻的地址(但正好跨越所有行的字节,会造成每次的冲突不命中),从而提升每次缓存的加载使得下一次缓存命中的机率,从而提升系统性能。

高速缓存的写

当CPU的计算单元完成计算后,可能需要修改某地址中的值,那么这是就涉及到高速缓存的写入问题。相对于读取,写入要复杂很多,java虚拟机中也有很多地方和此技术特性强相关。
高速缓存的写可以简单抽象为以下两种方式(实际情况可能复杂很多):

  • 直写(write-through)
    每一级缓存马上写入下一级缓存中,这样实现简单,但是每次运行都会造成CPU IO总线操作,如果下一级缓存性能很低,那么将造成长时间的等待。
  • 写回(write-back)
    因为直写可能造成的低性能,所以并不是每次都更新下一级缓存,而是只更新自身就返回。直到块将要退出时(例如不同地址命中相同组),才写入下一级。这样可能合并多次计算结果只写入一次,同时计算单元也不用每次长时间等待。但是这种方式需要额外的一个标识位来标示出是否缓存已经被更新了,也提升了复杂性(特别是多核CPU在多线程模式工作时)。

基于以上不同的特性,一般如果两级缓存性能相差不大,例如L2,L3之间,是可以使用直写方式的;而当两级缓存性能差距巨大,例如L3和主存,那么回使用写回的方式。
对于java虚拟机来说,因为高速缓存的写的代价很大,所以需要尽量减少写入的操作,那么在代码编译到字节码,或者动态编译的过程中,可能会合并多次处理,最终只写入一次。
例如代码:

int sum = 0;
int count() {
  int a = 0, b = 0, c = 0;
  sum = a + b;
  sum += c;
  return sum;
}

如果严格按照顺序编译执行,那么sum将需要刷新两次栈到主内存中,但是java虚拟机在这种情况下可能会合进行指令重排,只在最后一次执行中才进行主内存刷新指令。根据happens-before原则,这样在单线程模式下完全可以得到一致的结果。
我们再来到多核CPU的多线程执行模式下,因为多线程是基于同一个进程,那么每个线程都可以访问进程内的地址。回到高速缓存的实现模式,每个核的CPU都可能会有独立的L1、L2缓存,那么在多线程读写的情况下,就可能同一个地址的字节被读入不同核所对应的高速缓存,而当其中一个核更新了此缓存后,就会造成其他核缓存的数据一致性问题。
我们分两种情况来考虑:

  • 高速缓存和内存采用直写的方式:在这种情况下,当其中一个核心写入主内存后,CPU会根据自身的一致性协议(一般为MESI协议),让其他核心都嗅探到此次变化,从而标记自身的缓存为失效,那么下一次访问就能够从主内存中得到最新的结果,从而保证一致性。
  • 高速缓存和内存采用回写的方式:在这种情况下,高速缓存的一次更新可能并不会触发主内存的更新指令,那么其他核中的缓存也就无法直到此次更新的发生,从而造成一致性问题。

MESI协议简介


image.png

状态:
M(修改, Modified): 本地处理器已经修改缓存行, 即是脏行, 它的内容与内存中的内容不一样. 并且此cache只有本地一个拷贝(专有)。
E(专有, Exclusive): 缓存行内容和内存中的一样, 而且其它处理器都没有这行数据。
S(共享, Shared): 缓存行内容和内存中的一样, 有可能其它处理器也存在此缓存行的拷贝。
I(无效, Invalid): 缓存行失效, 不能使用。

从以上我们可以发现,因为CPU自身已经有一致性协议保证写入主内存的数据的多核一致性,所以我们只要保证每次修改高速缓存的值能够被刷新到主内存即可保证多线程模式下数据的一致性。
根据此需要,所有架构的CPU都提供了略微不同的内存屏障指令,其指令能够将缓存行强制刷新到主内存中,并预防其前后的指令重排。例如IA32的lock指令。
但是java虚拟机是需要能够跨平台运行的,所以需要屏蔽不同系统间的指令区别,所以才诞生了JMM,JMM根据JSF-133建立了抽象的统一的内存模型。主要将java内存划分为共享的主内存(可以简单理解为堆内存)以及各个线程之间隔离的栈内存(可以简单认为是寄存器,高速缓存以及缓存和指令集的处理集合)。
从JMM的角度看,不但存在高速缓存有写回造成的不一致问题,同时还有多个栈和主内存之间的不一致问题。所以JMM将不同系统的内存屏障指令抽象为以下几种类型:

屏障类型 指令示例 说明
LoadLoad Barriers Load1;LoadLoad;Load2 确保load1的数据装载限于load2及后续所有指令装载
StoreStore Barriers Store1;StoreStore;Store2 确保store1的数据刷新到主内存,并先于store2
LoadStore Barriers Load1;LoadStore;Store2 确保load1的数据装载先于store2及后续所有的存储指令刷新到内存
StoreLoad Barriers Store1;StoreLoad;Load2 确保stoare1的数据刷新到主内存,先于load2和其之后所有的内存访问

而在java虚拟机中,有多种情况都会涉及到关于对内存屏障的使用。

  • volatile
    很多人都直到volatile修饰的变量具有原子性,全局可见性和防重排。而这些特性正是使用了内存屏障来实现的。
  1. 每个volatile的写前插入StoreStore屏障
  2. 每个volatile的写后插入StoreLoad屏障
  3. 每个volatile的读后插入LoadLoad屏障
  4. 每个volatile的读后插入LoadStore屏障
    所以每个volatile变量的读写都会插入很多内存屏障指令,而且会造成高速缓存到主内存的刷新,从而造成性能损耗。
  • synchronized
    synchronized关键字用来处理多线程下资源竞争的同步问题,也就是在同一个进程内,同一时间能够有一个线程拥有被锁定的资源。那么我们设想为了数据的一致性,在进入锁定前,为了方式别的线程对数据的修改,所以需要将此线程栈中的缓存都标记为过期;而当释放锁前,为了防止线程写入的缓存并不能被其他线程访问到,所以需要将缓存中的内容都强制刷新到主内存中。
  • final
    java所定义的final修饰的变量是不可变的,所以需要每次访问到的时候获得的值都是一致的。
    所以为了避免在final变量初始化过程中,其他线程访问到了其未完成初始化的值,需要在每个final变量的写操作后插入StoreStore屏障,前面插入LoadLoad屏障,从而保证不会有指令重排许造成的其他线程读到中间状态的情况出现。

好了,以上就是对JMM中根据高速缓存特性建立的内存屏障的抽象以及使用的简单介绍。最后我们再来了解下关于高速缓存性能的一个有趣技术:

  • MEM:BLCKING:使用分块提高时间局部性
    此技术就是根据L1缓存的块大小(例如Core i7处理器的64B),将完整的信息正好设置为块大小,从而使得每一个块中都是独立完整的信息,可以独立的修改,访问和丢弃,从而提升性能。

而以高性能而著名的disraptor正是运用了此技术。

  • disraptor: https://github.com/disraptor/disraptor
    是一个高速消息队列,其主要思想就是将接收的消息缓存在一个ringBuffer中,而分别有写指针和读指针进行生产和消费,从而最低程度的减少锁冲突的发生。而ringBuffer中存储的数据正是使用了64B的数据大小,这样就是为了使得ringBuffer中的数据连续的分布于L1缓存的缓存行中,因为其中的写入操作是非常多的,这样每个数据的读取和修改只涉及到自身的行,没有竞争,从而提升性能。
    image.png

参考资料:
《深入理解计算机系统》
《java并发编程的艺术》
《深入理解java虚拟机》

你可能感兴趣的:(java缓存(一)——高速缓存)