高并发设计-缓存加速之cpu cache

简介

本章节主要介绍系统中的缓存使用,对于大多数web服务为了降低响应时间提升性能,都会大量使用缓存进行加速,除了常见的应用层的业务缓存,在我们系统的周边还存在着各式各样的其他缓存,也许你没有感知或者留意,比如cpu cache、浏览器cache、DNS cache、nginx 的http cache、mysql 的 query cache、innodb bufferPool cache、注册中心路由表cache等等都与我们的系统息息相关,在本节就以CPU cache的一些基本原理为例进行一些基本介绍。

程序局部性原理

在讲解缓存之前,我们先了解一下程序的局部性原理,
所谓局部性原理可以理解成为在一段时间内被访问的资源,在不久的将来还会被访问到,这就是时间局部性原理;
对某个存储区域中的某个地址进行过访问,那么不久的将来还会对这个位置附近的资源有访问诉求,这是空间局部性原理。
举例子:在解决时间局部性问题上我们可以使用缓存,来应对频繁被访问的热点数据,在空间局部性问题上CPU cache给我们提供了帮助。有人说CPU cache对我们的系统非常重要,但是对于一名业务开发人员好像也不需要关注,毕竟我们不会直接去操作它。好那么我们继续往下看。

投’CPU‘所好,四两拨千斤

那么看一段代码:

public class Main {
    public static void main(String[] args) {
        int[][] input = new int[1000][1000];
        long calcTime2 = Main.method2(input);
        long calcTime1 = Main.method1(input);
        System.out.println("calcTime1: " + calcTime1);
        System.out.println("calcTime2: " + calcTime2);
    }
    private static long method1(int[][] input) {
        long startTime = System.currentTimeMillis();
        int temp  = 1;
        for(int i=0;i<input.length;i++) {
            for(int j=0;j<input[i].length;j++) {
                input[i][j] = temp;
            }
        }
        long endTime = System.currentTimeMillis();
        return endTime-startTime;
    }

    private static  long method2(int[][] input) {
        long startTime = System.currentTimeMillis();
        int temp  = 2;
        for(int i=0;i<input.length;i++) {
            for(int j=0;j<input[i].length;j++) {
                input[j][i] = temp;
            }
        }
        long endTime = System.currentTimeMillis();
        return endTime-startTime;
    }
}

method1是满足了程序的局部性原理,大概耗时是4~6ms,method2不满足耗时约6~8ms。
为什么那么明显的差异呢?
原理大概大家也清楚:对于数组我们会给它分配一段连续的存储空间,因为我们顺序访问每个每个元素,那么第一次访问某个元素,将元素周边的定长数据作为一个缓存行放入cpu cache,那么后续每次顺序访问一直命中cpu cache,缓存的利用率会很高并且硬件性能要优于主存,这就是性能好的原因。
反观method2,因为是随机访问,所以被加载进cpu cache的缓存不能被有效使用,第一次可能命中了cache,第二次访问的元素因为是不连续的,有可能缓存中没有,所以需要从内存抓取,将缓存行再次放入cpu cache,没有有效利用历史的cache,这也就是慢的原因了。

CPU伪共享,性能影响知多少

大家都知道,正常情况下cpu cacheline可以存放多个变量,比如现在是一个2核CPU,核1的线程A访问一个int[]数组,并把数组装入cache line,核2线程B访问该数组的时候可以直接使用CPUcache查询,听起来比较完美,但是如果面临数据修改的时候比如线程B对数据内容做了修改,则会导致缓存行被标记失效,这样线程A就不能享受缓存的高效了,所以这种缓存共享的效率意义不大且效率比较低,被叫做’伪共享‘。

解决伪共享:针对伪共享如何解决呢?继续往下看,知名的高性能无锁队列Disruptor使用的是填充方式解决的,CPU每次会加载64位的数据,将共享资源通过填充长度占用独立一行,也就是将原来一行数据标记分散成多行,就可以解决局部的变化引起缓存失效的问题,最终还是使用空间换取时间。

关于性能的实验网上有不少,可以使用原始方式、填充方式(java7及以前)、@Contended注解方式(jdk1.8后)定义一个数组,将一个数组进行任务分片,分给多个线程,每个线程负责特定的元素写入,部分示例如下:

//存在伪共享
public class SharingObject {
    public volatile long value = 0L;
}
//前后填充解决伪共享
public class SharingObject {
    protected long p1, p2, p3, p4, p5, p6, p7;
    public volatile long value = 0L;
    protected long p9, p10, p11, p12, p13, p14, p15;
}
//1.8以后注解方式解决伪共享,配合-XX:-RestrictContended,java原生支持
@Contended
public class SharingObject {
    public volatile long value = 0L;
}

接下来定义ShaingObject[]数组,多线程操作对应的元素,经过验证,明显做过缓存行填充的性能会明显优于存在伪共享的代码。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章