ArrayBlockingQueue 和 LinkedBlockingQueue,它們都是基于 ReentrantLock 實現(xiàn)的, 在高并發(fā)場景下,鎖的效率并不高,那有沒有更好的替代品呢?
有,今天我們就來介紹一個性能更高的有界隊列:Disruptor 。
Disruptor 是一款高性能的有界隊列,目前應用非常廣泛,Log4j2、Spring Messaging、HBase、Storm 都用到了 Disruptor, 那 Disruptor 的性能為什么這么高呢?Disruptor 項目團隊曾經(jīng)寫過一篇論文,詳細解釋了其中原因,可以總結(jié)如下:
- 內(nèi)存分配更加合理,使用RingBuffer 數(shù)據(jù)結(jié)構(gòu),數(shù)組元素在初始化時一次性全部創(chuàng)建,提升緩存命中率,對象循環(huán)使用,避免頻繁GC
- 能夠避免偽共享,提升緩存利用率
- 采用無鎖算法,避免頻繁加鎖、解鎖的性能損耗
- 支持批量消費,消費者可以無鎖方式消費多個消息
RingBuffer 如何提升性能
Java SDK 中 ArrayBlockingQueue 使用數(shù)組作為底層的數(shù)據(jù)存儲, 而Disruptor 使用的是RingBuffer 作為數(shù)據(jù)存儲。RingBuffer 本質(zhì)上也是數(shù)組,所以僅僅將數(shù)據(jù)從數(shù)組換成RingBuffer 并不能提升性能。但是Disruptor 在 RingBuffer 的基礎上還做了很多優(yōu)化,其中一項優(yōu)化就是跟內(nèi)存分配有關的。
在介紹這項優(yōu)化之前,你需要了解下程序的局部性原理,簡單來講,程序的局部性原理指的是在一段時間內(nèi)程序的執(zhí)行會限定在一個局部范圍。這里的局部性可以從兩個方面來理解,一個是時間局部性,另一個是空間局部性。時間局部性指的是程序中某條指令一旦被執(zhí)行,不久之后這條指令很可能會被再次執(zhí)行,如果某條數(shù)據(jù)被訪問,不久之后這條數(shù)據(jù)很可能會被再次訪問。而空間局部性是指的某塊內(nèi)存一旦被訪問,不久之后這塊內(nèi)存附近的內(nèi)存很可能被再次訪問。
CPU 的緩存就利用了程序的局部性原理:CPU 從內(nèi)存中加載數(shù)據(jù) X 時,會將數(shù)據(jù) X 緩存在高速緩存 Cache 中,實際上 CPU 緩存 X 的同時,還緩存了 X 周圍的數(shù)據(jù),因為根據(jù)程序具備局部性原理,X 周圍的數(shù)據(jù)也很有可能被訪問。 從另一個角度講,如果程序能很好的體現(xiàn)出局部性原理,也就能更好的利用CPU 的緩存,從而提升程序性能。Disruptor 在設計 RingBuffer 的時候就充分考慮了這個問題,下面我們就對比著 ArrayBlockingQueue 來分析一下。
首先是 ArrayBlockingQueue。生產(chǎn)者線程向 ArrayBlockingQueue 增加一個元素,每次增加元素 E 之前,都需要創(chuàng)建一個對象 E,如下圖所示,ArrayBlockingQueue 內(nèi)部有 6 個元素,這 6 個元素都是由生產(chǎn)者線程創(chuàng)建的,由于創(chuàng)建這些元素的時間基本上是離散的,所以這些元素的內(nèi)存地址大概率也不是連續(xù)的。
Disruptor 內(nèi)部的 RingBuffer 也是用數(shù)組實現(xiàn)的,但是這個數(shù)組中的所有元素在初始化時是一次性全部創(chuàng)建的,所以這些元素的內(nèi)存地址大概率是連續(xù)的,相關的代碼如下所示。
for (int i=0; i<bufferSize; i++){
//entries[]就是RingBuffer內(nèi)部的數(shù)組
//eventFactory就是前面示例代碼中傳入的LongEvent::new
entries[BUFFER_PAD + i]
= eventFactory.newInstance();
}
Disruptor 內(nèi)部 RingBuffer 的結(jié)構(gòu)可以簡化成下圖,那問題來了,數(shù)組中所有元素內(nèi)存地址連續(xù)能提升性能嗎?能!為什么呢?因為消費者線程在消費的時候,是遵循空間局部性原理的,消費完第 1 個元素,很快就會消費第 2 個元素;當消費第 1 個元素 E1 的時候,CPU 會把內(nèi)存中 E1 后面的數(shù)據(jù)也加載進 Cache,如果 E1 和 E2 在內(nèi)存中的地址是連續(xù)的,那么 E2 也就會被加載進 Cache 中,然后當消費第 2 個元素的時候,由于 E2 已經(jīng)在 Cache 中了,所以就不需要從內(nèi)存中加載了,這樣就能大大提升性能。
除此之外,在 Disruptor 中,生產(chǎn)者線程通過 publishEvent() 發(fā)布 Event 的時候,并不是創(chuàng)建一個新的 Event,而是通過 event.set() 方法修改 Event, 也就是說 RingBuffer 創(chuàng)建的 Event 是可以循環(huán)利用的,這樣還能避免頻繁創(chuàng)建、刪除 Event 導致的頻繁 GC 問題。
如何避免“偽共享”
高效利用Cache ,能夠大大提升性能,所以要努力構(gòu)建能夠高效利用Cache 的內(nèi)存結(jié)構(gòu)。從另一個角度看,努力避免不能夠高效利用Cache 的內(nèi)存結(jié)構(gòu)也是相當重要的。
有一種叫做“偽共享(False sharing)” 的內(nèi)存結(jié)構(gòu)就會使Cache 失效,那什么是“偽共享”呢?
偽共享和 CPU 內(nèi)部的 Cache 有關,Cache 內(nèi)部是按照緩存行(Cache Line)管理的,緩存行的大小通常是 64 個字節(jié);CPU 從內(nèi)存中加載數(shù)據(jù) X,會同時加載 X 后面(64-size(X))個字節(jié)的數(shù)據(jù)。下面的示例代碼出自 Java SDK 的 ArrayBlockingQueue,其內(nèi)部維護了 4 個成員變量,分別是隊列數(shù)組 items、出隊索引 takeIndex、入隊索引 putIndex 以及隊列中的元素總數(shù) count。
/** 隊列數(shù)組 */
final Object[] items;
/** 出隊索引 */
int takeIndex;
/** 入隊索引 */
int putIndex;
/** 隊列中元素總數(shù) */
int count;
當 CPU 從內(nèi)存中加載 takeIndex 的時候,會同時將 putIndex 以及 count 都加載進 Cache。下圖是某個時刻 CPU 中 Cache 的狀況,為了簡化,緩存行中我們僅列出了 takeIndex 和 putIndex。
假設線程 A 運行在 CPU-1 上,執(zhí)行入隊操作,入隊操作會修改 putIndex,而修改 putIndex 會導致其所在的所有核上的緩存行均失效;此時假設運行在 CPU-2 上的線程執(zhí)行出隊操作,出隊操作需要讀取 takeIndex,由于 takeIndex 所在的緩存行已經(jīng)失效,所以 CPU-2 必須從內(nèi)存中重新讀取。入隊操作本不會修改 takeIndex,但是由于 takeIndex 和 putIndex 共享的是一個緩存行,就導致出隊操作不能很好地利用 Cache,這其實就是偽共享。簡單來講,偽共享指的是由于共享緩存行導致緩存無效的場景。
ArrayBlockingQueue 的入隊和出隊操作是用鎖來保證互斥的,所以入隊和出隊不會同時發(fā)生。如果允許入隊和出隊同時發(fā)生,那就會導致線程 A 和線程 B 爭用同一個緩存行,這樣也會導致性能問題。所以為了更好地利用緩存,我們必須避免偽共享,那如何避免呢?
方案很簡單,每個變量獨占一個緩存行、不共享緩存行就可以了,具體技術(shù)是緩存行填充。比如想讓 takeIndex 獨占一個緩存行,可以在 takeIndex 的前后各填充 56 個字節(jié),這樣就一定能保證 takeIndex 獨占一個緩存行。下面的示例代碼出自 Disruptor,Sequence 對象中的 value 屬性就能避免偽共享,因為這個屬性前后都填充了 56 個字節(jié)。Disruptor 中很多對象,例如 RingBuffer、RingBuffer 內(nèi)部的數(shù)組都用到了這種填充技術(shù)來避免偽共享。
Disruptor 中的無鎖算法
ArrayBlockingQueue 是利用管程實現(xiàn)的,中規(guī)中矩,生產(chǎn)、消費操作都需要加鎖,實現(xiàn)起來簡單,但是性能并不十分理想。Disruptor 采用的是無鎖算法,很復雜,但是核心無非是生產(chǎn)和消費兩個操作。Disruptor 中最復雜的是入隊操作,所以我們重點來看看入隊操作是如何實現(xiàn)的。
對于入隊操作,最關鍵的要求是不能覆蓋沒有消費的元素;對于出隊操作,最關鍵的要求是不能讀取沒有寫入的元素,所以 Disruptor 中也一定會維護類似出隊索引和入隊索引這樣兩個關鍵變量。Disruptor 中的 RingBuffer 維護了入隊索引,但是并沒有維護出隊索引,這是因為在 Disruptor 中多個消費者可以同時消費,每個消費者都會有一個出隊索引,所以 RingBuffer 的出隊索引是所有消費者里面最小的那一個。
總結(jié)
Disruptor 在優(yōu)化并發(fā)性能方面可謂是做到了極致,優(yōu)化的思路大體是兩個方面,一個是利用無鎖算法避免鎖的爭用,另外一個則是將硬件(CPU)的性能發(fā)揮到極致。尤其是后者,在 Java 領域基本上屬于經(jīng)典之作了。發(fā)揮硬件的能力一般是 C 這種面向硬件的語言常干的事兒,C 語言領域經(jīng)常通過調(diào)整內(nèi)存布局優(yōu)化內(nèi)存占用,而 Java 領域則用的很少,原因在于 Java 可以智能地優(yōu)化內(nèi)存布局,內(nèi)存布局對 Java 程序員的透明的。這種智能的優(yōu)化大部分場景是很友好的,但是如果你想通過填充方式避免偽共享就必須繞過這種優(yōu)化,關于這方面 Disruptor 提供了經(jīng)典的實現(xiàn),你可以參考。由于偽共享問題如此重要,所以 Java 也開始重視它了,比如 Java 8 中,提供了避免偽共享的注解:@sun.misc.Contended,通過這個注解就能輕松避免偽共享(需要設置 JVM 參數(shù) -XX:-RestrictContended)。不過避免偽共享是以犧牲內(nèi)存為代價的,所以具體使用的時候還是需要仔細斟酌。