深刻理解ArrayDeque的設計與實現

前言

最近在研讀OkHttp源碼,發現它的Dispatcher分發器使用了ArrayDeque數據集合,這個集合類是java.util裏提供的雙端隊列/棧,非線程安全,可是性能很好,很是值得研究一下。javascript

基本設計

咱們先總體推測一下ArrayDeque的設計思路。java

功能

每一種集合類的具體功能,基本上都是增/刪/改/查四大項,ArrayDeque也不例外,只不過它更貼近於棧的功能,主要操做棧頂和棧底的數據,對於棧中的數據,只有delete一個函數。數組

問題

Java中通常只有兩種基礎的數據容器,數組或鏈表。數組排列緊密、下標查找快、中間插入慢;鏈表排列稀疏、查找慢,中間插入快。對於棧結構來講,採用數組更具優點,ArrayDeque也是採用了數組來做爲基本數據容器,也須要處理數組擴容問題。安全

不過,傳統的數組都是線性數組,向尾部添加數據當然很方便,可是向中間添加數據的話,就須要挪動插入點以後的全部數據,若是向頭部添加數據的話,整個數組都要挪一遍,雖然Java提供了Native函數System.arrayCopy來提高效率,可是對內存的操做是不可避免的。bash

循環數組

爲了提升效率,ArrayDeque採用了循環數組的設計,也就是說雖然基礎容器是一個普通的數組(默認容量16),可是在邏輯上,這個數組沒有固定的開頭或結尾,既能夠直接向尾部添加數據,也能夠直接向頭部以前添加數據,不須要大面積地移動數據。
邏輯上的概念大概是這樣的:markdown


具體實現及優化

循環數組在概念上沒有左右邊界,可是Java並無這樣的數組,Java只能提供固定大小的數組,這樣的話,如何實現循環數組就轉變爲如何利用固定數組實現循環數組
相對於線性數組,循環數組是連續的,可是數組的頭和尾可能在任何位置,因此循環數組在真實數據中的映射大概是這樣:函數

在物理數組中的循環數組

在邏輯上,隊首老是在左邊,隊尾老是在右邊,可是若是持續向隊首插入數據,就很容易把隊首」頂「出物理容器的左邊界,」頂「進物理容器的右側。
這時候,咱們能夠看到,循環數組在物理數組中最大的問題是, 不少時候,邏輯上連續,但物理上被分紅了兩截
因此,ArrayDeque須要針對如下操做,作特殊處理:

1.添加/刪除頭

主要判斷頭的新位置在左側仍是右側。
先看添加:
head的新位置必定是head-1,若是獲得的結果爲-1,就須要挪到右側,也就是物理數組中的最後一個位置length-1。
若是讓咱們本身來寫,多是這樣寫:工具

if(head-1<0){ head=elements.length -1; }else{ head=head-1; } elements[head]=e; 複製代碼

事實上,ArrayDeque使用了更精妙的實現,他用一步位與運算實現了這個功能:性能

elements[head = (head - 1) & (elements.length - 1)] = e;
複製代碼

這行代碼具體什麼意思呢?
當head-1爲-1時,其實是11111111&00001111,結果是00001111,也就是物理數組的尾部15
當head-1爲較小的值如3時,其實是00000011&00001111,結果是00000011,仍是3。
當head增加如head+1超過物理數組長度如16時,其實是00010000&00001111,結果是00000000,也就是0,這樣就回到了物理數組的頭部
因此,位與運算能夠很輕鬆地實現把數據控制在某個範圍內
回過頭來,咱們再看刪除頭的代碼:優化

elements[h] = null; // Must null out slot
            head = (h + 1) & (elements.length - 1);
複製代碼

先清空數據,而後移動head位置,也是用位與運算實現的。
位運算原本就很是高效,ArrayDeque的這種寫法,更是用一行代碼覆蓋了函數中的全部場景,很是精妙。

2.添加/刪除尾

主要判斷尾的新位置在左側仍是右側,具體操做和上一步添加/刪除頭相似。

3.刪除中間某個數據

刪除中間元素時,即便是循環數組,也須要批量移動數組元素了,因此刪除中間元素實際上面臨三個問題,一是須要在左側或右側刪除,二是須要挪動頭或尾,三是優化須要,儘可能少得移動數組元素。
ArrayDeque其實是先從第三個問題入手的,先判斷中間元素裏head近仍是離tail近,而後移動較近的那一端。
不過,較近的一端只是邏輯上較近,物理數組上,可能被分紅了兩截,這就須要作兩次數組元素的批量移動

//ArrayDeque源碼(部分狀況)
                System.arraycopy(elements, 0, elements, 1, i);
                elements[0] = elements[mask];
                System.arraycopy(elements, h, elements, h + 1, mask - h);
複製代碼

4.計算隊列長度,若是物理上不連續,須要特別計算真正的數據。

若是讓咱們本身寫,可能也是分條件判斷,分別計算兩截的數據。
ArrayDeque又使用了位與運算:

return (tail - head) & (elements.length - 1);
複製代碼

也是隻有一行,當物理上被分爲兩截時,tail-head會是負數,整個操做至關於取模運算,例如,當tail爲3,head爲14,物理數組長度16時,運算的就是11110101&00001111,值爲00000101,也就是5。

5.複製一個線性數組toArray

從基本操做上,若是物理上不連續,先複製右側,再補上左側。
toArray的問題在於新數組的長度是動態的,爲了生成大小恰好的新數組,ArrayDeque使用了Arrays工具類來實現這個特定長度的數組,並同時實現對head一側數據的複製:

boolean wrap = (tail < head);
int end = wrap ? tail + elements.length : tail;
Object[] a = Arrays.copyOfRange(elements, head, end);
if (wrap)
     System.arraycopy(elements, 0, a, elements.length - head, tail);
複製代碼

這樣的話,若是物理空間連續,就直接複製完成;
但若是物理空間不連續,第一次Arrays.copyOfRange須要保證一次性生成足夠的物理空間,因此end的值不能是length(不然長度就只有length-head這麼長),而應該是tail+length。

6.空間擴容。

數據容器的擴容其實能夠分解爲兩個問題,一是什麼時候開始擴容,二是舊數組數據的複製。
對於什麼時候開始擴容的問題,爲了減小檢查的次數,ArrayDeque採用了對head和tail是否重合的檢查,只要tail和head不重合,就說明tail後面/head前面還有空間,因此只要在添加頭/尾時檢查head==tail便可。
對於舊數組數據的複製,空間擴容和問題和toArray有些相似,不一樣的是新數組的長度是可知的(2倍),因此能夠直接new一個定長的數組,這樣就不須要Array.copyOfRange函數,能夠統一使用System.arrayCopy:

Object[] a = new Object[newCapacity];
        System.arraycopy(elements, p, a, 0, r);
        System.arraycopy(elements, 0, a, r, p);
複製代碼

其餘

相比ArrayList,咱們能夠看到ArrayDeque大量減小了System.arrayCopy的使用,只在delete、clone、擴容和toArray函數中使用了這個函數,其餘操做中都不須要大量移動數組元素,這也能夠說明ArrayDeque這個數據集合的性能很是優良。

相關文章
相關標籤/搜索