[原始碼分析]ArrayList和LinkedList如何實現的?我看你還有機會!

未讀程式碼發表於2020-08-13

文章已經收錄在 Github.com/niumoo/JavaNotes ,更有 Java 程式設計師所需要掌握的核心知識,歡迎Star和指教。
歡迎關注我的公眾號,文章每週更新。

前言

說真的,在 Java 使用最多的集合類中,List 絕對佔有一席之地的,它和 Map 一樣適用於很多場景,非常方便我們的日常開發,畢竟儲存一個列表的需求隨處可見。儘管如此,還是有很多同學沒有弄明白 List 中 ArrayListLinkedList 有什麼區別,這簡直太遺憾了,這兩者其實都是資料結構中的基礎內容,這篇文章會從基礎概念開始,分析兩者在 Java 中的具體原始碼實現,尋找兩者的不同之處,最後思考它們使用時的注意事項。

這篇文章會包含以下內容。

  1. 介紹線性表的概念,詳細介紹線性表中陣列連結串列的資料結構。
  2. 進行 ArrayList 的原始碼分析,比如儲存結構、擴容機制、資料新增、資料獲取等。
  3. 進行 LinkedList 的原始碼分析,比如它的儲存結構、資料插入、資料查詢、資料刪除和 LinkedList 作為佇列的使用方式等。
  4. 進行 ArrayList 和 LinkedList 的總結。

線性表

要研究 ArrayListLinkedList ,首先要弄明白什麼是線性表,這裡引用百度百科的一段文字。

線性表是最基本、最簡單、也是最常用的一種資料結構。線性表(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);
}

通過原始碼發現擴容邏輯還是比較簡單的,整理下具體的擴容流程如下:

  1. 開始檢查當前插入位置時陣列容量是否足夠

  2. ArrayList 是否未初始化,未初始化是則初始化 ArrayList ,容量給 10.

  3. 判斷當前要插入的下標是否大於容量

    1. 不大於,插入新增元素,新增流程完畢。
  4. 如果所需的容量大於當前容量,開始擴充。

    1. 擴容規則是當前容量 + 當前容量右移1位。也就是1.5倍。

      int newCapacity = oldCapacity + (oldCapacity >> 1);

    2. 如果擴充之後還是小於需要的最小容量,則把所需最小容量作為容量。

    3. 如果容量大於預設最大容量,則使用 最大值 Integer 作為容量。

    4. 拷貝老陣列元素到擴充後的新陣列

  5. 插入新增元素,新增流程完畢。

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 的一個缺點,隨機插入新資料時效率不高

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 節點。

雙向連結串列(圖片來自 appcoda.com)

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 的新增也就是連結串列的資料新增了,這時候要根據要插入的位置的區分操作。

  1. 尾部插入

    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 即可。

  2. 中間新增

    下面是在指定位置新增元素,涉及到的原始碼部分。

    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 修改為新節點。

    LinkedLst 插入元素

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 刪除資料

擴充套件

你以為 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 都是開發中常用的集合類,這篇文章分析了兩者的底層實現,通過對底層實現的分析我們可以總結出兩者的主要優缺點。

  1. 遍歷,ArrayList 每次都是直接定位,LinkedList 通過 next 節點定位,不相上下。這裡要注意的是 LinkedList 只有使用迭代器的方式遍歷才會使用 next 節點。如果使用 get ,則因為遍歷查詢效率低下。
  2. 新增,ArrayList 可能會需要擴容,中間插入時,ArrayList 需要後移插入位置之後的所有元素。LinkedList 直接修改 node 的 prev, next 指向,LinkedList 勝出。
  3. 刪除,同 2.
  4. 隨機訪問指定位置,ArrayList 直接定位,LinkedList 從頭會尾開始查詢,陣列勝出

綜上所述,ArrayList 適合儲存和訪問資料,LinkedList 則更適合資料的處理,希望你以後在使用時可以合理的選擇 List 結構。

最後的話

我把我所有文章都開源在 Github.com/niumoo/JavaNotes ,歡迎 Star 和完善,希望我們一起變得優秀。

文章有幫助可以點個「」或「分享」,都是支援,我都喜歡!
文章每週持續更新,要實時關注我更新的文章以及分享的乾貨,可以關注「 未讀程式碼 」公眾號或者我的部落格

相關文章