LinkedList 原始碼分析(JDK 1.8)
1.概述
LinkedList 是 Java 集合框架中一個重要的實現,其底層採用的雙向連結串列結構。和 ArrayList 一樣,LinkedList 也支援空值和重複值。由於 LinkedList 基於連結串列實現,儲存元素過程中,無需像 ArrayList 那樣進行擴容。但有得必有失,LinkedList 儲存元素的節點需要額外的空間儲存前驅和後繼的引用。另一方面,LinkedList 在連結串列頭部和尾部插入效率比較高,但在指定位置進行插入時,效率一般。原因是,在指定位置插入需要定位到該位置處的節點,此操作的時間複雜度為O(N)
。最後,LinkedList 是非執行緒安全的集合類,併發環境下,多個執行緒同時操作 LinkedList,會引發不可預知的錯誤。
以上是對 LinkedList 的簡單介紹,接下來,我將會對 LinkedList 常用操作展開分析,繼續往下看吧。
2.繼承體系
LinkedList 的繼承體系較為複雜,繼承自 AbstractSequentialList,同時又實現了 List 和 Deque 介面。繼承體系圖如下(刪除了部分實現的介面):
LinkedList 繼承自 AbstractSequentialList,AbstractSequentialList 又是什麼呢?從實現上,AbstractSequentialList 提供了一套基於順序訪問的介面。通過繼承此類,子類僅需實現部分程式碼即可擁有完整的一套訪問某種序列表(比如連結串列)的介面。深入原始碼,AbstractSequentialList 提供的方法基本上都是通過 ListIterator 實現的,比如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public E get(int index) { try { return listIterator(index).next(); } catch (NoSuchElementException exc) { throw new IndexOutOfBoundsException("Index: "+index); } } public void add(int index, E element) { try { listIterator(index).add(element); } catch (NoSuchElementException exc) { throw new IndexOutOfBoundsException("Index: "+index); } } // 留給子類實現 public abstract ListIterator<E> listIterator(int index); |
所以只要繼承類實現了 listIterator 方法,它不需要再額外實現什麼即可使用。對於隨機訪問集合類一般建議繼承 AbstractList 而不是 AbstractSequentialList。LinkedList 和其父類一樣,也是基於順序訪問。所以 LinkedList 繼承了 AbstractSequentialList,但 LinkedList 並沒有直接使用父類的方法,而是重新實現了一套的方法。
另外,LinkedList 還實現了 Deque (double ended queue),Deque 又繼承自 Queue 介面。這樣 LinkedList 就具備了佇列的功能。比如,我們可以這樣使用:
1 |
Queue<T> queue = new LinkedList<>(); |
除此之外,我們基於 LinkedList 還可以實現一些其他的資料結構,比如棧,以此來替換 Java 集合框架中的 Stack 類(該類實現的不好,《Java 程式設計思想》一書的作者也對此類進行了吐槽)。
關於 LinkedList 繼承體系先說到這,下面進入原始碼分析部分。
3.原始碼分析
3.1 查詢
LinkedList 底層基於連結串列結構,無法向 ArrayList 那樣隨機訪問指定位置的元素。LinkedList 查詢過程要稍麻煩一些,需要從連結串列頭結點(或尾節點)向後查詢,時間複雜度為 O(N)
。相關原始碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
public E get(int index) { checkElementIndex(index); return node(index).item; } Node<E> node(int index) { /* * 則從頭節點開始查詢,否則從尾節點查詢 * 查詢位置 index 如果小於節點數量的一半, */ if (index < (size >> 1)) { Node<E> x = first; // 迴圈向後查詢,直至 i == index 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; } } |
上面的程式碼比較簡單,主要是通過遍歷的方式定位目標位置的節點。獲取到節點後,取出節點儲存的值返回即可。這裡面有個小優化,即通過比較 index 與節點數量 size/2 的大小,決定從頭結點還是尾節點進行查詢。查詢操作的程式碼沒什麼複雜的地方,這裡先講到這裡。
3.2 遍歷
連結串列的遍歷過程也很簡單,和上面查詢過程類似,我們從頭節點往後遍歷就行了。但對於 LinkedList 的遍歷還是需要注意一些,不然可能會導致程式碼效率低下。通常情況下,我們會使用 foreach 遍歷 LinkedList,而 foreach 最終轉換成迭代器形式。所以分析 LinkedList 的遍歷的核心就是它的迭代器實現,相關程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
public ListIterator<E> listIterator(int index) { checkPositionIndex(index); return new ListItr(index); } private class ListItr implements ListIterator<E> { private Node<E> lastReturned; private Node<E> next; private int nextIndex; private int expectedModCount = modCount; /** 構造方法將 next 引用指向指定位置的節點 */ ListItr(int index) { // assert isPositionIndex(index); next = (index == size) ? null : node(index); nextIndex = index; } public boolean hasNext() { return nextIndex < size; } public E next() { checkForComodification(); if (!hasNext()) throw new NoSuchElementException(); lastReturned = next; next = next.next; // 呼叫 next 方法後,next 引用都會指向他的後繼節點 nextIndex++; return lastReturned.item; } // 省略部分方法 } |
上面的方法很簡單,大家應該都能很快看懂,這裡就不多說了。下面來說說遍歷 LinkedList 需要注意的一個點。
我們都知道 LinkedList 不擅長隨機位置訪問,如果大家用隨機訪問的方式遍歷 LinkedList,效率會很差。比如下面的程式碼:
1 2 3 4 5 6 7 8 |
List<Integet> list = new LinkedList<>(); list.add(1) list.add(2) ...... for (int i = 0; i < list.size(); i++) { Integet item = list.get(i); // do something } |
當連結串列中儲存的元素很多時,上面的遍歷方式對於效率來說就是災難。原因在於,通過上面的方式每獲取一個元素,LinkedList 都需要從頭節點(或尾節點)進行遍歷,效率不可謂不低。在我的電腦(MacBook Pro Early 2015, 2.7 GHz Intel Core i5)實測10萬級的資料量,耗時約7秒鐘。20萬級的資料量耗時達到了約34秒的時間。50萬級的資料量耗時約250秒。從測試結果上來看,上面的遍歷方式在大資料量情況下,效率很差。大家在日常開發中應該儘量避免這種用法。
3.3 插入
LinkedList 除了實現了 List 介面相關方法,還實現了 Deque 介面的很多方法,所以我們有很多種方式插入元素。但這裡,我只打算分析 List 介面中相關的插入方法,其他的方法大家自己看吧。LinkedList 插入元素的過程實際上就是連結串列鏈入節點的過程,學過資料結構的同學對此應該都很熟悉了。這裡簡單分析一下,先看原始碼吧:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
/** 在連結串列尾部插入元素 */ public boolean add(E e) { linkLast(e); return true; } /** 在連結串列指定位置插入元素 */ public void add(int index, E element) { checkPositionIndex(index); // 判斷 index 是不是連結串列尾部位置,如果是,直接將元素節點插入連結串列尾部即可 if (index == size) linkLast(element); else linkBefore(element, node(index)); } /** 將元素節點插入到連結串列尾部 */ void linkLast(E e) { final Node<E> l = last; // 建立節點,並指定節點前驅為連結串列尾節點 last,後繼引用為空 final Node<E> newNode = new Node<>(l, e, null); // 將 last 引用指向新節點 last = newNode; // 判斷尾節點是否為空,為空表示當前連結串列還沒有節點 if (l == null) first = newNode; else l.next = newNode; // 讓原尾節點後繼引用 next 指向新的尾節點 size++; modCount++; } /** 將元素節點插入到 succ 之前的位置 */ void linkBefore(E e, Node<E> succ) { // assert succ != null; final Node<E> pred = succ.prev; // 1. 初始化節點,並指明前驅和後繼節點 final Node<E> newNode = new Node<>(pred, e, succ); // 2. 將 succ 節點前驅引用 prev 指向新節點 succ.prev = newNode; // 判斷尾節點是否為空,為空表示當前連結串列還沒有節點 if (pred == null) first = newNode; else pred.next = newNode; // 3. succ 節點前驅的後繼引用指向新節點 size++; modCount++; } |
上面是插入過程的原始碼,我對原始碼進行了比較詳細的註釋,應該不難看懂。上面兩個 add 方法只是對操作連結串列的方法做了一層包裝,核心邏輯在 linkBefore 和 linkLast 中。這裡以 linkBefore 為例,它的邏輯流程如下:
- 建立新節點,並指明新節點的前驅和後繼
- 將 succ 的前驅引用指向新節點
- 如果 succ 的前驅不為空,則將 succ 前驅的後繼引用指向新節點
對應於下圖:
以上就是插入相關的原始碼分析,並不複雜,就不多說了。繼續往下分析。
3.4 刪除
如果大家看懂了上面的插入原始碼分析,那麼再看刪除操作實際上也很簡單了。刪除操作通過解除待刪除節點與前後節點的連結,即可完成任務。過程比較簡單,看原始碼吧:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
public boolean remove(Object o) { if (o == null) { for (Node<E> x = first; x != null; x = x.next) { if (x.item == null) { unlink(x); return true; } } } else { // 遍歷連結串列,找到要刪除的節點 for (Node<E> x = first; x != null; x = x.next) { if (o.equals(x.item)) { unlink(x); // 將節點從連結串列中移除 return true; } } } return false; } public E remove(int index) { checkElementIndex(index); // 通過 node 方法定位節點,並呼叫 unlink 將節點從連結串列中移除 return unlink(node(index)); } /** 將某個節點從連結串列中移除 */ 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; // prev 為空,表明刪除的是頭節點 if (prev == null) { first = next; } else { // 將 x 的前驅的後繼指向 x 的後繼 prev.next = next; // 將 x 的前驅引用置空,斷開與前驅的連結 x.prev = null; } // next 為空,表明刪除的是尾節點 if (next == null) { last = prev; } else { // 將 x 的後繼的前驅指向 x 的前驅 next.prev = prev; // 將 x 的後繼引用置空,斷開與後繼的連結 x.next = null; } // 將 item 置空,方便 GC 回收 x.item = null; size--; modCount++; return element; } |
和插入操作一樣,刪除操作方法也是對底層方法的一層保證,核心邏輯在底層 unlink 方法中。所以長驅直入,直接分析 unlink 方法吧。unlink 方法的邏輯如下(假設刪除的節點既不是頭節點,也不是尾節點):
- 將待刪除節點 x 的前驅的後繼指向 x 的後繼
- 將待刪除節點 x 的前驅引用置空,斷開與前驅的連結
- 將待刪除節點 x 的後繼的前驅指向 x 的前驅
- 將待刪除節點 x 的後繼引用置空,斷開與後繼的連結
對應下圖:
結合上圖,理解 LInkedList 刪除操作應該不難。好了,LinkedList 的刪除原始碼分析就講到這。
4.總結
通過上面的分析,大家對 LinkedList 的底層實現應該很清楚了。總體來看 LinkedList 的原始碼並不複雜,大家耐心看一下,一般都能看懂。同時,通過本文,向大家展現了使用 LinkedList 的一個坑,希望大家在開發中儘量避免。好了,本文到這裡就結束了,感謝閱讀!
from: http://www.tianxiaobo.com/2018/01/31/LinkedList-%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90-JDK-1-8/