【集合系列】- 深刻淺出分析 ArrayDeque

1、摘要

在 jdk1.5 中,新增了 Queue 接口,表明一種隊列集合的實現,我們繼續來聊聊 java 集合體系中的 Queue 接口。java

Queue 接口是由大名鼎鼎的 Doug Lea 建立,中文名爲道格·利,關於這位大神,會在後期進行介紹,翻開 JDK1.8 源代碼,能夠將 Queue 接口旗下的實現類抽象成以下結構圖:算法

Queue 接口,主要實現類有:ArrayDeque、LinkedList、PriorityQueue。數組

關於 LinkedList 實現類,在以前的文章中已經有所介紹,今天我們來介紹一下 ArrayDeque 這個類,若是有理解不當之處,歡迎指正。安全

2、簡介

在介紹 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、常見方法介紹

3.一、添加方法

ArrayDeque,添加元素的方法有兩種,一種是經過數組尾部下標進行添加,另外一種是向數組頭部下標進行添加。兩種添加方式,按照處理方式的不一樣,一種處理方式是返回爲空,另外一種處理方式是成功返回true,二者共性是若是添加失敗直接拋異常。

3.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.length2的倍數,因此,位運算相似作%獲得其他數。

假設,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四、1五、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.二、offerLast 方法

offerLast 方法,調用了addLast()方法,二者不一樣之處,offerLast 有返回值,若是添加成功,則返回true,反之,拋異常;而 addLast 無返回值。

offerLast 方法源碼以下:

public boolean offerLast(E e) {
    addLast(e);
    return true;
}
3.1.三、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

頭部計算的下標從1五、1四、1三、.....、二、一、0,依次從大到小按照順序存儲,當達到最小值以後,返回到頭部,從 0 開始,結果也是一個循環下標

具體實現流程與addLast流程正好相反,就再也不贅述了。

3.1.四、offerFirst 方法

offerFirst 方法,調用了addFirst方法,二者不一樣之處,offerFirst 有返回值,若是添加成功,則返回true,反之,拋異常;而 addFirst 無返回值。

offerFirst 方法源碼以下:

public boolean offerFirst(E e) {
    addFirst(e);
    return true;
}

3.二、刪除方法

ArrayDeque,刪除元素的方法有兩種,一種是經過數組尾部下標進行刪除,另外一種是經過數組頭部下標進行刪除。兩種刪除方式,按照處理方式的不一樣,一種處理方式是刪除失敗拋異常,另外一種處理方式是刪除失敗返回null

3.2.一、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.二、removeFirst 方法

removeFirst 方法,調用了pollFirst方法,二者不一樣的是,removeFirst 方法,若是刪除元素失敗會拋異常,而 pollFirst 方法會返回null,源碼以下:

public E removeFirst() {
    E x = pollFirst();
    //返回爲null ,拋異常
    if (x == null)
        throw new NoSuchElementException();
    return x;
}
3.2.三、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.四、removeLast 方法

removeLast 方法,調用了pollLast方法,二者不一樣的是,removeLast 方法,若是刪除元素失敗會拋異常,而 pollLast 方法會返回null,源碼以下:

public E removeLast() {
    E x = pollLast();
    //返回爲null ,拋異常
    if (x == null)
        throw new NoSuchElementException();
    return x;
}

3.三、查詢方法

ArrayDeque,查詢元素的方法也有兩種,一種是經過數組尾部下標進行獲取,另外一種是經過數組頭部下標進行獲取。兩種查詢方式,按照處理方式的不一樣,一種處理方式是查詢失敗拋異常,另外一種處理方式是查詢失敗返回null

3.3.一、peekFirst 方法

peekFirst 方法,表示經過數組頭部獲取數組元素,可能返回null,源碼以下:

public E peekFirst() {
    //可能返回null
    return (E) elements[head];
}
3.3.二、getFirst 方法

getFirst 方法,表示經過數組頭部獲取數組元素,若是返回null則拋異常,源碼以下:

public E getFirst() {
    E result = (E) elements[head];
    //查詢返回null ,拋異常
    if (result == null)
        throw new NoSuchElementException();
    return result;
}
3.3.三、peekLast 方法

peekLast 方法,表示經過數組尾部獲取數組元素,可能返回null,源碼以下:

public E peekFirst() {
    //可能返回null
    return (E) elements[(tail - 1) & (elements.length - 1)];
}
3.3.四、getLast 方法

getLast 方法,表示經過數組尾部獲取數組元素,若是返回null則拋異常,源碼以下:

public E getLast() {
    //獲取數組尾部下標
    E result = (E) elements[(tail - 1) & (elements.length - 1)];
    //查詢返回null,拋異常
    if (result == null)
        throw new NoSuchElementException();
    return result;
}

4、性能比較

ArrayDeque 和 LinkedList 都是 Deque 接口的實現類,都具有既能夠做爲隊列,又能夠做爲棧來使用的特性,二者主要區別在於底層數據結構的不一樣。

ArrayDeque 底層數據結構是以循環數組爲基礎,而 LinkedList 底層數據結構是以循環鏈表爲基礎。理論上,鏈表在添加、刪除方面性能高於數組結構,在查詢方面數組結構性能高於鏈表結構,可是對於數組結構,若是不進行數組移動,在添加方面效率也很高。

下面,分別以10萬條數據爲基礎,經過添加、刪除,來測試二者做爲隊列、棧使用時所消耗的時間。

4.一、ArrayDeque性能測試

4.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.二、做爲棧
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.二、LinkedList

4.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.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.二、做爲棧
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.三、總結

咱們分別以10萬條數據、100萬條數據、1000萬條數據來測試,兩個類在做爲隊列和棧方面的性能,可能由於機器的不一樣,每一個機器的測試結果不一樣,本次使用的是 mac 機器,測試結果以下圖:

從數據上能夠看出,在 10 萬條數據下,二者性能都差很少,當達到 100 萬條、1000 萬條數據的時候,二者的差異就比較明顯了,ArrayDeque 不管是做爲隊列仍是做爲棧使用,性能均高於 LinkedList 。

爲何 ArrayDeque 性能,在大數據量的時候,明顯高於 LinkedList?

我的分析,咱們曾在集合系列文章中提到過 LinkedList,LinkedList 底層是以循環鏈表來實現的,每個節點都有一個前驅、後繼的變量,也就是說,每一個節點上都存放有它上一個節點的指針和它下一個節點的指針,同時還包括它本身的元素,在同等的數據量狀況下,鏈表的內存開銷要明顯大於數組,同時由於 ArrayDeque 底層是數組結構,自然在查詢方面在優點,在插入、刪除方面,只須要移動一下頭部或者尾部變量,時間複雜度是 O(1)。

因此,在大數據量的時候,LinkedList 的內存開銷明顯大於 ArrayDeque,在插入、刪除方面,都要頻發修改節點的前驅、後繼變量;而 ArrayDeque 在插入、刪除方面依然保存高性能。

若是對於小數據量,ArrayDeque 和 LinkedList 在效率方面相差不大,可是對於大數據量,推薦使用 ArrayDeque。

5、總結

ArrayDeque 底層基於循環數組實現,既能夠做爲隊列使用,又能夠做爲棧來使用。

ArrayDeque 做爲棧的時候,常常會將它與 Stack 作對比,Stack 也是一個能夠做爲棧使用的類,可是 Java 已不推薦使用它,若是要使用棧,推薦使用更高效的 ArrayDeque。

與此同時,ArrayDeque 和 LinkedList 都是 Deque 接口的實現類,二者差異在於底層數據結構的不一樣,LinkedList 底層基於循環鏈表實現,內存開銷高於 ArrayDeque,在小數據量的時候,二者效率差異不大;在大數據量的時候,ArrayDeque 性能高於 LinkedList,推薦使用 ArrayDeque 類。

還有一個不一樣的地方是,ArrayDeque 不容許插入null,而 LinkedList 容許插入null;同時,二者都是非線程安全的,若是在多線程環境下,建議使用 Java 併發工具包裏面的操做類。

6、參考

一、JDK1.7&JDK1.8 源碼

二、知乎 - CarpenterLee -Java ArrayDeque源碼剖析

做者:炸雞可樂
原文出處:www.pzblog.cn

相關文章
相關標籤/搜索