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

文章已經收錄在 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 和完善,希望我們一起變得優秀。

文章有幫助可以點個「」或「分享」,都是支持,我都喜歡!
文章每周持續更新,要實時關注我更新的文章以及分享的乾貨,可以關注「 未讀代碼 」公眾號或者我的博客