高性能队列Disruptor
Java SDK提供了2个有界队列:ArrayBlockingQueue
和LinkedBlockingQueue
,它们都是基于ReentrantLock
实现的,在高并发的场景下,锁的效率并不高。
今天就介绍一种性能更高的有界队列:Disruptor。
Disruptor是一款高性能的有界内存队列,目前应用广泛,Log4j2、Spring Messaging、HBase、Storm都用到了Disruptor。
Disruptor项目团队曾经写过一篇论文,详细解释了其性能这么高的原因,可以总结为如下:
- 内存分配更加合理,使用RingBuffer数据结构,数组元素在初始化时一次性全部创建,提升缓存命中率对象循环利用,避免频繁GC。
- 能够避免伪共享,提升缓存利用率。
- 采用无锁算法,避免频繁加锁、解锁的性能消耗。
- 支持批量消费,消费者可以无锁方式消费多个消息。
简单使用
Disruptor的maven依赖:1
2
3
4
5
6<!-- https://mvnrepository.com/artifact/com.lmax/disruptor -->
<dependency>
<groupId>com.lmax</groupId>
<artifactId>disruptor</artifactId>
<version>3.3.6</version>
</dependency>
相较而言,Disruptor的使用比Java SDK提供的BlockingQueue要复杂一些,但是总体思路还是一致的,其大致情况如下:
- 在Disruptor中,生产者生产的对象(也就是消费者消费的对象)称为Event,使用Disruptor必须自定义Event,例如示例代码的自定义Event是LongEvent;
- 构建Disruptor对象除了要指定队列大小外,还需要传入一个EventFactory,示例代码中传入的是LongEventFactory;
- 消费Disruptor中的Event需要通过handleEventsWith()方法注册一个事件处理器,发布Event则需要通过publishEvent()方法。
示例代码参见GitHub:Main01
更多代码:Disruptor
RingBuffer如何提升性能
Java SDK中ArrayBlockingQueue使用数组作为底层的数据存储,而Disruptor是使用RingBuffer作为数据存储。RingBuffer本质上也是数组,所以仅仅将数据从数组换成RingBuffer并不能提升性能,但是Disruptor在RingBuffer的基础上还做了很多优化,其中一项就是和内存分配有关。
程序的局部性原理
在介绍这项优化之前,需要先了解一下程序的局部性原理。简单来讲,程序的局部性原理指的是在一段时间内程序的执行会限定在一个局部范围内。这里的“局部性”可以从两个方面来理解,一个是时间局部性,另一个是空间局部性。
- 时间局部性:指的是程序中的某条指令一旦被执行,不久之后这条指令很可能再次被执行;如果某条数据被访问,不久之后这条数据很可能再次被访问。
- 空间局部性:指内存一旦被访问,不久之后这块内存附近的内存也很可能被访问。
CPU的缓存就利用了程序的局部性原理:CPU从内存中加载数据 X 时,会将数据 X 缓存在高速缓存Cache中,实际上CPU缓存 X 的同时,还缓存了 X 周围的数据,因为根据程序具备局部性原理,也就能更好地利用CPU缓存,从而提升程序的性能。
Disruptor对比ArrayBlockingQueue
Disruptor在设计RingBuffer的时候就充分考虑了程序的局部性原理,下面就对比着ArrayBlockingQueue来分析。
ArrayBlockingQueue
生产者线程向ArrayBlockingQueue增加一个元素,每次增加元素E之前,都需要创建一个对象E,如下图所示,ArrayBlockingQueue内部有6个元素,这6个元素都是由生产者线程创建的,由于创建这些元素的时间基本上是离散的,所以这些元素的内存地址大概率也不是连续的。(数组连续,数组里只有引用,e1,e2这些对象的地址不连续)。
RingBuffer
Disruptor内部的RingBuffer也是用数组实现的,但是这个数组中的所有元素在初始化时是一次性全部创建的,所以这些元素的内存地址大概率是连续的,相关代码如下:
1 | for (int i=0; i<bufferSize; i++){ |
Disruptor内部RingBuffer的结构可以简化成下图,那么问题来了,数组中所有元素内存地址连续能提升性能吗?能!因为消费者线程在消费的时候,是遵循空间局部性原理的,消费完第1个元素,很快就会消费第2个元素;当消费第1个元素E1的时候,CPU会把内存中E1后面的数据也加载进Cache中,然后当消费第2个元素的时候,由于E2已经在Cache中了,所以就不需要从内存中加载了,这样就能大大提升性能。
除此之外,在Disruptor中,生产者线程通过publishEvent()
发布Event的时候,并不是创建一个新的Event,而是通过event.set()
方法修改Event,也就是说RingBuffer创建的Event是可以循环利用的,这样还能避免频繁创建、删除Event导致的频繁GC问题。
如何避免“伪共享”
高效利用Cache,能够大大提升性能,所以要努力构建能够高效利用Cache的内部结构。而从另外一个角度看,努力避免不能高效利用Cache的内存结构也同样重要。
有一种叫做“伪共享(False sharing)”的内存布局就会使Cache失效。
伪共享和CPU内部的Cache有关,Cache内部是按照缓存行(Cache Line)管理的,缓存行的大小通常是64个字节;CPU从内存中加载数据 X ,会同时加载 X 后面(64-size(X))个字节的数据。下面的示例代码出自 Java SDK 的 ArrayBlockingQueue,其内部维护了 4 个成员变量,分别是队列数组 items、出队索引 takeIndex、入队索引 putIndex 以及队列中的元素总数 count。
1 | /** 队列数组 */ |
当 CPU 从内存中加载 takeIndex 的时候,会同时将 putIndex 以及 count 都加载进 Cache。下图是某个时刻 CPU 中 Cache 的状况,为了简化,缓存行中我们仅列出了 takeIndex 和 putIndex。
假设线程 A 运行在 CPU-1 上,执行入队操作,入队操作会修改 putIndex,而修改 putIndex 会导致其所在的所有核上的缓存行均失效;此时假设运行在 CPU-2 上的线程执行出队操作,出队操作需要读取 takeIndex,由于 takeIndex 所在的缓存行已经失效,所以 CPU-2 必须从内存中重新读取。入队操作本不会修改 takeIndex,但是由于 takeIndex 和 putIndex 共享的是一个缓存行,就导致出队操作不能很好地利用 Cache,这其实就是伪共享。简单来讲,伪共享指的是由于共享缓存行导致缓存无效的场景。
ArrayBlockingQueue 的入队和出队操作是用锁来保证互斥的,所以入队和出队不会同时发生。如果允许入队和出队同时发生,那就会导致线程 A 和线程 B 争用同一个缓存行,这样也会导致性能问题。所以为了更好地利用缓存,我们必须避免伪共享,那如何避免呢?
方案很简单,每个变量独占一个缓存行、不共享缓存行就可以了,具体技术是缓存行填充。比如想让 takeIndex 独占一个缓存行,可以在 takeIndex 的前后各填充 56 个字节,这样就一定能保证 takeIndex 独占一个缓存行。下面的示例代码出自 Disruptor,Sequence 对象中的 value 属性就能避免伪共享,因为这个属性前后都填充了 56 个字节。Disruptor 中很多对象,例如 RingBuffer、RingBuffer 内部的数组都用到了这种填充技术来避免伪共享。
1 | //前:填充56字节 |
Disruptor中的无锁算法
ArrayBlockingQueue是利用管程实现的,中规中矩,生产、消费操作都需要加锁,实现起来简单,但是性能并不十分理想。Disruptor采用的是无锁算法,很复杂,但是核心无非是生产和消费两个操作。Disruptor中最复杂的是入队操作,所以重点看入队操作如何实现。
对于入队操作,最关键的要求是不能覆盖没有消费的元素;对于出队操作,最关键的要求是不能读取没有写入的元素,所以Disruptor中也一定会维护类似出队索引和入队索引这样两个关键变量。Disruptor中的RingBuffer维护了入队索引,但是并没有维护出队索引,这是因为在Disruptor中多个消费者可以同时消费,每个消费者都会有一个出队索引,所以RingBuffer的出队索引是所有消费者里面最小的那一个。
下面是Disruptor生产者入队操作的核心代码,看上去很复杂,其实逻辑很简单:如果没有足够的空余位置,就出让CPU使用权,然后重新计算;反之则用CAS设置入队索引。
1 | //生产者获取n个写入位置 |
总结
Disruptor 在优化并发性能方面可谓是做到了极致,优化的思路大体是两个方面,一个是利用无锁算法避免锁的争用,另外一个则是将硬件(CPU)的性能发挥到极致。尤其是后者,在 Java 领域基本上属于经典之作了。
发挥硬件的能力一般是 C 这种面向硬件的语言常干的事儿,C 语言领域经常通过调整内存布局优化内存占用,而 Java 领域则用的很少,原因在于 Java 可以智能地优化内存布局,内存布局对 Java 程序员的透明的。这种智能的优化大部分场景是很友好的,但是如果你想通过填充方式避免伪共享就必须绕过这种优化,关于这方面 Disruptor 提供了经典的实现,你可以参考。
由于伪共享问题如此重要,所以 Java 也开始重视它了,比如 Java 8 中,提供了避免伪共享的注解:`@sun.misc.Contended`,通过这个注解就能轻松避免伪共享(需要设置 JVM 参数 -XX:-RestrictContended)。不过避免伪共享是以牺牲内存为代价的,所以具体使用的时候还是需要仔细斟酌。