资讯专栏INFORMATION COLUMN

java性能优化-关于cache

testbird / 674人阅读

摘要:前面了解存储结构对性能优化是非常关键的,不管是数据库,消息中间件,负载均衡器等性能优化的道理都是相通的,比如说性能优化,那么我们也需要从内部的存储和体系结构出发,分析树,块缓存,算法,逻辑读,,分析统计数据等,在分析逻辑读的时候需要考虑访问

前面

了解存储结构对性能优化是非常关键的,不管是数据库,消息中间件,负载均衡器,api gateway等
性能优化的道理都是相通的,比如说Oracle性能优化,那么我们也需要从Oracle内部的存储和体系结构出发,分析B*树,块缓存,JOIN算法,逻辑读,Latch/Lock,分析统计数据等,在分析逻辑读的时候需要考虑访问的顺序及存储的顺序,这里都涉及到如何最大化使用各层的Cache

本文主要介绍下cache的层次和java中可以优化或关注的地方

存储分层

现在计算机体系里,根据读写的速度,可以分为以下层次,这里远程也可以根据读写速度再细分,比如Redis可以作为Mysql的上一级cache

不同的存储访问延时差别甚大,实现的细节也有很大的差别
比如下图的SRAM和DRAM

操作 延时
execute typical instruction 1/1,000,000,000 sec = 1 nanosec
fetch from L1 cache memory 0.5 nanosec
branch misprediction 5 nanosec
fetch from L2 cache memory 7 nanosec
Mutex lock/unlock 25 nanosec
fetch from main memory 100 nanosec
send 2K bytes over 1Gbps network 20,000 nanosec
read 1MB sequentially from memory 250,000 nanosec
fetch from new disk location (seek) 8,000,000 nanosec
read 1MB sequentially from disk 20,000,000 nanosec
send packet US to Europe and back 150 milliseconds = 150,000,000 nanosec

如何利用好每一个层次的cache,对系统的性能至关重要,比如操作系统的Page Cache, Buffer Cache , Oracle的block cache,比如我们常用的java on/off-heap cache,Jedis/Memcached等。
因为篇幅有限,本文主要挑L0-L4进行具体介绍,以及我们设计程序的时候需要考虑到哪些问题以追求极致的性能。

L0-L4会涉及到JVM的内存模型,大家可以先不关心这个,JMM是另外一个话题(涉及cpu 指令流水,store buffer,invalid message queue,memory barrier等),这里不做介绍,下次找时间完整的写一篇,顺便学学JCStressTest

L0 - CPU Register

寄存器是存储结构里的L0缓存,寄存器速度是最快的,毕竟是直接跟ALU(Arithmetric Logic Uint in CPU)打交道的,不快能行吗

下图X86-64会有16个寄存器(更多的寄存器,可以将一部分函数调用的参数直接取寄存器,节约了栈上分配及访问的时间),IA32只有8个

寄存器的优化主要在编译器或/JIT层面,比如X86-64有16个寄存器,可以将一部分函数调用的参数直接取寄存器,节约了栈上分配及访问的时间等。
寄存器java程序员不用怎么care

L1-L3 CPU Cache

首先看为啥要有L1-L3 cache,如图所示,cpu的发展速度要远高于DRAM,当出现数量级的差异的时候,就需要中间加一层cache,来缓冲这个数量级的差异带来的巨大影响,这个也适用于上面的存储层次

cpu cache是一个复杂的体系,这里先介绍基本的层次结构,cache的映射方式,一致性协议等略过
大家先看下cpu cache的整体结构

PS: QPI(quick path interconnect)实现芯片之间的快速互联,不占用内存总线带宽

Cache Line

Cache Line 是CPU Cache的最小缓存单位,一般大小是64个字节
完整的了解看 https://en.wikipedia.org/wiki...

如何高效的利用CPU Cache

好了,回到性能优化的主题,我们java程序员,如何做到高效的使用CPU cache呢,或则,我们需要关心哪些地方会对性能产生较大影响。

请求由同一个core处理

如果某一个请求,有多个core处理,那么请求相关的cache line需要在多个core之间"copy",其实这里也有一个zero copy的概念,就是在多个core之间的cache line zero copy(自己发明一个。。。)

为了达到这个目的,有下面几个需要注意的地方

将请求绑定到某一个线程/进程

利用OS的soft cpu affinity或手动绑定,将某一个线程/进程绑定到固定的core

网络io的tx/rx亲缘性以及收发的core和处理的core的亲缘性

java进程内部,比如EventLoop的亲缘性

这里核心的思路是解决各种亲缘性,如cpu 亲缘性, io 亲缘性, 应用的请求亲缘性,netty的EventLoop亲缘性, 目的还是让请求或某个切面的请求尽量在同一个core处理,以最大化的利用cache,而且这个切面的一些共享数据都可以不用加锁,最大化系统的并行程度

我们看看Google Maglev中是如何处理的
Maglev 是google的负载均衡器(类似于LVS,但是Maglev实现的更底层)
Maglev中根据连接的五元组(这里除了src ip,port dst ip,port外,还有protocal version)将packet hash到某一个packet thread处理(packet thread跟core绑定),然后再根据packet thread自己的缓存映射到service ip(假设之前已经映射过),这里的优势是

packet不会在多个core之间交互,zero cache line copy

packet可以无锁的使用或更新到service ip的映射

考虑算法的cache友好度

我们在设计数据结构和算法时,除了算法理论的时间和空间复杂度,还要考虑集合是否缓存友好,比如ArrayList和LinkedList这两种数据结构,很多人认为LinkedList适合插入节点的场景,因为ArrayList需要arraycopy,其实是不一定的

下面是我的JMH测试数据(mbp i7 2.5G 单线程,java 1.8.0_65)

@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 5, time = 500, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 2, time = 500, timeUnit = TimeUnit.MILLISECONDS)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Fork(1)
@State(Scope.Benchmark)
@Threads(1)
public class LinkedListTest {
    private int size = 10000;
    @Param({"1", "500", "1000", "5000"})
    private int offset;
    @Param({"true", "false"})
    private boolean arrayScanIndex;
    LinkedList linkedList = new LinkedList();
    ArrayList arrayList = new ArrayList(size + 1);

    public LinkedListTest() {
        for (int i = 0; i < size; i++) {
            linkedList.add(new Object());
        }
        for (int i = 0; i < size; i++) {
            arrayList.add(new Object());
        }
    }

    @Benchmark
    public void testLinkedList() {
        linkedList.add(offset, new Object());
        linkedList.remove(offset);
    }

    @Benchmark
    public void tetArrayList() {
        if (arrayScanIndex) {
            for (int i = 0; i < offset; i++) {
                arrayList.get(i);
            }
        }
        arrayList.add(offset, new Object());
        if (arrayScanIndex) {
            for (int i = 0; i < offset; i++) {
                arrayList.get(i);
            }
        }
        arrayList.remove(offset);
    }
}

上面开不开arrayScanIndex,对ArrayList性能基本没有影响,因为一个cache line可以存多个array的节点对象,大致估下64/4=16,比如需要遍历5000,那么5000/16=312个cache line的扫描,而且循环调用可以反复使用这些cache line,另外,ArrayList的elementData数组元素一定是连续分配的,所以arraycopy的时候可以最大化利用cache line

而LinkedList但是因为他的Node节点占用40个字节,item这里占用16个字节,那么遍历5000个节点,需要5000*56/64=4375个cache line(粗略估计),根据测试结果来看,前面的cache line已被换出,无法循环使用

只有在index非常小的时候,LinkedList才有优势,另外,ArrayList比LinkedList最坏情况好得多

这个例子不是说让大家使用ArrayList,因为LinkedList可以用辅助结构来加快index速度,而是说明一个问题,算法之外,考虑cache友好

降低指针跳转次数

在保持合理的抽象层度的同时,需要尽可能降低under lying数据的寻址次数,从而减少cache的淘汰,提高cache hit
我们有时候写java对象,一层套一层,5,6层不算啥,a->b->c->d->data
我们用pointer chasing 来表示这种现象

下面我们来看两个场景的测试结果
先设计几个测试的类,分别是Level1,Level2,Level3,Level4,每个类在heap中占24个字节(64位机器)

//以Level3举例
public class Level3 {
    public Level2 level2 = new Level2();

    public int get(){
        return level2.level1.x;
    }
}
@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 2, time = 500, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 2, time = 500, timeUnit = TimeUnit.MILLISECONDS)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Fork(1)
@State(Scope.Benchmark)
@Threads(1)
public class PointerChasingTest {
    private int size = 10000;
    private int[] list0 = new int[size];
    private Level1[] list1 = new Level1[size];
    private Level2[] list2 = new Level2[size];
    private Level3[] list3 = new Level3[size];
    private Level4[] list4 = new Level4[size];

    public PointerChasingTest() {
        for (int i = 0; i < size; i++) {
            list0[i] = i;
        }
        for (int i = 0; i < size; i++) {
            list1[i] = new Level1();
        }

        for (int i = 0; i < size; i++) {
            list2[i] = new Level2();
        }

        for (int i = 0; i < size; i++) {
            list3[i] = new Level3();
        }

        for (int i = 0; i < size; i++) {
            list4[i] = new Level4();
        }
    }

    @Benchmark
    public void testLevel0() {
        for (int i = 0; i < size; i++) {
            if (list0[i] == i) {

            }
            ;
        }
    }

    @Benchmark
    public void testLevel1() {
        for (int i = 0; i < size; i++) {
            list1[i].get();
        }
    }

    @Benchmark
    public void testLevel2() {
        for (int i = 0; i < size; i++) {
            list2[i].get();
        }
    }

    @Benchmark
    public void testLevel3() {
        for (int i = 0; i < size; i++) {
            list3[i].get();
        }
    }

    @Benchmark
    public void testLevel4() {
        for (int i = 0; i < size; i++) {
            list4[i].get();
        }
    }

}


从测试结果可以看出,pointer chasing越深,性能越差
另外,原始类型比Object性能好几个数量级,一个是原始类型没有pointer chasing,另一个是一个cache line可以存储的int要远远多余Object,Object在JVM中是臃肿的

大致画了下pointer chasing的内存分布图

有没有即有ArrayList这样的面向Object的集合抽象,又有原始类型的性能?
有,java project Valhalla

This aims to “reboot the layout of data in memory” in order to reduce the amount of memory used fetching objects from memory compared to, for example, arithmetic calculations. Not all classes need mutability or polymorphism. To do this, the project explores value types, generic specialisation and enhanced volatiles, and more.

Value types would provide JVM infrastructure to work with immutable, reference-free objects. This will be essential when high performance is required, and pairs of numbers need to be returned. Using primitives avoids allocation, but an object to wrap around the pair gives the benefit of abstraction. This project looks to open the door to user-defined abstract data types that perform like primitives

精简对象

我们知道,java对象在heap中是很臃肿的(所有才会用公司在保持api不变的同时,直接读写自己的raw data...),过于臃肿的对象,势必需要更多的cache line,产生更多的cache 淘汰
简单对比了下面两个对象的效率,后者快2倍多,FatModel除了占用更多的内存,需要扫描更多的cache line

public class FatModel {
    long a, b, c, d, e, f;

    public long get() {
        return a & b & c & d & e & f;
    }
}

public class ThinModel {
    byte a, b, c, d, e, f;

    public byte get() {
        return (byte) (a & b & c & d & e & f);
    }
}

//todo 测试二进制raw对象的query 性能
这里需要注意false sharing的场景,见下面章节

降低Cache Contention

在设计无锁高并发的数据结构结构时,都会用到CAS或volatile,为了支持更高的并行度,需要将CAS的变量细化成数组,分配给不同的core,每一个CAS变量负责一个区域或一个切面,那么在不同切面的请求,可以独立的进行CAS并发控制。
然额,因为JVM对数组元素对象倾向于连续分配,会导致多个对象在同一个cache line, 导致不同切面的请求,实际上是对同一个cache line竞争,这种情况就是False Sharing
不管是False Sharing还是别的原因,多个core对同一个cache line的争用(如lock xcmchg指令)会导致
对性能会产生较大影响
在我本机JMH 2*core线程测试AtomicLong和LongAdder,后者性能是前者10x,当然内存占用也更多)

下图解释了False Sharing为什么会导致cache contention
解决Flase Sharing: 在对象中加cache line padding,使操作的对象在不同的cache line,从而减少cache contention

很多开源的高性能无锁结构都有这方面的处理,不如Disruptor,或则JDK自带的LongAdder

我们测试一下

@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 5, time = 500, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 5, time = 500, timeUnit = TimeUnit.MILLISECONDS)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Fork(1)
@State(Scope.Benchmark)
@Threads(16)
public class FalseSharingTest {
    private int threads = 16;
    private FalseSharing[] counters1 = new FalseSharing[threads];
    private FalseSharingPadding[] counters2 = new FalseSharingPadding[threads];
    public FalseSharingTest(){
        for(int i = 0 ; i < threads ; i++){
            counters1[i] = new FalseSharing();
        }
        for(int i = 0 ; i < threads ; i++){
            counters2[i] = new FalseSharingPadding();
        }
    }

    @Benchmark
    public void testFalseSharing(ThreadParams params){
        counters1[params.getThreadIndex()%threads].value=2;
    }

    @Benchmark
    public void testFalseSharingPadding(ThreadParams params){
        counters2[params.getThreadIndex()%threads].value=2;
    }
}

降低Context Switch

老生常谈了,context switch会进行model switch(user->kernel),再进行线程切换
Context Switch在OS里实现的比较heavy,本身切换的效率也比coroutine切换低很多
另外,频繁context switch会导致cache hit下降(多个线程频繁交互的使用cache)
如果线程需要充分利用cache,最好是non-blocking,降低csw,然后持有cpu尽量多的时间批量干活

L4 DRAM

内存的话题太大,挑几个介绍下

User & Kernal Space

首先,大家可能平常经常听到一些词,用户态啊,内核态啊,zero copy啊,但是又有点疑惑,底下到底是怎么搞的

我们先从虚拟地址,物理地址,进程的地址空间说起
todo:待补充细节

Cache & Buffer

简单来说,cache 和 buffer 定位如下

The page cache caches pages of files to optimize file I/O. The buffer cache caches disk blocks to optimize block I/O.

page cache
file cache,mmap,direct buffer…
buffer cache
metadata(permission…) , raw io , 其他非文件的运行时数据
2.4版本的内核之前,文件的内容也会在buffer存储,也就是需要存储2次,2.4版本之后,buffer不会再存储再Cache中的内容

//todo 补充更细粒度的内存视图

IO时的内存逻辑分层
Layer Unit Typical Unit Size
User Space System Calls read() , write()
Virtual File System Switch (VFS) Block 4096 Bytes
Page Cache Page Normal:4k Huge:
Filesystem (For example ext3) Blocks 4096 Bytes (Can be set at FS creation)
Generic Block Layer Page Frames / Block IO Operations (bio)
I/O Scheduler Layer bios per block device (Which this layer may combine)
Block Device Driver Segment 512 Bytes
Hard Disk Sector 512 Bytes
如何使用cache提高性能

我们主要关心的是Page Cache,Buffer Cache对我们来说不需要重点去关注
1.Zero Copy

Item Value
mmap 读写文件,不需要再从user区(比如java heap)复制一份到kernal区再进行write到page cache,user直接写page cache
direct buffer 针对网络IO,减少了一次user和kernal space之间的copy,其实这里也不能叫zero copy,就是减少了一次copy

//todo mmap性能对比

引用

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/66262.html

相关文章

  • 关于MySQL的知识点与面试常见问题都在这里

    摘要:但是这将严重影响程序的性能。垂直分区的优点在于可以使得行数据变小,在查询时减少读取的数,减少次数。此外,垂直分区可以简化表的结构,易于维护。垂直分区的缺点在于主键会出现冗余,需要管理冗余列,并会引起操作,可以通过在应用层进行来解决。 Java面试通关手册(Java学习指南,欢迎Star,会一直完善下去,欢迎建议和指导):https://github.com/Snailclimb/Jav...

    LeoHsiun 评论0 收藏0
  • 关于MySQL的知识点与面试常见问题都在这里

    摘要:串行最高的隔离级别,完全服从的隔离级别。但是这将严重影响程序的性能。此外,垂直分区可以简化表的结构,易于维护。 我自己总结的Java学习的一些知识点以及面试问题,目前已经开源,会一直完善下去,欢迎建议和指导欢迎Star: https://github.com/Snailclimb/Java_Guide 书籍推荐 《高性能MySQL : 第3版》 文字教程推荐 MySQL 教程(菜鸟教程...

    hss01248 评论0 收藏0
  • 关于MySQL的知识点与面试常见问题都在这里

    摘要:串行最高的隔离级别,完全服从的隔离级别。但是这将严重影响程序的性能。此外,垂直分区可以简化表的结构,易于维护。 我自己总结的Java学习的一些知识点以及面试问题,目前已经开源,会一直完善下去,欢迎建议和指导欢迎Star: https://github.com/Snailclimb/Java_Guide 书籍推荐 《高性能MySQL : 第3版》 文字教程推荐 MySQL 教程(菜鸟教程...

    newtrek 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<