因收到Google相关通知,网站将会择期关闭。相关通知内容 40 案例分析(三):高性能队列Disruptor 我们在《20 | 并发容器:都有哪些“坑”需要我们填?》介绍过Java SDK提供了2个有界队列:ArrayBlockingQueue 和 LinkedBlockingQueue,它们都是基于ReentrantLock实现的,在高并发场景下,锁的效率并不高,那有没有更好的替代品呢?有,今天我们就介绍一种性能更高的有界队列:Disruptor。 Disruptor是一款高性能的有界内存队列,目前应用非常广泛,Log4j2、Spring Messaging、HBase、Storm都用到了Disruptor,那Disruptor的性能为什么这么高呢?Disruptor项目团队曾经写过一篇论文,详细解释了其原因,可以总结为如下: 内存分配更加合理,使用RingBuffer数据结构,数组元素在初始化时一次性全部创建,提升缓存命中率;对象循环利用,避免频繁GC。 能够避免伪共享,提升缓存利用率。 采用无锁算法,避免频繁加锁、解锁的性能消耗。 支持批量消费,消费者可以无锁方式消费多个消息。 其中,前三点涉及到的知识比较多,所以今天咱们重点讲解前三点,不过在详细介绍这些知识之前,我们先来聊聊Disruptor如何使用,好让你先对Disruptor有个感官的认识。 下面的代码出自官方示例,我略做了一些修改,相较而言,Disruptor的使用比Java SDK提供BlockingQueue要复杂一些,但是总体思路还是一致的,其大致情况如下: 在Disruptor中,生产者生产的对象(也就是消费者消费的对象)称为Event,使用Disruptor必须自定义Event,例如示例代码的自定义Event是LongEvent; 构建Disruptor对象除了要指定队列大小外,还需要传入一个EventFactory,示例代码中传入的是LongEvent::new; 消费Disruptor中的Event需要通过handleEventsWith()方法注册一个事件处理器,发布Event则需要通过publishEvent()方法。 //自定义Event class LongEvent { private long value; public void set(long value) { this.value = value; } } //指定RingBuffer大小, //必须是2的N次方 int bufferSize = 1024; //构建Disruptor Disruptor disruptor = new Disruptor<>( LongEvent::new, bufferSize, DaemonThreadFactory.INSTANCE); //注册事件处理器 disruptor.handleEventsWith( (event, sequence, endOfBatch) -> System.out.println("E: "+event)); //启动Disruptor disruptor.start(); //获取RingBuffer RingBuffer ringBuffer = disruptor.getRingBuffer(); //生产Event ByteBuffer bb = ByteBuffer.allocate(8); for (long l = 0; true; l++){ bb.putLong(0, l); //生产者生产消息 ringBuffer.publishEvent( (event, sequence, buffer) -> event.set(buffer.getLong(0)), bb); Thread.sleep(1000); } RingBuffer如何提升性能 Java SDK中ArrayBlockingQueue使用数组作为底层的数据存储,而Disruptor是使用RingBuffer作为数据存储。RingBuffer本质上也是数组,所以仅仅将数据存储从数组换成RingBuffer并不能提升性能,但是Disruptor在RingBuffer的基础上还做了很多优化,其中一项优化就是和内存分配有关的。 在介绍这项优化之前,你需要先了解一下程序的局部性原理。简单来讲,程序的局部性原理指的是在一段时间内程序的执行会限定在一个局部范围内。这里的“局部性”可以从两个方面来理解,一个是时间局部性,另一个是空间局部性。时间局部性指的是程序中的某条指令一旦被执行,不久之后这条指令很可能再次被执行;如果某条数据被访问,不久之后这条数据很可能再次被访问。而空间局部性是指某块内存一旦被访问,不久之后这块内存附近的内存也很可能被访问。 CPU的缓存就利用了程序的局部性原理:CPU从内存中加载数据X时,会将数据X缓存在高速缓存Cache中,实际上CPU缓存X的同时,还缓存了X周围的数据,因为根据程序具备局部性原理,X周围的数据也很有可能被访问。从另外一个角度来看,如果程序能够很好地体现出局部性原理,也就能更好地利用CPU的缓存,从而提升程序的性能。Disruptor在设计RingBuffer的时候就充分考虑了这个问题,下面我们就对比着ArrayBlockingQueue来分析一下。 首先是ArrayBlockingQueue。生产者线程向ArrayBlockingQueue增加一个元素,每次增加元素E之前,都需要创建一个对象E,如下图所示,ArrayBlockingQueue内部有6个元素,这6个元素都是由生产者线程创建的,由于创建这些元素的时间基本上是离散的,所以这些元素的内存地址大概率也不是连续的。 ArrayBlockingQueue内部结构图 下面我们再看看Disruptor是如何处理的。Disruptor内部的RingBuffer也是用数组实现的,但是这个数组中的所有元素在初始化时是一次性全部创建的,所以这些元素的内存地址大概率是连续的,相关的代码如下所示。 for (int i=0; icachedGatingSequence || cachedGatingSequence>current){ //重新计算所有消费者里面的最小值位置 long gatingSequence = Util.getMinimumSequence( gatingSequences, current); //仍然没有足够的空余位置,出让CPU使用权,重新执行下一循环 if (wrapPoint > gatingSequence){ LockSupport.parkNanos(1); continue; } //从新设置上一次的最小消费位置 gatingSequenceCache.set(gatingSequence); } else if (cursor.compareAndSet(current, next)){ //获取写入位置成功,跳出循环 break; } } while (true); 总结 Disruptor在优化并发性能方面可谓是做到了极致,优化的思路大体是两个方面,一个是利用无锁算法避免锁的争用,另外一个则是将硬件(CPU)的性能发挥到极致。尤其是后者,在Java领域基本上属于经典之作了。 发挥硬件的能力一般是C这种面向硬件的语言常干的事儿,C语言领域经常通过调整内存布局优化内存占用,而Java领域则用的很少,原因在于Java可以智能地优化内存布局,内存布局对Java程序员的透明的。这种智能的优化大部分场景是很友好的,但是如果你想通过填充方式避免伪共享就必须绕过这种优化,关于这方面Disruptor提供了经典的实现,你可以参考。 由于伪共享问题如此重要,所以Java也开始重视它了,比如Java 8中,提供了避免伪共享的注解:@sun.misc.Contended,通过这个注解就能轻松避免伪共享(需要设置JVM参数-XX:-RestrictContended)。不过避免伪共享是以牺牲内存为代价的,所以具体使用的时候还是需要仔细斟酌。 欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。