ArrayDeque(JDK雙端佇列)原始碼深度剖析
前言
在本篇文章當中主要跟大家介紹JDK
給我們提供的一種用陣列實現的雙端佇列,在之前的文章LinkedList原始碼剖析當中我們已經介紹了一種雙端佇列,不過與ArrayDeque
不同的是,LinkedList
的雙端佇列使用雙向連結串列實現的。
雙端佇列整體分析
我們通常所談論到的佇列都是一端進一端出,而雙端佇列的兩端則都是可進可出。下面是雙端佇列的幾個操作:
-
資料從雙端佇列左側進入。
-
資料從雙端佇列右側進入。
- 資料從雙端佇列左側彈出。
- 資料從雙端佇列右側彈出。
而在ArrayDeque
當中也給我們提供了對應的方法去實現,比如下面這個例子就是上圖對應的程式碼操作:
public void test() {
ArrayDeque<Integer> deque = new ArrayDeque<>();
deque.addLast(100);
System.out.println(deque);
deque.addFirst(55);
System.out.println(deque);
deque.addLast(-55);
System.out.println(deque);
deque.removeFirst();
System.out.println(deque);
deque.removeLast();
System.out.println(deque);
}
// 輸出結果
[100]
[55, 100]
[55, 100, -55]
[100, -55]
[100]
陣列實現ArrayDeque(雙端佇列)的原理
ArrayDeque
底層是使用陣列實現的,而且陣列的長度必須是2
的整數次冪,這麼操作的原因是為了後面位運算好操作。在ArrayDeque
當中有兩個整形變數head
和tail
,分別指向右側的第一個進入佇列的資料和左側第一個進行佇列的資料,整個記憶體佈局如下圖所示:
其中tail
指的位置沒有資料,head
指的位置存在資料。
- 當我們需要從左往右增加資料時(入隊),記憶體當中資料變化情況如下:
- 當我們需要從右往做左增加資料時(入隊),記憶體當中資料變化情況如下:
- 當我們需要從右往左刪除資料時(出隊),記憶體當中資料變化情況如下:
- 當我們需要從左往右刪除資料時(出隊),記憶體當中資料變化情況如下:
底層資料遍歷順序和邏輯順序
上面主要談論到的陣列在記憶體當中的佈局,但是他是具體的物理儲存資料的順序,這個順序和我們的邏輯上的順序是不一樣的,根據上面的插入順序,我們可以畫出下面的圖,大家可以仔細分析一下這個圖的順序問題。
上圖當中佇列左側的如隊順序是0, 1, 2, 3,右側入隊的順序為15, 14, 13, 12, 11, 10, 9, 8,因此在邏輯上我們的佇列當中的資料佈局如下圖所示:
根據前面一小節談到的輸入在入隊的時候陣列當中資料的變化我們可以知道,資料在陣列當中的佈局為:
ArrayDeque類關鍵欄位分析
// 底層用於儲存具體資料的陣列
transient Object[] elements;
// 這就是前面談到的 head
transient int head;
// 與上文談到的 tail 含義一樣
transient int tail;
// MIN_INITIAL_CAPACITY 表示陣列 elements 的最短長度
private static final int MIN_INITIAL_CAPACITY = 8;
以上就是ArrayDeque
當中的最主要的欄位,其含義還是比較容易理解的!
ArrayDeque建構函式分析
- 預設建構函式,陣列預設申請的長度為
16
。
public ArrayDeque() {
elements = new Object[16];
}
- 指定陣列長度的初始化長度,下面列出了改建構函式涉及的所有函式。
public ArrayDeque(int numElements) {
allocateElements(numElements);
}
private void allocateElements(int numElements) {
elements = new Object[calculateSize(numElements)];
}
private static int calculateSize(int numElements) {
int initialCapacity = MIN_INITIAL_CAPACITY;
// Find the best power of two to hold elements.
// Tests "<=" because arrays aren't kept full.
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
}
return initialCapacity;
}
上面的最難理解的就是函式calculateSize
了,他的主要作用是如果使用者輸入的長度小於MIN_INITIAL_CAPACITY
時,返回MIN_INITIAL_CAPACITY
。否則返回比initialCapacity
大的第一個是2
的整數冪的整數,比如說如果輸入的是9
返回的16
,輸入4
返回8
。
calculateSize
的程式碼還是很難理解的,讓我們一點一點的來分析。首先我們使用一個2
的整數次冪的數進行上面移位操作
的操作!
從上圖當中我們會發現,我們在一個數的二進位制數的32位放一個1
,經過移位之後最終32
位的位元數字全部變成了1
。根據上面數字變化的規律我們可以發現,任何一個位元經過上面移位的變化,這個位元後面的31
個位元位都會變成1
,像下圖那樣:
因此上述的移位操作的結果只取決於最高一位的位元值為1
,移位操作後它後面的所有位元位的值全為1
,而在上面函式的最後,我們返回的結果就是上面移位之後的結果 +1
。又因為移位之後最高位的1
到最低位的1
之間的位元值全為1
,當我們+1
之後他會不斷的進位,最終只有一個位元位置是1
,因此它是2
的整數倍。
經過上述過程分析,我們就可以立即函式calculateSize
了。
ArrayDeque關鍵函式分析
addLast函式分析
// tail 的初始值為 0
public void addLast(E e) {
if (e == null)
throw new NullPointerException();
elements[tail] = e;
// 這裡進行的 & 位運算 相當於取餘數操作
// (tail + 1) & (elements.length - 1) == (tail + 1) % elements.length
// 這個操作主要是用於判斷陣列是否滿了,如果滿了則需要擴容
// 同時這個操作將 tail + 1,即 tail = tail + 1
if ( (tail = (tail + 1) & (elements.length - 1)) == head)
doubleCapacity();
}
程式碼(tail + 1) & (elements.length - 1) == (tail + 1) % elements.length
成立的原因是任意一個數\(a\)對\(2^n\)進行取餘數操作和\(a\)跟\(2^n - 1\)進行&
運算的結果相等,即:
從上面的程式碼來看下標為tail
的位置是沒有資料的,是一個空位置。
addFirst函式分析
// head 的初始值為 0
public void addFirst(E e) {
if (e == null)
throw new NullPointerException();
// 若此時陣列長度elements.length = 16
// 那麼下面程式碼執行過後 head = 15
// 下面程式碼的操作結果和下面兩行程式碼含義一致
// elements[(head - 1 + elements.length) % elements.length] = e
// head = (head - 1 + elements.length) % elements.length
elements[head = (head - 1) & (elements.length - 1)] = e;
if (head == tail)
doubleCapacity();
}
上面程式碼操作結果和上文當中我們提到的,在佇列當中從右向左加入資料一樣。從上面的程式碼看,我們可以發現下標為head
的位置是存在資料的。
doubleCapacity函式分析
private void doubleCapacity() {
assert head == tail;
int p = head;
int n = elements.length;
int r = n - p; // number of elements to the right of p
int newCapacity = n << 1;
if (newCapacity < 0)
throw new IllegalStateException("Sorry, deque too big");
Object[] a = new Object[newCapacity];
// arraycopy(Object src, int srcPos,
Object dest, int destPos,
int length)
// 上面是函式 System.arraycopy 的函式引數列表
// 大家可以參考上面理解下面的拷貝程式碼
System.arraycopy(elements, p, a, 0, r);
System.arraycopy(elements, 0, a, r, p);
elements = a;
head = 0;
tail = n;
}
上面的程式碼還是比較簡單的,這裡給大家一個圖示,大家就更加容易理解了:
擴容之後將原來陣列的資料拷貝到了新陣列當中,雖然資料在舊陣列和新陣列當中的順序發生變化了,但是他們的相對順序卻沒有發生變化,他們的邏輯順序也是一樣的,這裡的邏輯可能有點繞,大家在這裡可以好好思考一下。
pollLast和pollFirst函式分析
這兩個函式的程式碼就比較簡單了,大家可以根據前文所談到的內容和圖示去理解下面的程式碼。
public E pollLast() {
// 計算出待刪除的資料的下標
int t = (tail - 1) & (elements.length - 1);
@SuppressWarnings("unchecked")
E result = (E) elements[t];
if (result == null)
return null;
// 將需要刪除的資料的下標值設定為 null 這樣這塊記憶體就
// 可以被回收了
elements[t] = null;
tail = t;
return result;
}
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;
}
總結
在本篇文章當中,主要跟大家分享了ArrayDeque
的設計原理,和他的底層實現過程。ArrayDeque
底層陣列當中的資料順序和佇列的邏輯順序這部分可能比較抽象,大家可以根據圖示好好體會一下!!!
以上就是本篇文章的所有內容了,希望大家有所收穫,我是LeHung,我們下期再見!!!都看到這裡了,給孩子一個贊(start)吧,免費的哦!!!
更多精彩內容合集可訪問專案:https://github.com/Chang-LeHung/CSCore
關注公眾號:一無是處的研究僧,瞭解更多計算機(Java、Python、計算機系統基礎、演算法與資料結構)知識。