Disruptor原理剖析
都说Disruptor是高性能、低延迟的内存队列,每秒可以处理600W的订单,但是它为什么这么快呢?这就需要我们从他的底层设计原理开始剖析。我觉得,学习了他的实现原理,对自身了解Java并发内存结构是有很大的好处的,因为它把如何基于Java内存结构实现高性能的并发操作,解决锁的性能开销问题发挥到了极致。
一.无锁(Lock-Free)
要想提高内存队列的性能,首先需要解决的就是并发环境下锁的开销问题。上一篇中说明了JDK内置了哪些内存队列,有的是有锁的,有的是无锁的,其中无锁的都是无界(List)队列,而Disruptor就是要基于环形数组提供一种无锁的有界(Array)队列。
锁是为了解决多线程并发环境下的冲突问题,为了避免产生脏数据,就需要对临界对象加锁。但是锁是很慢的,在获得锁和释放锁的过程中都会产生性能开销,并且如果锁的使用不当,还会产生死锁。锁分为悲观锁和乐观锁。悲观锁就是一个线程获得锁后,其他线程都处于等待状态,随着等待线程的增多,系统的响应性就会越来越慢。乐观锁不是对临界对象加锁,而是线程在修改变量时,会比较与期望的值是否相同,如果相同则修改,如果不同则根据策略是一直重试或者直接抛出异常。
Disruptor的解决方案是基于CAS算法实现无锁,JDK内置的ConcurrentLinkedQueue也是基于CAS算法实现的无界队列。CAS是一个CPU级别的指令,工作方式类似于乐观锁——这里需要说下,乐观锁并不是一种算法或者实现,而是一种思想,CAS才是基于乐观锁这个思想形成的算法实现。CAS操作比锁消耗资源少得多,因为不牵涉操作系统,直接在CPU上操作。当然,CAS虽然比锁消耗资源少,但是相对于单线程无锁而言,还是耗性能的。针对这种情况,Disruptor针对对RingBuffer的写入分别支持单线程模式和多线程模式,在写入过程中会出现对共享对象sequence的操作,针对单线程模式,使用Long类型不加锁,对于多线程模式,使用AtomicLong类型的CAS方式。
二.缓冲行填充
缓冲行填充是为了解决伪共享带来的问题,所以我们先了解下什么是伪共享。
三.伪共享
伪共享的问题也是基于CPU级别的机制引起的。正常情况下现在的大多数机器的内存分级机制如下图所示:
一般内存会再分为如上图所示的3层,多核CPU,每个CPU核心都有自己的私有内存空间L1,L1上层会有一个比较大的L2内存空间,L2上层有一个同一个插槽的CPU共享的L3内存空间,再上层就是所有插槽的CPU共享的主内存空间。每个CPU核心存取离自己越近的内存空间速度越快,越往上速度越慢。所以为了提高性能,就需要尽量将数据存放在L1区域,而尽量避免到主内存中取数据。
缓存系统是以缓存行为单位存储的,每个缓存行一般64字节,为了提高数据访问性能,CPU在拉起一个变量的值到L1空间时,会将相邻一共达到64字节的变量一起拉倒L1空间中。这种情况正常是没有问题的,因为CPU访问相邻变量会变得很快。但是有一个问题,就是如果相邻的变量对该CPU而言是不相干的,也就是说CPU1只关心变量1,CPU2只关心变量2,但是CPU1和CPU2在分别拉取变量1和变量2的都将这两个变量拉倒了自己的L1空间。此时如果CPU1修改了变量1的值,CPU为了保障修改的值被其他CPU看到,基于内存屏障的机制,会将修改的变量立即刷新到主内存中。而此时CPU2在获取变量2的值时,却不得不到主内存中获取。CPU2修改变量2的值时,也会影响CPU1对变量1的访问。这就是伪共享。
四.缓存行填充
那么Disruptor是怎么解决伪共享的问题的呢?就是通过缓存行填充。既然每个CPU会将访问的变量相邻的64字节的变量拉倒自己的内存空间,那么可以在该变量上再新建几个空变量满足64个字节不就可以了么。即使出现伪共享,也不会影响其他CPU。所以在Disruptor的RingBuffer源码中可以看到有几个Long类型的变量P1,P2,P3,P4,P5,P6,P7,就是为了填充。
五.环形Buffer
环形Buffer是一个数组,在Disruptor提高性能方面也起着重要作用,具体方面如下。
六.数组
通过数组预分配内存,减少节点操作空间释放和申请的过程,从而减少GC次数。并且由于数据元素内存地址是连续的,基于缓存行的机制,数组中的数据会被预加载到CPU的L1空间中,就无需到主内存中加载下一个元素,从而提高了数据访问性能。
七.求余操作优化
我们在新建Disruptor的实例时,需要设置bufferSize,并且官方说明该值必须是2的N次方,否则会抛出异常。那么为什么会需要2的N次方呢?主要是为了求余的优化。求余操作本身是一个高耗费的操作,但是在Disruptor中,通过位操作来高效实现求余,这需要值是2的N次方才能保证结果的唯一性。
八.不删除数据
对数组中数据的删除也是比较消耗性能的,因为涉及到索引的重新排位,而环形Buffer中并不会删除已经被消费的数据,而是等到有新的数据覆盖它们。
posted on 2019-01-09 23:56 bijian1013 阅读(885) 评论(0) 编辑 收藏 举报