Java专家系列:CPU Cache与高性能编程

认识CPU Cache

CPU Cache概述

随着CPU的频率不断提高,而内存的访问速度却没有质的突破,为了弥补访问内存的速度慢,充分发挥CPU的计算资源,提升CPU总体吞吐量,在CPU与内存之间引入了一级Cache。随着热点数据体积愈来愈大,一级Cache L1已经不知足发展的要求,引入了二级Cache L2,三级Cache L3。(注:若无特别说明,本文的Cache指CPU Cache,高速缓存)CPU Cache在存储器层次结构中的示意以下图:html

计算机早已进入多核时代,软件也愈来愈多的支持多核运行。一个处理器对应一个物理插槽,多处理器间经过QPI总线相连。一个处理器包含多个核,一个处理器间的多核共享L3 Cache。一个核包含寄存器、L1 Cache、L2 Cache,下图是Intel Sandy Bridge CPU架构,一个典型的NUMA多处理器结构:java

做为程序员,须要理解计算机存储器层次结构,它对应用程序的性能有巨大的影响。若是须要的程序是在CPU寄存器中的,指令执行时1个周期内就能访问到他们。若是在CPU Cache中,须要1~30个周期;若是在主存中,须要50~200个周期;在磁盘上,大概须要几千万个周期。充分利用它的结构和机制,能够有效的提升程序的性能。git

以咱们常见的X86芯片为例,Cache的结构下图所示:整个Cache被分为S个组,每一个组是又由E行个最小的存储单元——Cache Line所组成,而一个Cache Line中有B(B=64)个字节用来存储数据,即每一个Cache Line能存储64个字节的数据,每一个Cache Line又额外包含一个有效位(valid bit)、t个标记位(tag bit),其中valid bit用来表示该缓存行是否有效;tag bit用来协助寻址,惟一标识存储在CacheLine中的块;而Cache Line里的64个字节实际上是对应内存地址中的数据拷贝。根据Cache的结构题,咱们能够推算出每一级Cache的大小为B×E×S。程序员

那么如何查看本身电脑CPU的Cache信息呢?github

在windows下查看方式有多种方式,其中最直观的是,经过安装CPU-Z软件,直接显示Cache信息,以下图:shell

此外,Windows下还有两种方法:编程

①Windows API调用GetLogicalProcessorInfo。 
②经过命令行系统内部工具CoreInfo。windows

若是是Linux系统, 可使用下面的命令查看Cache信息:后端

ls /sys/devices/system/cpu/cpu0/cache/index0


还有lscpu等命令也能够查看相关信息,若是是Mac系统,能够用sysctl machdep.cpu 命令查看cpu信息。数组

若是咱们用Java编程,还能够经过CacheSize API方式来获取Cache信息, CacheSize是一个谷歌的小项目,java语言经过它能够进行访问本机Cache的信息。示例代码以下:

public static void main(String[] args) throws CacheNotFoundException {
        CacheInfo info = CacheInfo.getInstance(); 
        CacheLevelInfo l1Datainf = info.getCacheInformation(CacheLevel.L1, CacheType.DATA_CACHE);
        System.out.println("第一级数据缓存信息:"+l1Datainf.toString());

        CacheLevelInfo l1Instrinf = info.getCacheInformation(CacheLevel.L1, CacheType.INSTRUCTION_CACHE);
        System.out.println("第一级指令缓存信息:"+l1Instrinf.toString());
    }

打印输出结果以下:

第一级数据缓存信息:CacheLevelInfo [cacheLevel=L1, cacheType=DATA_CACHE, cacheSets=64, cacheCoherencyLineSize=64, cachePhysicalLinePartitions=1, cacheWaysOfAssociativity=8, isFullyAssociative=false, isSelfInitializing=true, totalSizeInBytes=32768]

第一级指令缓存信息:CacheLevelInfo [cacheLevel=L1, cacheType=INSTRUCTION_CACHE, cacheSets=64, cacheCoherencyLineSize=64, cachePhysicalLinePartitions=1, cacheWaysOfAssociativity=8, isFullyAssociative=false, isSelfInitializing=true, totalSizeInBytes=32768]

还能够查询L二、L3级缓存的信息,这里不作示例。从打印的信息和CPU-Z显示的信息能够看出,本机的Cache信息是一致的,L1数据/指令缓存大小都为:C=B×E×S=64×8×64=32768字节=32KB。

Cache Line伪共享及解决方案

Cache Line伪共享分析

说伪共享前,先看看Cache Line 在java编程中使用的场景。若是CPU访问的内存数据不在Cache中(一级、二级、三级),这就产生了Cache Line miss问题,此时CPU不得不发出新的加载指令,从内存中获取数据。经过前面对Cache存储层次的理解,咱们知道一旦CPU要从内存中访问数据就会产生一个较大的时延,程序性能显著下降,所谓远水救不了近火。为此咱们不得不提升Cache命中率,也就是充分发挥局部性原理。

局部性包括时间局部性、空间局部性。时间局部性:对于同一数据可能被屡次使用,自第一次加载到Cache Line后,后面的访问就能够屡次从Cache Line中命中,从而提升读取速度(而不是从下层缓存读取)。空间局部性:一个Cache Line有64字节块,咱们能够充分利用一次加载64字节的空间,把程序后续会访问的数据,一次性所有加载进来,从而提升Cache Line命中率(而不是从新去寻址读取)。

看个例子:内存地址是连续的数组(利用空间局部性),能一次被L1缓存加载完成。

以下代码,长度为16的row和column数组,在Cache Line 64字节数据块上内存地址是连续的,能被一次加载到Cache Line中,因此在访问数组时,Cache Line命中率高,性能发挥到极致。

public int run(int[] row, int[] column) {
    int sum = 0;
    for(int i = 0; i < 16; i++ ) {
        sum += row[i] * column[i];
    }
    return sum;
}

而上面例子中变量i则体现了时间局部性,i做为计数器被频繁操做,一直存放在寄存器中,每次从寄存器访问,而不是从主存甚至磁盘访问。虽然连续紧凑的内存分配带来高性能,但并不表明它一直都能带来高性能。若是把它放在多线程中将会发生什么呢?如图:

数据X、Y、Z被加载到同一Cache Line中,线程A在Core1修改X,线程B在Core2上修改Y。根据MESI大法,假设是Core1是第一个发起操做的CPU核,Core1上的L1 Cache Line由S(共享)状态变成M(修改,脏数据)状态,而后告知其余的CPU核,图例则是Core2,引用同一地址的Cache Line已经无效了;当Core2发起写操做时,首先致使Core1将X写回主存,Cache Line状态由M变为I(无效),然后才是Core2从主存从新读取该地址内容,Cache Line状态由I变成E(独占),最后进行修改Y操做, Cache Line从E变成M。可见多个线程操做在同一Cache Line上的不一样数据,相互竞争同一Cache Line,致使线程彼此牵制影响,变成了串行程序,下降了并发性。此时咱们则须要将共享在多线程间的数据进行隔离,使他们不在同一个Cache Line上,从而提高多线程的性能。

Cache Line伪共享处理方案

处理伪共享的两种方式:

  1. 增大数组元素的间隔使得不一样线程存取的元素位于不一样的cache line上。典型的空间换时间。(Linux cache机制与之相关)
  2. 在每一个线程中建立全局数组各个元素的本地拷贝,而后结束后再写回全局数组。

在Java类中,最优化的设计是考虑清楚哪些变量是不变的,哪些是常常变化的,哪些变化是彻底相互独立的,哪些属性一块儿变化。举个例子:

public class Data{
    long modifyTime;
    boolean flag;
    long createTime;
    char key;
    int value;
}

假如业务场景中,上述的类知足如下几个特色:

  1. 当value变量改变时,modifyTime确定会改变
  2. createTime变量和key变量在建立后,就不会再变化。
  3. flag也常常会变化,不过与modifyTime和value变量毫无关联。

当上面的对象须要由多个线程同时的访问时,从Cache角度来讲,就会有一些有趣的问题。当咱们没有加任何措施时,Data对象全部的变量极有可能被加载在L1缓存的一行Cache Line中。在高并发访问下,会出现这种问题:

如上图所示,每次value变动时,根据MESI协议,对象其余CPU上相关的Cache Line所有被设置为失效。其余的处理器想要访问未变化的数据(key 和 createTime)时,必须从内存中从新拉取数据,增大了数据访问的开销。

Padding 方式

正确的方式应该将该对象属性分组,将一块儿变化的放在一组,与其余属性无关的属性放到一组,将不变的属性放到一组。这样当每次对象变化时,不会带动全部的属性从新加载缓存,提高了读取效率。在JDK1.8之前,咱们通常是在属性间增长长整型变量来分隔每一组属性。被操做的每一组属性占的字节数加上先后填充属性所占的字节数,不小于一个cache line的字节数就能够达到要求:

public class DataPadding{
    long a1,a2,a3,a4,a5,a6,a7,a8;//防止与前一个对象产生伪共享
    int value;
    long modifyTime;
    long b1,b2,b3,b4,b5,b6,b7,b8;//防止不相关变量伪共享;
    boolean flag;
    long c1,c2,c3,c4,c5,c6,c7,c8;//
    long createTime;
    char key;
    long d1,d2,d3,d4,d5,d6,d7,d8;//防止与下一个对象产生伪共享
}

经过填充变量,使不相关的变量分开

Contended注解方式

在JDK1.8中,新增了一种注解@sun.misc.Contended,来使各个变量在Cache line中分隔开。注意,jvm须要添加参数-XX:-RestrictContended才能开启此功能 
用时,能够在类前或属性前加上此注释:

// 类前加上表明整个类的每一个变量都会在单独的cache line中
@sun.misc.Contended
@SuppressWarnings("restriction")
public class ContendedData {
    int value;
    long modifyTime;
    boolean flag;
    long createTime;
    char key;
}
或者这种:
// 属性前加上时须要加上组标签
@SuppressWarnings("restriction")
public class ContendedGroupData {
    @sun.misc.Contended("group1")
    int value;
    @sun.misc.Contended("group1")
    long modifyTime;
    @sun.misc.Contended("group2")
    boolean flag;
    @sun.misc.Contended("group3")
    long createTime;
    @sun.misc.Contended("group3")
    char key;
}

采起上述措施图示:

JDK1.8 ConcurrentHashMap的处理

java.util.concurrent.ConcurrentHashMap在这个如雷贯耳的Map中,有一个很基本的操做问题,在并发条件下进行++操做。由于++这个操做并非原子的,并且在连续的Atomic中,很容易产生伪共享(false sharing)。因此在其内部有专门的数据结构来保存long型的数据:

(openjdk\jdk\src\share\classes\java\util\concurrent\ConcurrentHashMap.java line:2506):

    /* ---------------- 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; }
    }

咱们看到该类中,是经过@sun.misc.Contended达到防止false sharing的目的

JDK1.8 Thread 的处理

java.lang.Thread在java中,生成随机数是和线程有着关联。并且在不少状况下,多线程下产生随机数的操做是很常见的,JDK为了确保产生随机数的操做不会产生false sharing ,把产生随机数的三个相关值设为独占cache line。

(openjdk\jdk\src\share\classes\java\lang\Thread.java line:2023)

    // 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;

Java中对Cache line经典设计

Disruptor框架

认识Disruptor

LMAX是在英国注册并受到FCA监管的外汇黄金交易所。也是欧洲第一家也是惟一一家采用多边交易设施Multilateral Trading Facility(MTF)拥有交易所牌照和经纪商牌照的欧洲顶级金融公司。LMAX的零售金融交易平台,是创建在JVM平台上,核心是一个业务逻辑处理器,它可以在一个线程里每秒处理6百万订单。业务逻辑处理器的核心就是Disruptor(注,本文Disruptor基于当前最新3.3.6版本),这是一个Java实现的并发组件,可以在无锁的状况下实现网络的Queue并发操做,它确保任何数据只由一个线程拥有以进行写访问,从而消除写争用的设计, 这种设计被称做“破坏者”,也是这样命名这个框架的。

Disruptor是一个线程内通讯框架,用于线程里共享数据。与LinkedBlockingQueue相似,提供了一个高速的生产者消费者模型,普遍用于批量IO读写,在硬盘读写相关的程序中应用的十分普遍,Apache旗下的HBase、Hive、Storm等框架都有在使用Disruptor。LMAX 建立Disruptor做为可靠消息架构的一部分,并将它设计成一种在不一样组件中共享数据很是快的方法。Disruptor运行大体流程入下图:

图中左侧(Input Disruptor部分)能够看做多生产者单消费者模式。外部多个线程做为多生产者并发请求业务逻辑处理器(Business Logic Processor),这些请求的信息通过Receiver存放在粉红色的圆环中,业务处理器则做为消费者从圆环中取得数据进行处理。右侧(Output Disruptor部分)则可看做单生产者多消费者模式。业务逻辑处理器做为单生产者,发布数据到粉红色圆环中,Publisher做为多个消费者接受业务逻辑处理器的结果。这里两处地方的数据共享都是经过那个粉红色的圆环,它就是Disruptor的核心设计RingBuffer。

Disruptor特色

  1. 无锁机制。
  2. 没有CAS操做,避免了内存屏障指令的耗时。
  3. 避开了Cache line伪共享的问题,也是Disruptor部分主要关注的主题。

Disruptor对伪共享的处理

RingBuffer类

RingBuffer类(即上节中粉红色的圆环)的类关系图以下:

经过源码分析,RingBuffer的父类,RingBufferFields采用数组来实现存放线程间的共享数据。下图,第57行,entries数组。

前面分析过数组比链表、树更具备缓存友好性,此处不作细表。不使用LinkedBlockingQueue队列,是基于无锁机制的考虑。详细分析可参考,并发编程网的翻译。这里咱们主要分析RingBuffer的继承关系中的填充,解决缓存伪共享问题。以下图: 

依据JVM对象继承关系中父类属性与子类属性,内存地址连续排列布局,RingBufferPad的protected long p1,p2,p3,p4,p5,p6,p7;做为缓存前置填充,RingBuffer中的protected long p1,p2,p3,p4,p5,p6,p7;做为缓存后置填充。这样任意线程访问RingBuffer时,RingBuffer放在父类RingBufferFields的属性,都是独占一行Cache line不会产生伪共享问题。如图,RingBuffer的操做字段在RingBufferFields中,使用rbf标识:


按照一行缓存64字节计算,先后填充56字节(7个long),中间大于等于8字节的内容都能独占一行Cache line,此处rbf是大于8字节的。

Sequence类

Sequence类用来跟踪RingBuffer和事件处理器的增加步数,支持多个并发操做包括CAS指令和写指令。同时使用了Padding方式来实现,以下为其类结构图及Padding的类。

Sequence里在volatile long value先后放置了7个long padding,来解决伪共享的问题。示意如图,此处Value等于8字节:

也许读者应该会认为这里的图示比上面RingBuffer的图示更好理解,这里的操做属性只有一个value,两个图相互结合就更能理解了。

Sequencer的实现

在RingBuffer构造函数里面存在一个Sequencer接口,用来遍历数据,在生产者和消费者之间传递数据。Sequencer有两个实现类,单生产者模式的实现SingleProducerSequencer与多生产者模式的实现MultiProducerSequencer。它们的类结构如图:

单生产者是在Cache line中使用padding方式实现,源码以下:

多生产者则是使用 sun.misc.Unsafe来实现的。以下图:

总结与使用示例

可见padding方式在Disruptor中是处理伪共享常见的方式,JDK1.8的@Contended很好的解决了这个问题,不知道Disruptor后面的版本是否会考虑使用它。

Disruptor使用示例代码参考地址

参考资料:

7个示例科普CPU Cache:http://coolshell.cn/articles/10249.html 
Linux Cache 机制:http://www.cnblogs.com/liloke/archive/2011/11/20/2255737.html 
《深刻理解计算机系统》:第六章部分 
Disruptor官方文档:https://github.com/LMAX-Exchange/disruptor/tree/master/docs 
Disruptor并发编程网文档翻译:http://ifeve.com/disruptor/

做者简介:

上海-周卫理、北京-杨珍琪、北京-冯英圣、深圳-姜寄羽 倾力合做,另外感谢惠普系统架构师吴治辉策划支持。

周卫理:本科,从事Java开发7年,热爱研究术问题,喜欢运动。目前就任于上海一家互联网公司,担任Java后端小组组长,负责分布式系统框架搭建。正往Java高性能编程,大数据中间件方向靠拢。

冯英胜:长期从事Java软件开发工做,善于复杂业务开发,6年工做经验,对大数据平台和分布式架构等有浓厚兴趣。目前就任于北京当当网。

姜寄羽:四川大学软件工程学士。目前在深圳亚略特担任Java工程师一职。负责Java方面的开发和维护以及新技术预研,对软件工程、分布式系统和高性能编程有着深厚的理论基础。

杨珍琪:硕士,会计学士,前HP工程师,参与中国移动BOSS系统开发,现为创业公司CTO。

相关文章
相关标签/搜索