ArrayDeque原理詳解


介紹

ArrayDeque是雙向隊列,線程不安全的雙向隊列,長度可以自己擴容的雙向隊列,並且長度需要是2的冪次方,雙端主要是頭部和尾部兩端都可以進行插入刪除和獲取操作,該實現類實現了Deque接口,Deque接口提供了雙向隊列需要實現的方法,接口提供了從頭部插入、尾部插入,從頭部獲取、尾部獲取以及刪除等操作。ArrayDeque從名稱來看能看出ArrayDeque內部使用的是數組來進行存儲元素。

類圖

image-20210613153422308.png

通過類圖也可以清晰的看到ArrayDeque繼承自Deque接口,並且繼承自Queue接口。同時也繼承自Collection接口說明可以使用迭代器進行遍歷集合。

源碼分析

在分析源碼之前,我們可以試想一些問題,前文已經介紹過ArrayDeque內部使用的數組元素來進行存儲,數組中是如何控制從頭部進行插入的呢?當數組為空時,從頭部插入元素是如何實現的?以及數組元素中有值時,比如數組a=[1,2,3],這時候數組元素0的位置是有元素的,那又是如何將元素插入到數組下標0的元素之前的呢?前文講述過數組的長度是2的冪次方?為什么數組的長度要是2的冪次方呢?帶着這個問題來看源碼的分析。

字段信息

由於Deque接口是雙向隊列,所以再進行添加元素的時候會指定head指針和tail尾指針,head指針指向數據元素的頭部,tail指針指向數據元素的尾部,通過head指針和tail指針控制是從頭部進行操作還是尾部進行操作,以下是ArrayDeque中的字段信息:

/**
 * 數組存儲的元素。
 */
transient Object[] elements; // non-private to simplify nested class access

/**
 * 頭指針。
 */
transient int head;

/**
 * 尾指針。
 */
transient int tail;

/**
 * 數組的默認最小大小。長度必須是2的冪次方。
 */
private static final int MIN_INITIAL_CAPACITY = 8;

calculateSize方法

首先我們來解決第一個問題,就是數組的長度的問題,數組長度前面說必須是2的冪次方,但是看到構造函數中可以指定數組的長度,既然可以指定數組的長度,那這里指定數組長度為10,這個數字也不是2的冪次方啊,其實我們指定的雖然不是2的冪次方,但是ArrayDeque內部會幫我進行調整,調整數組長度為2的冪次方,先看一下ArrarDeque的構造函數:

// 默認長度為16的數組。
public ArrayDeque() {
    elements = new Object[16];
}

/**
 * 指定數組長度,發現指定數組長度時,調用了allocateElements方法。
 */
public ArrayDeque(int numElements) {
    allocateElements(numElements);
}

/**
 * 指定集合並且初始化大小。
 */
public ArrayDeque(Collection<!--? extends E--> c) {
    allocateElements(c.size());
    addAll(c);
}

構造函數中除了第一個默認構造函數之外,其他兩個構造函數都調用了allocateElements方法來進行初始化數組的動作以及容量調整的動作,接下來我們來分析下是如何做到容量是2的冪次方?

/**
 * 初始化數組大小,調用calculateSize方法來調整容量大小。
 */
private void allocateElements(int numElements) {
    elements = new Object[calculateSize(numElements)];
}

上面的方法對數組元素進行初始化,在初始化前需要進行容量的調整,實際調整容量大小的方法是calculateSize方法。

private static int calculateSize(int numElements) {
  	// 獲取最小的初始化容量大小。
    int initialCapacity = MIN_INITIAL_CAPACITY;
		// 如果容量大於最小的容量,則尋找一個2的冪次方的值。
    if (numElements >= initialCapacity) {
        initialCapacity = numElements;
        initialCapacity |= (initialCapacity >>>  1);
        initialCapacity |= (initialCapacity >>>  2);
        initialCapacity |= (initialCapacity >>>  4);
        initialCapacity |= (initialCapacity >>>  8);
        initialCapacity |= (initialCapacity >>> 16);
        initialCapacity++;

        if (initialCapacity < 0)   // Too many elements, must back off
            initialCapacity >>>= 1;// Good luck allocating 2 ^ 30 elements
    }
  	// 返回2的冪次方值。
    return initialCapacity;
}

以開始時的例子為主,比如我們在初始化ArrayDeque指定了數組長度為10,它在進行初始化數組大小時會調用calculateSize來計算一個2的冪次方的值。

public static void main(String[] args) {
    ArrayDeque<integer> arrayDeque = new ArrayDeque<>(10);
}
  1. 程序運行到第五行時,numElements >= initialCapacity成立,10>=8,則會進入到if語句內部。
  2. 程序運行到第六行時, initialCapacity = numElements,initialCapacity 設置為10,10的二進制表示方式是1010。
  3. 程序運行到第七行時, initialCapacity無符號向右移動一位,1010無符號向右移動一位是0101,1010|0101=1111,十進制表示方式是15。
  4. 程序運行到第七行時, initialCapacity無符號向右移動一位,1111無符號向右移動一位是0011,1111|0011=1111,十進制表示方式是15,一直持續下去都是15,當程序運行到第12行時,15進行加1操作,則變成16。這個時候16就是2的冪次方返回。

整體思路是每次移動將位數最高的值變成1,從而將二進制所有位數都變成1,變成1之后得到的十進制加上1之后得到值就是2的冪次方的值,這里的操作在JDK1.7版本中的HashMap擴容操作代碼是類似的。

AddFirst方法

以上就是如何保證了數組的大小是2的冪次方的代碼邏輯,代碼設計很巧妙,2的冪次方數組大小有什么好處呢?其實這里我可以簡單描述下好處在於可以控制指針的在數組中的位置,也就是可以解決第一個問題,接下來再往下繼續進行分析數組進行頭部插入時的內容,先上源碼先上源碼:

public void addFirst(E e) {
    if (e == null)
        throw new NullPointerException();
    elements[head = (head - 1) & (elements.length - 1)] = e;
    if (head == tail)
        doubleCapacity();
}

在頭部進行插入元素,比如我們看一下如下代碼的運行結果,先進行頭部進行插入,然后再尾部插入,看一下整體的插入過程,通過這個簡單的實例能夠了解為什么數組長度設置為2的冪次方。

public static void main(String[] args) {
    ArrayDeque<integer> arrayDeque = new ArrayDeque<>(10);
    arrayDeque.addFirst(5);
    arrayDeque.addLast(1);
    arrayDeque.forEach(System.out::println);
}

此時初始化時數組長度為16,頭指針head和尾指針head默認是0,此時數組的內容如下所示:

image-20210613173946845.png

當執行addFirst方法插入數組元素5時,通過源碼可以看到需要執行elements[head = (head - 1) & (elements.length - 1)] = e;這一行代碼,至於后面那個headtail是為了擴容使用,也就是只有在隊列滿時才會對數組進行擴容操作,隊列滿的標識是headtail代表隊列已經滿了,這里先進行分析elements[head = (head - 1) & (elements.length - 1)] = e,我們將其拆分成如下內容

  • head=head-1,此時的head=0,那么head-1得到值15(二進制減法操作),15使用二進制表示為:1111
  • elements.length - 1,這里開始是初始化大小為10,通過calculateSize方法計算的到數組長度為16,16-1=15,二進制表示方式也是1111。
  • 1111&1111依然是1111,此時數組的下標為15。

咦?通過這個算出來的下標竟然是15,而不是0?可能大家猜想的是在頭部插入時會當數組為空時,它會插入到數組的元素下標0的位置,其實並不是,而是插入到下標為15的位置處,通過圖示法來看一下:

image-20210613175135449.png

插入到下標為15的位置是因為,假如我們在0,1,2下標位置插入值后,在通過頭插法插入值時,發現數組的頭部已經沒有位置了,它會利用數組的尾部進行插入,head會指向尾部的位置,這也是為什么數組要設置為2的冪次方的原因,是為了能夠定位數組的中頭指針的位置,大家看到頭指針前一個指針是head = (head - 1) & (elements.length - 1),那大膽猜測一下頭指針的下一個指針指向的head = (head + 1) & (elements.length - 1),這個我們后面來驗證。

AddLast方法

還是回到上面的例子中,這里只是運行到了addFirst,繼續運行addLast內容,首先先上源碼,然后在針對源碼進行分析:

public void addLast(E e) {
    if (e == null)
        throw new NullPointerException();
    elements[tail] = e;
    if ( (tail = (tail + 1) & (elements.length - 1)) == head)
        doubleCapacity();
}

這里從尾指針進行插入看着還是比較簡單的,直接使用 elements[tail] = e;即可,此時數組中元素存儲情況以及tail指針移動位置。

image-20210613210956127.png

此時tail指針是進行增加1,也就是會運行到tail = (tail + 1) & (elements.length - 1)這里,這里判斷tail的下一個節點如果和head節點重合說明數組已經滿了,需要進行擴容操作,相當於如下所示:

image-20210613211152913.png

當有線程再對ArrayDeque隊列進行插入值時,這是tail值插入值后,tail會指向head節點,此時head和tail進行重合,重合后進行擴容操作,如下圖所示:

image-20210613211426159.png

ArrayDeque雙向隊列巧妙的運用了數組的頭部和尾部,簡單點說頭指針在獲取數據時,需要將頭指針進行增加1操作,當頭指針達到數組尾部時,將頭指針指向數組的頭部,從數組的頭部進行獲取數據。如果從尾指針獲取數據時,其實就是從尾指針數據進行減少1操作,向前進行獲取數據,如果達到了數組頭部,則將尾指針指向數組的數組的尾部,從尾部往前在進行尋找,如果找到值為空說明數組為空了。

pollFirst方法

當然ArrayDeque對獲取數據還有peekFirst和peekLast,這兩個方法比較簡單,就是獲取對應指針值,這里就不再贅述了,這里重點講一下pollFirst和pollLast方法。

public E pollFirst() {
  	// 獲取頭指針。
    int h = head;
    @SuppressWarnings("unchecked")
		// 獲取頭指針對應的數組元素值。
    E result = (E) elements[h];
    // Element is null if deque empty
  	// 如果為空則說明說明數組為空,直接返回。
    if (result == null)
        return null;
  	// 將頭指針值設置為空。
    elements[h] = null;     // Must null out slot
  	// 啊哈?這里是不是我們上面猜測的內容,頭指針下一個位置。
    head = (h + 1) & (elements.length - 1);
    return result;
}

演示下剛才插入的兩個元素情況進行pollFirst是一種什么樣的操作,插入之后數組元素是這樣的。

image-20210613210956127.png

當pollFirst時,此時head=15,h=15,result=elements[15]=5,並且將 elements[15]設置為null,此時數組為:

image-20210613213246501.png
設置為空后會執行如下語句,head = (h + 1) & (elements.length - 1),啊哈,這里和上文中猜測的內容是一樣的,h+1=15+1=16,用二進制表示1 0000,數組長度為16,16-1=15,用二進制表示1111,相當於1111&0000=0,此時數組head節點被調整到數組的頭部,head=0。此時數組為:

image-20210613213540103.png

pollLast方法

這里還是回歸到插入兩個元素的情況,即如下狀態:

image-20210613210956127.png

先看一下源碼信息,如下所示:

public E pollLast() {
  	// 找到尾指針的前一個指針位置。
    int t = (tail - 1) & (elements.length - 1);
    @SuppressWarnings("unchecked")
  	// 獲取尾部元素。
    E result = (E) elements[t];
    if (result == null)
        return null;
  	// 將尾部元素設置為空。
    elements[t] = null;
  	// 調整尾指針位置。
    tail = t;
    return result;
}

當調用pollLast時,首先運行的是 int t = (tail - 1) & (elements.length - 1),此時tail=1,tail-1=0,二進制表示為0000,elements.length - 1=16-1=15,用二進制表示為1111,0000&1111=0,則代表尾指針的最后一個元素存儲的內容在尾指針的前一個坐標,此時獲取數組下標為0的值,result=1,將 elements[t]設置為null,此時數組為:

image-20210613214415131.png
接着將tail=t,t=0,將尾指針調整到0的位置,此時數組內容:

image-20210613214513031.png

其他用法

在源碼中還看到了removeFirst和removeLast其實內部調用的就是pollFist和pollLast方法,以及存在棧功能的pop方法和push方法,其實內部調用的就是addFist和pollFist的操作,這里就不在進行一一講解了,主要方法都在上面講述了。

總結

  1. ArrayDeque是一個雙向隊列,線程非安全。

  2. ArrayDeque是基於數組來進行實現的。

  3. ArrayDeque的數組長度是2的冪次方。

  4. 指針下一個和上一個表示方式:

    • 頭指針的前一個節點定位坐標:(head-1)&(elements.length - 1),下一個節點位置:(head+1)&(elements.length - 1)
    • 尾指針的前一個節點定位坐標:(tail-1)&(elements.length - 1),下一個節點位置:(tail+1)&(elements.length - 1)
    • 總結起來就是前一個節點位置:(i-1)&(elements.length - 1),下一個位置:(i+1)&(elements.length - 1)

喜歡的朋友可以關注我的微信公眾號BattleHeart,不定時推送文章


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM