【集合系列】- 深入淺出分析 ArrayDeque
一、摘要
在 jdk1.5 中,新增了 Queue 介面,代表一種佇列集合的實現,咱們繼續來聊聊 java 集合體系中的 Queue 介面。
Queue 介面是由大名鼎鼎的 Doug Lea 建立,中文名為道格·利,關於這位大神,會在後期進行介紹,翻開 JDK1.8 原始碼,可以將 Queue 介面旗下的實現類抽象成如下結構圖:
Queue 介面,主要實現類有:ArrayDeque、LinkedList、PriorityQueue。
關於 LinkedList 實現類,在之前的文章中已經有所介紹,今天咱們來介紹一下 ArrayDeque 這個類,如果有理解不當之處,歡迎指正。
二、簡介
在介紹 ArrayDeque 類之前,可以從上圖中看出,ArrayDeque 實現了 Deque 介面,Deque 是啥呢,全稱含義為double ended queue
,即雙端佇列。Deque 介面的實現類可以被當作 FIFO(佇列)使用,也可以當作 LIFO(棧)來使用。
其中佇列(FIFO)表示先進先出,比如水管,先進去的水先出來;棧(LIFO)表示先進後出,比如,手槍彈夾,最後進去的子彈,最先出來。
ArrayDeque 是 Deque 介面的一種具體實現,所以,既可以當成佇列,也可以當成棧來使用,類定義如下:
public class ArrayDeque<E> extends AbstractCollection<E> implements Deque<E>, Cloneable, Serializable{ }
當作為佇列使用時,我們會將它與 LinkedList 類來做對比,在後文,我們會做測試類來將兩者進行詳細資料對比。因為 Deque 介面繼承自 Queue介面,在這裡,我們分別列出兩者介面所定義的方法,兩者內容區別如下:
當作為棧使用時,難免會將它與 Java 中一個叫做 Stack 的類做比較,Stack 類的資料結構也是後進先出,可以作為棧來使用,我們分別列出 Stack 類和 Deque 介面所定義的方法,兩者內容區別如下:
雖然,ArrayDeque 和 Stack 類都可以作為棧來使用,但是 ArrayDeque 的效率要高於 Stack 類,並且功能也比 Stack 類豐富的多,當需要使用棧時,Java 已不推薦使用 Stack,而是推薦使用更高效的 ArrayDeque,次選 LinkedList 。
從上面兩張圖中可以看出,Deque 總共定義了 2 組方法,新增、刪除、取值都有兩套方法,它們功能相同,區別是對失敗情況的處理不同,一組方法是遇到失敗會拋異常,另一組方法是遇到失敗會返回null
。
方法雖然定義的很多,但無非就是對容器的兩端進行新增、刪除、查詢操作,明白這一點,那麼使用起來就很簡單了。
繼續回到咱們要介紹的這個 ArrayDeque 類,從名字上可以看出 ArrayDeque 底層是通過陣列實現的,為了滿足可以同時在陣列兩端插入或刪除元素的需求,該陣列還必須是迴圈的,即迴圈陣列,也就是說陣列的任何一點都可能被看作起點或者終點。
因為是迴圈陣列,所以 head 不一定總是指向下標為 0 的陣列元素,tail 也不一定總是比 head 大。
這一點,我們可以通過 ArrayDeque 原始碼分析得出這些結論,開啟 ArrayDeque 的原始碼分析,可以看到,主要有3個關鍵引數:
- elements:用於存放陣列元素。
- head:用於指向陣列中頭部下標。
- tail:用於指向陣列中尾部下標。
public class ArrayDeque<E> extends AbstractCollection<E>
implements Deque<E>, Cloneable, Serializable{
/**用於存放陣列元素*/
transient Object[] elements;
/**用於指向陣列中頭部下標*/
transient int head;
/**用於指向陣列中尾部下標*/
transient int tail;
/**最小容量,必須為2的冪次方*/
private static final int MIN_INITIAL_CAPACITY = 8;
}
與此同時,ArrayDeque 提供了三個構造方法,分別是預設容量,指定容量及依據給定的集合中的元素進行建立,其中預設容量為 16。
public ArrayDeque() {
//預設初始化陣列大小為 16
elements = new Object[16];
}
指定容量初始化方法,原始碼如下:
public ArrayDeque(int numElements) {
//指定容量
allocateElements(numElements);
}
我們來看看指定容量呼叫的allocateElements
方法,原始碼如下:
private void allocateElements(int numElements) {
elements = new Object[calculateSize(numElements)];
}
calculateSize
方法,原始碼如下:
private static int calculateSize(int numElements) {
//最小容量為 8
int initialCapacity = MIN_INITIAL_CAPACITY;
//如果容量大於8,比如是2的倍數
if (numElements >= initialCapacity) {
initialCapacity = numElements;
initialCapacity |= (initialCapacity >>> 1);
initialCapacity |= (initialCapacity >>> 2);
initialCapacity |= (initialCapacity >>> 4);
initialCapacity |= (initialCapacity >>> 8);
initialCapacity |= (initialCapacity >>> 16);
initialCapacity++;
//容量超出int 型最大範圍,直接擴容到最大容量到 2 ^ 30
if (initialCapacity < 0)
initialCapacity >>>= 1;
}
return initialCapacity;
}
ArrayDeque 預設初始化容量為 16,如果指定容量,必須是 2 的倍數,當陣列容量超過 int 型最大範圍時,直接擴容到最大容量到2 ^ 30
。
三、常見方法介紹
3.1、新增方法
ArrayDeque,新增元素的方法有兩種,一種是通過陣列尾部下標進行新增,另一種是向陣列頭部下標進行新增。兩種新增方式,按照處理方式的不同,一種處理方式是返回為空,另一種處理方式是成功返回true
,兩者共性是如果新增失敗直接拋異常。
3.1.1、addLast 方法
addLast 方法,表示向尾部新增元素,操作如下圖:
如果插入失敗,就失敗拋異常,同時新增的元素不能為空null
,原始碼如下:
public void addLast(E e) {
//不允許放入null
if (e == null)
throw new NullPointerException();
elements[tail] = e;//將元素插入到尾部
//將尾部進行+1,判斷下標是否越界
if ( (tail = (tail + 1) & (elements.length - 1)) == head)
//陣列下標越界,進行擴容
doubleCapacity();
}
值得注意的是(tail = (tail + 1) & (elements.length - 1)) == head
這個方法,
可以把它拆成兩個步驟,第一個步驟是計算tail
陣列尾部值,等於(tail + 1) & (elements.length - 1)
,這個操作是先對尾部引數進行+1
處理,然後結合陣列長度通過位運算得到尾部值,因為elements.length
是2
的倍數,所以,位運算類似做%
得到其餘數。
假設,elements.length
等於16
,測試如下:
public static void main(String[] args) {
int tail = 0;
int[] elements = new int[16];
for (int i = 0; i < elements.length; i++) {
tail = (tail + 1) & (elements.length - 1);
System.out.println("第" + (i+1) + "次計算,結果值:" + tail);
}
}
輸出結果:
第1次計算,結果值:1
第2次計算,結果值:2
第3次計算,結果值:3
第4次計算,結果值:4
第5次計算,結果值:5
第6次計算,結果值:6
第7次計算,結果值:7
第8次計算,結果值:8
第9次計算,結果值:9
第10次計算,結果值:10
第11次計算,結果值:11
第12次計算,結果值:12
第13次計算,結果值:13
第14次計算,結果值:14
第15次計算,結果值:15
第16次計算,結果值:0
尾部下標從1、2、3、.....、14、15、0,依次按照順序儲存,當達到最大值之後,返回到頭部,從 0 開始,結果是一個迴圈下標。
第二個步驟是判斷tail == head
是否相等,當計算處理的尾部下標迴圈到與頭部下標重合的時候,說明陣列長度已經裝滿,直接進行擴容處理。
我們來看看doubleCapacity()
擴容這個方法,其邏輯是申請一個更大的陣列(原陣列的兩倍),然後將原陣列複製過去,流程圖下:
doubleCapacity()
擴容原始碼如下:
private void doubleCapacity() {
//擴容時頭部索引和尾部索引肯定相等
assert head == tail;
int p = head;
int n = elements.length;
//計算頭部索引到陣列末端(length-1處)共有多少元素
int r = n - p;
//容量翻倍,相當於 2 * n
int newCapacity = n << 1;
//容量過大,溢位了
if (newCapacity < 0)
throw new IllegalStateException("Sorry, deque too big");
//分配新空間
Object[] a = new Object[newCapacity];
//複製頭部索引至陣列末端的元素到新陣列的頭部
System.arraycopy(elements, p, a, 0, r);
//複製其餘元素
System.arraycopy(elements, 0, a, r, p);
elements = a;
head = 0;
tail = n;
}
複製陣列分兩次進行,第一次複製 head 頭部索引至陣列末端的元素到新陣列,第二次複製 head 左邊的元素到新陣列。
3.1.2、offerLast 方法
offerLast 方法,呼叫了addLast()
方法,兩者不同之處,offerLast 有返回值,如果新增成功,則返回true
,反之,拋異常;而 addLast 無返回值。
offerLast 方法原始碼如下:
public boolean offerLast(E e) {
addLast(e);
return true;
}
3.1.3、addFirst 方法
addFirst 方法,與addLast()
方法一樣,都是向陣列中新增元素,不同的是,addFirst 方法是向頭部新增元素,與 addLast 方法正好相反,但是演算法原理是一樣。
addFirst 方法原始碼如下:
public void addFirst(E e) {
//不允許元素為 null
if (e == null)
throw new NullPointerException();
//使用頭部引數計算下標
elements[head = (head - 1) & (elements.length - 1)] = e;
if (head == tail)
//如果頭部與尾部重合,進行陣列擴容
doubleCapacity();
}
假設elements.length
等於 16,我們來測試一下,通過 head 計算的陣列下標值,測試方法如下:
public static void main(String[] args) {
int head = 0;
int[] elements = new int[16];
for (int i = 0; i < elements.length; i++) {
head = (head - 1) & (elements.length - 1);
System.out.println("第" + (i+1) + "次計算,結果值:" + head);
}
}
輸出結果:
第1次計算,結果值:15
第2次計算,結果值:14
第3次計算,結果值:13
第4次計算,結果值:12
第5次計算,結果值:11
第6次計算,結果值:10
第7次計算,結果值:9
第8次計算,結果值:8
第9次計算,結果值:7
第10次計算,結果值:6
第11次計算,結果值:5
第12次計算,結果值:4
第13次計算,結果值:3
第14次計算,結果值:2
第15次計算,結果值:1
第16次計算,結果值:0
頭部計算的下標從15、14、13、.....、2、1、0,依次從大到小按照順序儲存,當達到最小值之後,返回到頭部,從 0 開始,結果也是一個迴圈下標。
具體實現流程與addLast
流程正好相反,就不再贅述了。
3.1.4、offerFirst 方法
offerFirst 方法,呼叫了addFirst
方法,兩者不同之處,offerFirst 有返回值,如果新增成功,則返回true
,反之,拋異常;而 addFirst 無返回值。
offerFirst 方法原始碼如下:
public boolean offerFirst(E e) {
addFirst(e);
return true;
}
3.2、刪除方法
ArrayDeque,刪除元素的方法有兩種,一種是通過陣列尾部下標進行刪除,另一種是通過陣列頭部下標進行刪除。兩種刪除方式,按照處理方式的不同,一種處理方式是刪除失敗拋異常,另一種處理方式是刪除失敗返回null
。
3.2.1、pollFirst 方法
pollFirst 方法,表示刪除頭部元素,並返回刪除的元素。
pollFirst 方法原始碼如下:
public E pollFirst() {
//獲取陣列頭部
int h = head;
E result = (E) elements[h];
//判斷頭部元素是否為空
if (result == null)
return null;
//設為null,方便GC回收
elements[h] = null;
//向上移動頭部元素
head = (h + 1) & (elements.length - 1);
return result;
}
pollFirst 方法是先獲取陣列頭部元素,判斷元素是否存在,如果不存在,直接返回null
,如果存在,將其設為null
,並返回元素。
3.2.2、removeFirst 方法
removeFirst 方法,呼叫了pollFirst
方法,兩者不同的是,removeFirst 方法,如果刪除元素失敗會拋異常,而 pollFirst 方法會返回null
,原始碼如下:
public E removeFirst() {
E x = pollFirst();
//返回為null ,拋異常
if (x == null)
throw new NoSuchElementException();
return x;
}
3.2.3、pollLast 方法
pollLast 方法,與pollFirst
方法正好相反,對陣列尾部元素進行刪除,並返回元素。
pollLast 方法,原始碼如下:
public E pollLast() {
//通過尾部計算陣列下標
int t = (tail - 1) & (elements.length - 1);
E result = (E) elements[t];
//判斷是否為空
if (result == null)
return null;
//設為null
elements[t] = null;
tail = t;
return result;
}
pollLast 方法是先通過陣列尾部計算陣列元素下標,判斷元素是否存在,如果不存在,直接返回null
,如果存在,將其設為null
,並返回元素。
3.2.4、removeLast 方法
removeLast 方法,呼叫了pollLast
方法,兩者不同的是,removeLast 方法,如果刪除元素失敗會拋異常,而 pollLast 方法會返回null
,原始碼如下:
public E removeLast() {
E x = pollLast();
//返回為null ,拋異常
if (x == null)
throw new NoSuchElementException();
return x;
}
3.3、查詢方法
ArrayDeque,查詢元素的方法也有兩種,一種是通過陣列尾部下標進行獲取,另一種是通過陣列頭部下標進行獲取。兩種查詢方式,按照處理方式的不同,一種處理方式是查詢失敗拋異常,另一種處理方式是查詢失敗返回null
。
3.3.1、peekFirst 方法
peekFirst 方法,表示通過陣列頭部獲取陣列元素,可能返回null
,原始碼如下:
public E peekFirst() {
//可能返回null
return (E) elements[head];
}
3.3.2、getFirst 方法
getFirst 方法,表示通過陣列頭部獲取陣列元素,如果返回null
則拋異常,原始碼如下:
public E getFirst() {
E result = (E) elements[head];
//查詢返回null ,拋異常
if (result == null)
throw new NoSuchElementException();
return result;
}
3.3.3、peekLast 方法
peekLast 方法,表示通過陣列尾部獲取陣列元素,可能返回null
,原始碼如下:
public E peekFirst() {
//可能返回null
return (E) elements[(tail - 1) & (elements.length - 1)];
}
3.3.4、getLast 方法
getLast 方法,表示通過陣列尾部獲取陣列元素,如果返回null
則拋異常,原始碼如下:
public E getLast() {
//獲取陣列尾部下標
E result = (E) elements[(tail - 1) & (elements.length - 1)];
//查詢返回null,拋異常
if (result == null)
throw new NoSuchElementException();
return result;
}
四、效能比較
ArrayDeque 和 LinkedList 都是 Deque 介面的實現類,都具備既可以作為佇列,又可以作為棧來使用的特性,兩者主要區別在於底層資料結構的不同。
ArrayDeque 底層資料結構是以迴圈陣列為基礎,而 LinkedList 底層資料結構是以迴圈連結串列為基礎。理論上,連結串列在新增、刪除方面效能高於陣列結構,在查詢方面陣列結構效能高於連結串列結構,但是對於陣列結構,如果不進行陣列移動,在新增方面效率也很高。
下面,分別以10萬條資料為基礎,通過新增、刪除,來測試兩者作為佇列、棧使用時所消耗的時間。
4.1、ArrayDeque效能測試
4.1.1、作為佇列
public static void main(String[] args) {
ArrayDeque<String> arrayDeque = new ArrayDeque<>();
long addStart = System.currentTimeMillis();
//向佇列尾部插入 10W 條資料
for (int i = 0; i < 100000; i++) {
arrayDeque.addLast(i + "");
}
long result1 = System.currentTimeMillis() - addStart;
System.out.println("向佇列尾部插入10W條資料耗時:" + result1);
//獲取並刪除隊首元素
long deleteStart = System.currentTimeMillis();
while (true){
String content = arrayDeque.pollFirst();
if(content == null){
break;
}
}
long result2 = System.currentTimeMillis() - deleteStart;
System.out.println("\n從頭部刪除佇列10W條資料耗時:" + result2);
System.out.println("佇列元素總數:" + arrayDeque.size());
}
輸出結果:
向佇列尾部插入10W條資料耗時:59
從佇列頭部刪除10W條資料耗時:4
佇列元素總數:0
4.1.2、作為棧
public static void main(String[] args) {
ArrayDeque<String> arrayDeque = new ArrayDeque<>();
long addStart = System.currentTimeMillis();
//向棧頂插入 10W 條資料
for (int i = 0; i < 100000; i++) {
arrayDeque.addFirst(i + "");
}
long result1 = System.currentTimeMillis() - addStart;
System.out.println("向棧頂插入10W條資料耗時:" + result1);
//獲取並刪除棧頂元素
long deleteStart = System.currentTimeMillis();
while (true){
String content = arrayDeque.pollFirst();
if(content == null){
break;
}
}
long result2 = System.currentTimeMillis() - deleteStart;
System.out.println("從棧頂刪除10W條資料耗時:" + result2);
System.out.println("棧元素總數:" + arrayDeque.size());
}
輸出結果:
向棧頂插入10W條資料耗時:61
從棧頂刪除10W條資料耗時:3
棧元素總數:0
4.2、LinkedList
4.2.1、作為佇列
public static void main(String[] args) {
LinkedList<String> linkedList = new LinkedList();
long addStart = System.currentTimeMillis();
//向佇列尾部插入 10W 條資料
for (int i = 0; i < 100000; i++) {
linkedList.addLast(i + "");
}
long result1 = System.currentTimeMillis() - addStart;
System.out.println("向佇列尾部插入10W條資料耗時:" + result1);
//獲取並刪除隊首元素
long deleteStart = System.currentTimeMillis();
while (true){
String content = linkedList.pollFirst();
if(content == null){
break;
}
}
long result2 = System.currentTimeMillis() - deleteStart;
System.out.println("從佇列頭部刪除10W條資料耗時:" + result2);
System.out.println("佇列元素總數:" + linkedList.size());
}
輸出結果:
向佇列尾部插入10W條資料耗時:70
從佇列頭部刪除10W條資料耗時:5
佇列元素總數:0
4.2.2、作為棧
public static void main(String[] args) {
LinkedList<String> linkedList = new LinkedList();
long addStart = System.currentTimeMillis();
//向棧頂插入 10W 條資料
for (int i = 0; i < 100000; i++) {
linkedList.addFirst(i + "");
}
long result1 = System.currentTimeMillis() - addStart;
System.out.println("向棧頂插入10W條資料耗時:" + result1);
//獲取並刪除棧頂元素
long deleteStart = System.currentTimeMillis();
while (true){
String content = linkedList.pollFirst();
if(content == null){
break;
}
}
long result2 = System.currentTimeMillis() - deleteStart;
System.out.println("從棧頂刪除10W條資料耗時:" + result2);
System.out.println("棧元素總數:" + linkedList.size());
}
輸出結果:
向棧頂插入10W條資料耗時:71
從棧頂刪除10W條資料耗時:5
棧元素總數:0
4.3、總結
我們分別以10萬條資料、100萬條資料、1000萬條資料來測試,兩個類在作為佇列和棧方面的效能,可能因為機器的不同,每個機器的測試結果不同,本次使用的是 mac 機器,測試結果如下圖:
從資料上可以看出,在 10 萬條資料下,兩者效能都差不多,當達到 100 萬條、1000 萬條資料的時候,兩者的差別就比較明顯了,ArrayDeque 無論是作為佇列還是作為棧使用,效能均高於 LinkedList 。
為什麼 ArrayDeque 效能,在大資料量的時候,明顯高於 LinkedList?
個人分析,我們曾在集合系列文章中提到過 LinkedList,LinkedList 底層是以迴圈連結串列來實現的,每一個節點都有一個前驅、後繼的變數,也就是說,每個節點上都存放有它上一個節點的指標和它下一個節點的指標,同時還包括它自己的元素,在同等的資料量情況下,連結串列的記憶體開銷要明顯大於陣列,同時因為 ArrayDeque 底層是陣列結構,天然在查詢方面在優勢,在插入、刪除方面,只需要移動一下頭部或者尾部變數,時間複雜度是 O(1)。
所以,在大資料量的時候,LinkedList 的記憶體開銷明顯大於 ArrayDeque,在插入、刪除方面,都要頻發修改節點的前驅、後繼變數;而 ArrayDeque 在插入、刪除方面依然儲存高效能。
如果對於小資料量,ArrayDeque 和 LinkedList 在效率方面相差不大,但是對於大資料量,推薦使用 ArrayDeque。
五、總結
ArrayDeque 底層基於迴圈陣列實現,既可以作為佇列使用,又可以作為棧來使用。
ArrayDeque 作為棧的時候,經常會將它與 Stack 做對比,Stack 也是一個可以作為棧使用的類,但是 Java 已不推薦使用它,如果要使用棧,推薦使用更高效的 ArrayDeque。
與此同時,ArrayDeque 和 LinkedList 都是 Deque 介面的實現類,兩者差別在於底層資料結構的不同,LinkedList 底層基於迴圈連結串列實現,記憶體開銷高於 ArrayDeque,在小資料量的時候,兩者效率差別不大;在大資料量的時候,ArrayDeque 效能高於 LinkedList,推薦使用 ArrayDeque 類。
還有一個不同的地方是,ArrayDeque 不允許插入null
,而 LinkedList 允許插入null
;同時,兩者都是非執行緒安全的,如果在多執行緒環境下,建議使用 Java 併發工具包裡面的操作類。
六、參考
1、JDK1.7&JDK1.8 原始碼
2、知乎 - CarpenterLee -Java ArrayDeque原始碼剖析
作者:炸雞可樂
原文出處:www.pzblog.cn