Disruptor高性能之道—False Sharing(伪共享)

1,163 阅读8分钟

这是我参与8月更文挑战的第4天,活动详情查看:8月更文挑战

​系列文章:

Disruptor高性能之道—开篇&介绍

Disruptor高性能之道—False Sharing(伪共享)

Disruptor高性能之道—无锁实现(CAS)

Disruptor高性能之道—内存屏障(Volatile)

Disruptor高性能之道—AvailableBuffer的原理

目录

一、前言

二、False Sharing的简介

1、什么是False Sharing

2、如何避免False Sharing

三、False Sharing在Disruptor中的应用

1、Sequence的字节填充

2、SingleProducerSequencer的字节填充

3、RingBuffer的字节填充

四、Java8中解决False Sharing

五、惯例


一、前言

False Sharing又叫伪共享、错误的共享、不正确的共享等(这个翻译应该比较多,但是业界大家叫的最多的应该还是伪共享。为了后续不纠结这个称号所以文章后续都将用英文名称)。相信很多同学或多或少都听过,甚至有很多同学都应该了解过其具体含义了。所以本篇文件并不对False Sharing本身做过多详细的介绍,而会重点介绍False Sharing在Disruptor中的应用。

那么如果对False Sharing不是特别了解的同学,建议先去网上找找相关的介绍文章。对False Sharing进行讲解的文章非常非常多。

二、 False Sharing的简介

1、什么是False Sharing

False Sharing说简单点就是只两个变量A和B同时被缓存在同一个缓存行(CPU在对数据进行缓存的时候是按缓存行的最小粒度缓存的,缓存行的大小通常为32字节、64字节和128字节,目前主流机器中64字节的会比较多一些)中,并且有两个线程1和2需要分别对变量A和B操作(线程1修改变量A,线程2读取变量B),此时假设线程1和线程2分别在不同的CPU核上执行,那么CPU两个核都会把包含A和B的缓存行缓存到其对应的L1或者L2缓存中。当线程1(在CPU核1上)修改了变量A并刷会L3或者主存之后(整个缓存行都会刷回去),会导致整个包含变量A和变量B的缓存行失效(缓存失效),此时当线程2(在CPU核2上)去读取变量B的时候,发现缓存行失效,其会重新从L3中加载缓存行数据。这就带来了一次无用的缓存加载。因为变量B自始至终都没有改变,却因为另外一个变量A的修改导致了另外的内核在读取变量B的时候需要重新去读取一次L3缓存。

1、这里提到了CPU核和L1、L2以及L3缓存,如果不了解的同学自己百度下,本文就不做详解了。

2、缓存行的生失效涉及到MESI协议,并没有文中说的那么简单,如果有兴趣的自行学习下把。

2、如何避免False Sharing

其实False Sharing在我们的程序中肯定是大量存在的,只是我们平时不是很关心罢了。但是在如Disruptor这种高性能的框架中,必须要面对且解决这个问题以此来达到极致的性能。因为False Sharing带来的缓存失效在高并发框架极致性能的场景下,性能影响就会放大。而在我们平时响应在几十毫秒级别甚至秒级别都可以接受的情况下,False Sharing因缓存失效带来的缓存性能下降就显得微不足道了。

那么在高并发架构中是如何避免False Sharing的呢?其实方式也比较简单,就是用空间换时间(这也是一般业务代码中为什么没有人说要解决了False Sharing了,因为如果你每个变量都用空间换时间的方式来提升False Sharing带来的一点性能影响,那么你的程序内存会暴增几十上百倍)。以64字节的缓存行举例,如果我们要让变量Long a不被其他变量影响,我们只需要在其前后分别添加7个Long 类型的空变量即可。这样就能够保证a变量永远不会和其他变量在同一个缓存行中。那么a变量也就不会因为其变量的修改导致其缓存失效了。

Long p1, p2, p3, p4, p5, p6, p7;
Long a;
Long p8, p9, p10, p11, p12, p13, p14;

这种填充可以解决代码在缓存行为64字节或者32字节的机器下的False Sharing的问题,但是解决不了缓存行为128个字节的机器下的False Sharing的问题。

三、False Sharing在Disruptor中的应用

在Disruptor中就是通过在变量前后填充7个Long类型的冗余变量来解决Long变量的False Sharing的问题。因此它解决不了缓存行为128个字节的机器中的False Long的问题。至于为什么Disruptor不解决128个字节的机器的False Sharing的问题?我想是因为徒增那么多的无用变量会得不偿失吧,再加上现在主流的机器的缓存行应该都还是64个字节的吧。

另外很明显Disruptor也不是把所有的变量的False Sharing的问题都解决了,因为这会导致程序占用内存增加几十甚至几百倍。Disruptor只是对其被频繁操作的变量(高并发访问)进行了冗余自动填充处理。比如:在Disruptor中访问量最高的Sequence类中的Value变量(一个Long类变量)、生产者中的缓存变量以及RingBuffer中的BufferSize等重要变量。

1、Sequence的字节填充

Sequence是一个在Disruptor中并发访问频率最高的对象,每个生产者和消费者都持有一个Sequence对象,且生产者的Sequence会被所有的消费者不断并发访问(判断当前消费者的位置),消费者本身的Sequence则会被其下游依赖的消费者不断并发访问(判断上游消费到什么位置了)。这些访问都是为了读取Sequence对象中的value变量。因此Disruptor对Sequence中的value变量做了前后字节填充。

下面Sequence对象的内存布局:

2、 SingleProducerSequencer的字节填充

在单生产者中其有两个缓存变量会被高并发访问,即每次生产消息都会多次读取这两个值,因此Disruptor也在这两个变量前后最了字节填充。

因为这里的填充通过继承实现,直接截图代码不太直观,所以这里我们直接看内存布局:

通过这个例子可以看到,填充并不是只能对单个Long类型变量,多个变量也可以。即填充之后就能够保证多个变量在一个缓存行中,且这些变量永远不会和其他变量在同一个缓存行中。

3、RingBuffer的字节填充

RingBuffer的访问当然是极高的,生产者生成消息和消费者消费消息都需要访问RingBuffer,因此Disruptor在其高并发的变量也做了冗余数据填充。这填充也是通过继承实现的,直接看代码截图不太直观。所以我们直接看RingBuffer的内存布局。

四、Java8中解决False Sharing

Java8中新增了一个注解:@sun.misc.Contended。加上这个注解的类会自动补齐缓存行,需要注意的是此注解默认是无效的,需要在jvm启动时设置-XX:-RestrictContended才会生效。

其原理就是自动在对应的字段前后添加128个字节来填充。貌似比字节解决要浪费内存一些。具体可以参考官方文档:mail.openjdk.java.net/pipermail/h…

这个注解可以使用到类或者某个域上,使用到类上面就表示对类中所有的域看为一个整体,然后在其前后添加128个字节填充。如果放到域上,则在这个Filed前后填充128个字节。

五、惯例

如果你对本文有任何疑问或者高见,欢迎添加公众号共同交流探讨(添加公众号可以获得”Java高级架构“上10G的视频和图文资料哦)。