ArrayList和LinkedList如何實現的?

千鋒Python唐小強發表於2020-08-14

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

本文包含以下內容。

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

線性表

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

線性表是最基本、最簡單、也是最常用的一種資料結構。線性表(linear list)是資料結構的一種,一個線性表是n個具有相同特性的資料元素的有限序列。

你肯定看到了,線性表在資料結構中是一種 最基本、最簡單、最常用的資料結構。它將資料一個接一個的排成一條線(可能邏輯上),也因此線性表上的每個資料只有前後兩個方向,而在資料結構中, 陣列、連結串列、棧、佇列都是線性表。你可以想象一下整整齊齊排隊的樣子。

ArrayList和LinkedList如何實現的?

看到這裡你可能有疑問了,有線性表,那麼肯定有 非線性表嘍?沒錯。 二叉樹就是典型的非線性結構了。不要被這些花裡胡哨的圖嚇到,其實這篇文章非常簡單,希望同學耐心看完 點個贊

ArrayList和LinkedList如何實現的?

陣列

既然知道了什麼是線性表,那麼理解陣列也就很容易了,首先陣列是線性表的一種實現。陣列是由 相同型別元素組成的一種資料結構,陣列需要分配 一段連續的記憶體用來儲存。注意關鍵詞, 相同型別連續記憶體,像這樣。

ArrayList和LinkedList如何實現的?

不好意思放錯圖了,像這樣。

ArrayList和LinkedList如何實現的?

上面的圖可以很直觀的體現陣列的儲存結構,因為陣列記憶體地址連續,元素型別固定,所有具有 快速查詢某個位置的元素的特性;同時也因為陣列需要一段連續記憶體,所以長度在初始化 長度已經固定,且不能更改。Java 中的  ArrayList 本質上就是一個陣列的封裝。

連結串列

連結串列也是一種線性表,和陣列不同的是連結串列 不需要連續的記憶體進行資料儲存,而是在每個節點裡同時 儲存下一個節點的指標,又要注意關鍵詞了,每個節點都有一個指標指向下一個節點。那麼這個連結串列應該是什麼樣子呢?看圖。

ArrayList和LinkedList如何實現的?

哦不,放錯圖了,是這樣。

ArrayList和LinkedList如何實現的?

上圖很好的展示了連結串列的儲存結構,圖中每個節點都有一個指標指向下一個節點位置,這種我們稱為 單向連結串列;還有一種連結串列在每個節點上還有一個指標指向上一個節點,這種連結串列我們稱為 雙向連結串列。圖我就不畫了,像下面這樣。

ArrayList和LinkedList如何實現的?

可以發現連結串列不必連續記憶體儲存了,因為連結串列是透過節點指標進行下一個或者上一個節點的,只要找到頭節點,就可以以此找到後面一串的節點。不過也因此,連結串列在 查詢或者訪問某個位置的節點時,需要**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. 判斷當前要插入的下標是否大於容量不大於,插入新增元素,新增流程完畢。
  4. 如果所需的容量大於當前容量,開始擴充。擴容規則是當前容量 + 當前容量右移1位。也就是1.5倍。int newCapacity = oldCapacity + (oldCapacity >> 1);如果擴充之後還是小於需要的最小容量,則把所需最小容量作為容量。如果容量大於預設最大容量,則使用 最大值 Integer 作為容量。複製老陣列元素到擴充後的新陣列
  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和LinkedList如何實現的?

由此也可以看到 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 節點。

ArrayList和LinkedList如何實現的?

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

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 指向清空。如果理解起來有點費勁,可以看下面這個圖,可能會比較明白。

ArrayList和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 結構。



來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69923331/viewspace-2711554/,如需轉載,請註明出處,否則將追究法律責任。

相關文章