文章已經收錄在 Github.com/niumoo/JavaNotes ,更有 Java 程式設計師所需要掌握的核心知識,歡迎Star和指教。
歡迎關注我的公眾號,文章每週更新。
前言
說真的,在 Java 使用最多的集合類中,List 絕對佔有一席之地的,它和 Map 一樣適用於很多場景,非常方便我們的日常開發,畢竟儲存一個列表的需求隨處可見。儘管如此,還是有很多同學沒有弄明白 List 中 ArrayList 和 LinkedList 有什麼區別,這簡直太遺憾了,這兩者其實都是資料結構中的基礎內容,這篇文章會從基礎概念開始,分析兩者在 Java 中的具體原始碼實現,尋找兩者的不同之處,最後思考它們使用時的注意事項。
這篇文章會包含以下內容。
- 介紹線性表的概念,詳細介紹線性表中陣列和連結串列的資料結構。
- 進行 ArrayList 的原始碼分析,比如儲存結構、擴容機制、資料新增、資料獲取等。
- 進行 LinkedList 的原始碼分析,比如它的儲存結構、資料插入、資料查詢、資料刪除和 LinkedList 作為佇列的使用方式等。
- 進行 ArrayList 和 LinkedList 的總結。
線性表
要研究 ArrayList 和 LinkedList ,首先要弄明白什麼是線性表,這裡引用百度百科的一段文字。
線性表是最基本、最簡單、也是最常用的一種資料結構。線性表(linear list)是資料結構的一種,一個線性表是n個具有相同特性的資料元素的有限序列。
你肯定看到了,線性表在資料結構中是一種最基本、最簡單、最常用的資料結構。它將資料一個接一個的排成一條線(可能邏輯上),也因此線性表上的每個資料只有前後兩個方向,而在資料結構中,陣列、連結串列、棧、佇列都是線性表。你可以想象一下整整齊齊排隊的樣子。
看到這裡你可能有疑問了,有線性表,那麼肯定有非線性表嘍?沒錯。二叉樹和圖就是典型的非線性結構了。不要被這些花裡胡哨的圖嚇到,其實這篇文章非常簡單,希望同學耐心看完點個贊。
陣列
既然知道了什麼是線性表,那麼理解陣列也就很容易了,首先陣列是線性表的一種實現。陣列是由相同型別元素組成的一種資料結構,陣列需要分配一段連續的記憶體用來儲存。注意關鍵詞,相同型別,連續記憶體,像這樣。
不好意思放錯圖了,像這樣。
上面的圖可以很直觀的體現陣列的儲存結構,因為陣列記憶體地址連續,元素型別固定,所有具有快速查詢某個位置的元素的特性;同時也因為陣列需要一段連續記憶體,所以長度在初始化長度已經固定,且不能更改。Java 中的 ArrayList 本質上就是一個陣列的封裝。
連結串列
連結串列也是一種線性表,和陣列不同的是連結串列不需要連續的記憶體進行資料儲存,而是在每個節點裡同時儲存下一個節點的指標,又要注意關鍵詞了,每個節點都有一個指標指向下一個節點。那麼這個連結串列應該是什麼樣子呢?看圖。
哦不,放錯圖了,是這樣。
上圖很好的展示了連結串列的儲存結構,圖中每個節點都有一個指標指向下一個節點位置,這種我們稱為單向連結串列;還有一種連結串列在每個節點上還有一個指標指向上一個節點,這種連結串列我們稱為雙向連結串列。圖我就不畫了,像下面這樣。
可以發現連結串列不必連續記憶體儲存了,因為連結串列是通過節點指標進行下一個或者上一個節點的,只要找到頭節點,就可以以此找到後面一串的節點。不過也因此,連結串列在查詢或者訪問某個位置的節點時,需要O(n)的時間複雜度。但是插入資料時可以達到O(1)的複雜度,因為只需要修改節點指標指向。
ArratList
上面介紹了線性表的概念,並舉出了兩個線性表的實際實現例子,既陣列和連結串列。在 Java 的集合類 ArrayList 裡,實際上使用的就是陣列儲存結構,ArrayList 對 Array 進行了封裝,並增加了方便的插入、獲取、擴容等操作。因為 ArrayList 的底層是陣列,所以存取非常迅速,但是增刪時,因為要移動後面的元素位置,所以增刪效率相對較低。那麼它具體是怎麼實現的呢?不妨深入原始碼一探究竟。
ArrayList 儲存結構
檢視 ArrayList 的原始碼可以看到它就是一個簡單的陣列,用來資料儲存。
/**
* The array buffer into which the elements of the ArrayList are stored.
* The capacity of the ArrayList is the length of this array buffer. Any
* empty ArrayList with elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA
* will be expanded to DEFAULT_CAPACITY when the first element is added.
*/
transient Object[] elementData; // non-private to simplify nested class access
/**
* Shared empty array instance used for default sized empty instances. We
* distinguish this from EMPTY_ELEMENTDATA to know how much to inflate when
* first element is added.
*/
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
/**
* Default initial capacity.
*/
private static final int DEFAULT_CAPACITY = 10;
通過上面的註釋瞭解到,ArrayList 無參構造時是會共享一個長度為 0 的陣列 DEFAULTCAPACITY_EMPTY_ELEMENTDATA. 只有當第一個元素新增時才會第一次擴容,這樣也防止了建立物件時更多的記憶體浪費。
ArrayList 擴容機制
我們都知道陣列的大小一但確定是不能改變的,那麼 ArrayList 明顯可以不斷的新增元素,它的底層又是陣列,它是怎麼實現的呢?從上面的 ArrayList 儲存結構以及註釋中瞭解到,ArrayList 在初始化時,是共享一個長度為 0 的陣列的,當第一個元素新增進來時會進行第一次擴容,我們可以想像出 ArrayList 每當空間不夠使用時就會進行一次擴容,那麼擴容的機制是什麼樣子的呢?
依舊從原始碼開始,追蹤 add() 方法的內部實現。
/**
* Appends the specified element to the end of this list.
*
* @param e element to be appended to this list
* @return <tt>true</tt> (as specified by {@link Collection#add})
*/
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
// 開始檢查當前插入位置時陣列容量是否足夠
private void ensureCapacityInternal(int minCapacity) {
// ArrayList 是否未初始化,未初始化是則初始化 ArrayList ,容量給 10.
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
// 比較插入 index 是否大於當前陣列長度,大於就 grow 進行擴容
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
/**
* Increases the capacity to ensure that it can hold at least the
* number of elements specified by the minimum capacity argument.
*
* @param minCapacity the desired minimum capacity
*/
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
// 擴容規則是當前容量 + 當前容量右移1位。也就是1.5倍。
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
// 是否大於 Int 最大值,也就是容量最大值
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
// 拷貝元素到擴充後的新的 ArrayList
elementData = Arrays.copyOf(elementData, newCapacity);
}
通過原始碼發現擴容邏輯還是比較簡單的,整理下具體的擴容流程如下:
-
開始檢查當前插入位置時陣列容量是否足夠
-
ArrayList 是否未初始化,未初始化是則初始化 ArrayList ,容量給 10.
-
判斷當前要插入的下標是否大於容量
- 不大於,插入新增元素,新增流程完畢。
-
如果所需的容量大於當前容量,開始擴充。
-
擴容規則是當前容量 + 當前容量右移1位。也就是1.5倍。
int newCapacity = oldCapacity + (oldCapacity >> 1);
-
如果擴充之後還是小於需要的最小容量,則把所需最小容量作為容量。
-
如果容量大於預設最大容量,則使用 最大值 Integer 作為容量。
-
拷貝老陣列元素到擴充後的新陣列
-
-
插入新增元素,新增流程完畢。
ArrayList 資料新增
上面分析擴容時候已經看到了新增一個元素的具體邏輯,因為底層是陣列,所以直接指定下標賦值即可,非常簡單。
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e; // 直接賦值
return true;
}
但是還有一種新增資料得情況,就是新增時指定了要加入的下標位置。這時邏輯有什麼不同呢?
/**
* Inserts the specified element at the specified position in this
* list. Shifts the element currently at that position (if any) and
* any subsequent elements to the right (adds one to their indices).
*
* @param index index at which the specified element is to be inserted
* @param element element to be inserted
* @throws IndexOutOfBoundsException {@inheritDoc}
*/
public void add(int index, E element) {
rangeCheckForAdd(index);
ensureCapacityInternal(size + 1); // Increments modCount!!
// 指定下標開始所有元素後移一位
System.arraycopy(elementData, index, elementData, index + 1,size - index);
elementData[index] = element;
size++;
}
可以發現這種新增多了關鍵的一行,它的作用是把從要插入的座標開始的元素都向後移動一位,這樣才能給指定下標騰出空間,才可以放入新增的元素。
比如你要在下標為 3 的位置新增資料100,那麼下標為3開始的所有元素都需要後移一位。
由此也可以看到 ArrayList 的一個缺點,隨機插入新資料時效率不高。
ArrayList 資料獲取
資料下標獲取元素值,一步到位,不必多言。
public E get(int index) {
rangeCheck(index);
return elementData(index);
}
E elementData(int index) {
return (E) elementData[index];
}
LinkedList
LinkedList 的底層就是一個連結串列線性結構了,連結串列除了要有一個節點物件外,根據單向連結串列和雙向連結串列的不同,還有一個或者兩個指標。那麼 LinkedList 是單連結串列還是雙向連結串列呢?
LinkedList 儲存結構
依舊深入 LinkedList 原始碼一探究竟,可以看到 LinkedList 無參構造裡沒有任何操作,不過我們通過檢視變數 first、last 可以發現它們就是儲存連結串列第一個和最後 一個的節點。
transient int size = 0;
/**
* Pointer to first node.
* Invariant: (first == null && last == null) ||
* (first.prev == null && first.item != null)
*/
transient Node<E> first;
/**
* Pointer to last node.
* Invariant: (first == null && last == null) ||
* (last.next == null && last.item != null)
*/
transient Node<E> last;
/**
* Constructs an empty list.
*/
public LinkedList() {
}
變數 first 和 last 都是 Node 型別,繼而檢視 Node 原始碼。
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
可以看到這就是一個典型的雙向連結串列結構,item 用來存放元素值;next 指向下一個 node 節點,prev 指向上一個 node 節點。
LinkedList 資料獲取
連結串列不像陣列是連續的記憶體地址,連結串列是通過next 和 prev 指向記錄連結路徑的,所以查詢指定位置的 node 只能遍歷查詢,檢視原始碼也是如此。
public E get(int index) {
checkElementIndex(index);
return node(index).item;
}
/**
* Returns the (non-null) Node at the specified element index.
*/
// 遍歷查詢 index 位置的節點資訊
Node<E> node(int index) {
// assert isElementIndex(index);
// 這裡判斷 index 是在當前連結串列的前半部分還是後半部分,然後決定是從
// first 向後查詢還是從 last 向前查詢。
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
查詢指定位置的 node 物件,這個部分要注意的是,查詢會首先判斷 index 是在當前連結串列的前半部分還是後半部分,然後決定是從 first 向後查詢還是從 last 向前查詢。這樣可以增加查詢速度。從這裡也可以看出連結串列在查詢指定位置元素時,效率不高。
LinkedList 資料新增
因為 LinkedList 是連結串列,所以 LinkedList 的新增也就是連結串列的資料新增了,這時候要根據要插入的位置的區分操作。
-
尾部插入
public boolean add(E e) { linkLast(e); return true; } void linkLast(E e) { final Node<E> l = last; // 新節點,prev 為當前尾部節點,e為元素值,next 為 null, final Node<E> newNode = new Node<>(l, e, null); last = newNode; if (l == null) first = newNode; else // 目前的尾部節點 next 指向新的節點 l.next = newNode; size++; modCount++; }
預設的 add 方式就是尾部新增了,尾部新增的邏輯很簡單,只需要建立一個新的節點,新節點的 prev 設定現有的末尾節點,現有的末尾 Node 指向新節點 Node,新節點的 next 設為 null 即可。
-
中間新增
下面是在指定位置新增元素,涉及到的原始碼部分。
public void add(int index, E element) { checkPositionIndex(index); if (index == size) // 如果位置就是當前連結串列尾部,直接尾插 linkLast(element); else // 獲取 index 位置的節點,插入新的元素 linkBefore(element, node(index)); } /** * Inserts element e before non-null Node succ. */ // 在指定節點處新增元素,修改指定元素的下一個節點為新增元素,新增元素的下一個節點是查詢到得 node 的next節點指向, // 新增元素的上一個節點為查詢到的 node 節點,查詢到的 node 節點的 next 指向 node 的 prev 修改為新 Node void linkBefore(E e, Node<E> succ) { // assert succ != null; final Node<E> pred = succ.prev; final Node<E> newNode = new Node<>(pred, e, succ); succ.prev = newNode; if (pred == null) first = newNode; else pred.next = newNode; size++; modCount++; }
可以看到指定位置插入元素主要分為兩個部分,第一個部分是查詢 node 節點部分,這部分就是上面介紹的 LinkedList 資料獲取部分,
第二個部分是在查詢到得 node 物件後插入元素。主要就是修改 node 的 next 指向為新節點,新節點的 prev 指向為查詢到的 node 節點,新節點的 next 指向為查詢到的 node 節點的 next 指向。查詢到的 node 節點的 next 指向的 node 節點的 prev 修改為新節點。
LinkedList 資料刪除
依舊檢視原始碼進行分析,原始碼中看到如果節點是頭結點或者尾節點,刪除比較簡單。我們主要看刪除中間一個節點時的操作
public E remove(int index) {
checkElementIndex(index);
return unlink(node(index));
}
/**
* Unlinks non-null node x.
*/
E unlink(Node<E> x) {
// assert x != null;
final E element = x.item;
final Node<E> next = x.next;
final Node<E> prev = x.prev;
if (prev == null) {
first = next;
} else {
prev.next = next;
x.prev = null;
}
if (next == null) {
last = prev;
} else {
next.prev = prev;
x.next = null;
}
x.item = null;
size--;
modCount++;
return element;
}
node(index) 方法依舊是二分查詢目標位置,然後進行刪除操作。比如要刪除的節點叫做 X,刪除操作主要是修改 X 節點的 prev 節點的 next 指向為 X 節點的 next 指向,修改 X 節點的 next 節點的 prev 指向為 X 節點的 prev 指向,最後把 X 節點的 prev 和 next 指向清空。如果理解起來有點費勁,可以看下面這個圖,可能會比較明白。
擴充套件
你以為 LinkedList 只是一個 List,其他它不僅實現了 List 介面,還實現了 Deque ,所以它表面上是一個 List,其實它還是一個佇列。
public class LinkedList<E> extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
體驗一下先進先出的佇列。
Queue<String> queue = new LinkedList<>();
queue.add("a");
queue.add("b");
queue.add("c");
queue.add("d");
System.out.println(queue.poll());
System.out.println(queue.poll());
System.out.println(queue.poll());
System.out.println(queue.poll());
// result:
// a
// b
// c
// d
同學可以思考一下這個佇列是怎麼實現的,其實很簡單對不對,就是先進先出嘛,poll
時刪除 first 節點不就完事了嘛。
總結
不管是 ArrayList 還是 LinkedList 都是開發中常用的集合類,這篇文章分析了兩者的底層實現,通過對底層實現的分析我們可以總結出兩者的主要優缺點。
- 遍歷,ArrayList 每次都是直接定位,LinkedList 通過 next 節點定位,不相上下。這裡要注意的是 LinkedList 只有使用迭代器的方式遍歷才會使用 next 節點。如果使用
get
,則因為遍歷查詢效率低下。 - 新增,ArrayList 可能會需要擴容,中間插入時,ArrayList 需要後移插入位置之後的所有元素。LinkedList 直接修改 node 的 prev, next 指向,LinkedList 勝出。
- 刪除,同 2.
- 隨機訪問指定位置,ArrayList 直接定位,LinkedList 從頭會尾開始查詢,陣列勝出。
綜上所述,ArrayList 適合儲存和訪問資料,LinkedList 則更適合資料的處理,希望你以後在使用時可以合理的選擇 List 結構。
最後的話
我把我所有文章都開源在 Github.com/niumoo/JavaNotes ,歡迎 Star 和完善,希望我們一起變得優秀。
文章有幫助可以點個「贊」或「分享」,都是支援,我都喜歡!
文章每週持續更新,要實時關注我更新的文章以及分享的乾貨,可以關注「 未讀程式碼 」公眾號或者我的部落格。