CPU缓存CPU是计算机的大脑,它负责执行程序的指令;内存负责存数据,包括程序自身数据内存比CPU慢很多,现在获取内存中的一条数据大概需要200多个CPU周期(CPU cycles),而CPU寄存器一般情况下1个CPU周期就够了,现在小编就来说说关于cpu 缓存一致性?下面内容希望能帮助到你,我们来一起看看吧!

cpu 缓存一致性(CPU缓存和伪共享)

cpu 缓存一致性

CPU缓存

CPU是计算机的大脑,它负责执行程序的指令;内存负责存数据,包括程序自身数据。内存比CPU慢很多,现在获取内存中的一条数据大概需要200多个CPU周期(CPU cycles),而CPU寄存器一般情况下1个CPU周期就够了。

网页浏览器为了加快速度,会在本机缓存以前浏览过的数据;传统数据库或NoSQL数据库为了加速查询,常在内存设置一个缓存,减少对磁盘(慢)的IO。同样内存与CPU的速度相差太远,于是CPU设计者们就给CPU加上了缓存(CPU Cache)。如果需要对同一批数据操作很多次,那么把数据放至离CPU更近的缓存,会给程序带来很大的速度提升。例如,做一个循环计数,把计数变量放到缓存里,就不用每次循环都往内存存取数据了。下面是CPU Cache的简单示意图:

随着多核的发展,CPU Cache分成了三个级别:L1、 L2、L3。级别越小越接近CPU,所以速度也更快,同时也代表着容量越小。L1是最接近CPU的,它容量最小,例如32K,速度最快,每个核上都有一个L1 Cache(准确地说每个核上有两个L1 Cache,一个存数据 L1d Cache,一个存指令 L1i Cache)。L2 Cache 更大一些,例如256K,速度要慢一些,一般情况下每个核上都有一个独立的L2 Cache;L3 Cache是三级缓存中最大的一级,例如12MB,同时也是最慢的一级,在同一个CPU插槽之间的核共享一个L3 Cache。就像数据库cache一样,获取数据时首先会在最快的cache中找数据,如果没有命中(Cache miss)则往下一级找,直到三层Cache都找不到,那只有向内存要数据了。一次次地未命中,代表取数据消耗的时间越长。

缓存行(Cache Line)

Cache的最小组成单位是cache line。每一个缓存都是由很多个cache line组成的。每个cache line的大小一般的32字节或者64字节。cache中的数据操作也是以cache line为单位的,也就是一个cache line上的数据会被同时操作。一个Java long型占8个字节,所以从一条缓存行上可以获取到8个long型变量,如果访问一个long型数组,当有一个long被加载到cache中,将会无消耗地加载了另外7个,所以可以非常快地遍历数组。

MESI协议

多核CPU都有自己的专门缓存(一般伪L1,L2),以及用一个CPU插槽之间的核共享缓存(一般为L3),不同核心的CPU缓存中难免会加载同样的数据,那么如何保证数据的一致性呢,就是 MESI 协议了。

在 MESI 协议中,每个 Cache line 有4个状态,可用 2 个 bit 表示,它们分别是:

M(Modified):这行数据有效,数据被修改了,和内存中的数据不一致,数据只存在于本 Cache 中

E(Exclusive):这行数据有效,数据和内存中的数据一致,数据只存在于本 Cache 中;

S(Shared):这行数据有效,数据和内存中的数据一致,数据存在于很多 Cache 中

I(Invalid):这行数据无效

那么,假设有一个变量i=3(应该是包括变量i的缓存块,块大小为缓存行大小);已经加载到多核(a,b,c)的缓存中,此时该缓存行的状态为S;此时其中的一个核a改变了变量i的值,那么在核a中的当前缓存行的状态将变为M,b,c核中的当前缓存行状态将变为I。如下图:

伪共享

然而缓存行也存在问题:假设CPU的第一个核需要操作a变量,第二个核需要操作b变量,表面看a和b是没有任何关系的,但是a和b在同一个cache line中,这样假设核心一修改了变量a的值,那么它将会刷新所有和a相关的缓存的数据,b变量也就会受到牵连,最后导致核心二再去缓存读取b变量的时候出现cache miss,需要重新到主存加载新数据,这就是所谓的false share(伪共享)

如何避免伪共享?

为了避免由于 false sharing 导致 Cache Line 从 L1,L2,L3 到主存之间重复载入,我们可以使用数据填充的方式来避免,即单个数据填充满一个CacheLine。

一个典型的例子就是lmax disruptor中的缓存行填充技术。假设cache line的大小是64字节,一个cache line能容纳8个long型变量,disruptor中的做法是在long变量的前面和后面分别填充7个long变量,这样就可以避免我们需要的变量和其他变量在同一个cache line,解决伪共享问题。这就是用空间换时间。

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

  1. public class Data{

  2. long modifyTime;

  3. boolean flag;

  4. long createTime;

  5. char key;

  6. int value;

  7. }

假如业务场景中,上述的类满足以下几个特点:

  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才能开启此功能

用时,可以在类前或属性前加上此注释:

  1. // 类前加上代表整个类的每个变量都会在单独的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来实现的。如下图: