ArrayList 從原始碼角度剖析底層原理

SH的全棧筆記發表於2021-07-20

本篇文章已放到 Github github.com/sh-blog 倉庫中,裡面對我寫的所有文章都做了分類,更加方便閱讀。同時也會發布一些職位資訊,持續更新中,歡迎 Star

對於 ArrayList 來說,我們平常用的最多的方法應該就是 addremove 了,本文就主要通過這兩個基礎的方法入手,通過原始碼來看看 ArrayList 的底層原理。

add

預設新增元素

這個應該是平常用的最多的方法了,其用法如下。

接下來我們就來看看 add 方法的底層原始碼。

ensureCapacityInternal 作用為:保證在不停的往 ArrayList 插入資料時,陣列不會越界,並且實現自動擴容。

image-20210712202044397
image-20210712202044397

這裡的 minCapacaity 的值,實際上就是在呼叫完當前這次 add 操作之後,陣列中元素的數量

注意,這裡說當前這次 add 操作完成。舉個例子,呼叫 add 之前,ArrayList 中有3個元素,那麼此時這個 minCapacity 的值就為 4

此外,可以看到將函式 calculateCapacity 的返回值作為了 ensureExplicitCapacity 的輸入。

calculateCapacity 做了什麼,用大白話來說是,如果當前陣列是空的,則直接返回 陣列預設長度(10)minCapacity 的最大值,否則就直接返回 minCapacity

接下里是 ensureExplicitCapacity ,原始碼如下:

modCount 表示該 ArrayList 被更改過多少次,這裡的更改不只是新增,刪除也是一種更改。通過上面的瞭解我們知道,如果當前陣列內的元素個數是小於陣列長度的,則 minCapacity 的值一定是小於 elementData.length 的。

相反,如果陣列內的元素個數已經和陣列長度相等了,則 0>0 一定是 false ,此時就會呼叫 grow 函式來進行陣列擴容。

核心邏輯很簡單,新的陣列長度 = 舊陣列長度 + 舊陣列長度/2。

這裡的右移,就相當於直接除以2

所以通過這裡的原始碼我們驗證,ArrayList 的擴容是每次擴容 1.5 倍。但是這裡會有一個疑問,因為上文提到擴容時 minCapacity 的值和陣列長度應該是相等的,所以 新陣列長度 - minCapacity 應該永遠大於0才對,為什麼會有小於0的情況?

答案是 addAll

可以看到,addaddAll 底層都會呼叫 ensureCapacityInternaladd 是往陣列中添單個元素,而 addAll 則是往陣列中新增整個陣列。假設 addAll 我們傳進了一個很大的值,舉個例子,ArrayList 的預設陣列長度為 10 ,擴容一次之後長度為 15 ,假設我們傳入的陣列元素有 10 個,那麼即使擴容一次還是不足以放下所有的元素,因為 15 < 20

所以才會出現 newCapacity(擴容之後的陣列長度) < minCapacity(執行完當前操作之後的陣列內元素數量) 的情況,所以當這種情況出現之後,就會直接將 minCapacity 的值賦給 newCapacity

除此之外,還會有個極端的情況,假設 addAll 往裡面塞入了 Integer.MAX_VALUE 個元素呢?這就是 hugeCapacity 函式要處理的邏輯了。首先如果溢位了就直接丟擲 OOM 異常,其次會保證其容量不會超過 Integer.MAX_VALUE

最後是真正執行擴容的操作,呼叫了 java.util 包裡的 Arrays.copyOf 方法。從上圖可以看出,這個方法中傳入了兩個引數,分別是存放元素的陣列、新的陣列長度,舉個例子:

int[] elementData = {12345}; 
int[] newElementData = Arrays.copyOf(elementData, 10);
System.out.println(newElementData); // [1 2 3 4 5 0 0 0 0 0]

陣列擴容完成之後,就會將本次 add 的元素寫入 elementData 的末尾了,elementData[size++] = e

接下來我們用流程圖來總結一下 add 操作的整個核心流程。

指定新增元素的位置

瞭解完了 addaddAll,我們趁熱打鐵,來看看可以指定元素位置的 add ,其接受兩個引數,分別是:

  1. 新元素在陣列中的下標
  2. 新元素本身

這裡和最開始的 add 就有些不同了,之前的 add 方法會將元素放在陣列的末尾,而 add(int index, E element) 則會將元素插入到陣列中指定的位置,接下來從原始碼層面看看。

首先,由於這個方法允許使用者傳入陣列下標,所以首先要做的事情就是檢查傳入的陣列下標是否合法,如果不合法則會直接丟擲 IndexOutOfBoundsException 異常。

很簡單的判斷,下標 index 不能小於0,並且不能超過陣列中的元素個數,舉個例子:

像上圖這種情況,呼叫 add(int index, E element) 之前,陣列內有3個元素,即使底層陣列的長度為10 ,但是陣列下標如果傳入5,是會丟擲 IndexOutOfBoundsException 異常的。在上圖這種情況,index 的值最大隻能為3才不會報錯,因為 index(下標為3) > size(3個元素) 肯定不為 true

完成了校驗之後,還是會呼叫上面聊到過的 ensureCapacityInternal 方法,根據需要,來對底層的陣列進行擴容。然後呼叫 System.arraycopy 方法,這個方法比較關鍵,也比較難理解,所以我就簡單一句話把它的作用概括了——將制定下標後的元素全部往後移動一位。

System.arraycopy 接收 4 個引數,分別是:

  1. 原陣列
  2. 原陣列中的起始下標
  3. 目標陣列
  4. 目標陣列中的起始下標

光看引數,不結合例子,其實很難理解,我這裡舉個簡單的例子。

假設現在陣列裡有元素 [1 2 3] ,然後此時我呼叫方法 add(1, 4) ,表明我想要將元素 4 插入到陣列下標為 1 的位置,那麼此時 index 的值為1,size 的值為 3。

System.arraycopy 的方法就會變成 System.arraycopy(elementData, 1, elementData, 2, 2),也就是將 elementData 從下標 1 開始的兩個元素(也就是 2 和 3),拷貝到 elementData 的從下標 2 開始的地方。

可能還是有點繞,說人話就是執行完後,elementData 就變成了 [1 2 2 3],然後再對 elementData 進行賦值,將下標為 1 的元素改為本次需要 add 的元素。再說句人話就變成了 [1 4 2 3]

所以綜上來看,沒有什麼黑魔法,主要需要了解的就是兩個關鍵的函式,分別是 Arrays.copySystem.arraycopy 。我們需要把這兩個封裝好的函式的作用給記住。

  • Arrays.copy 陣列擴容
  • System.arraycopy 將陣列中某個下標之後元素全部往後移動一位

所以從這裡就可以看到,看原始碼的好處,主要有兩個方面。第一,我相信你在刷題的時候一定也遇到過需要將陣列的元素整個後移的 case,但是你可能並不知道可以使用 System.arraycopy ,就算你知道有這麼個函式可能就連引數都看不懂;第二,知道了 System.arraycopy ,但是覺得這些函式完全沒有應用場景。

remove

瞭解資料怎麼來,接下來我們來看一下資料是怎麼被移除的。首先我們來看最常用的兩種:

  • 按照下標移除
  • 根據元素移除

根據下標移除

首先是根據下標移除

這裡也會先檢查傳入的 index 是否合法,但是這裡的 indexadd 中呼叫的 rangeCheck 還有點區別。add 中的 rangeCheckForAdd 會判斷 index 是否為負數,而 remove 中的 rangeCheck 則只會判斷 index 是否 >= 陣列中的元素個數。

其實從函式的名稱就能看出,rangeCheckForAdd 是專門給 add 方法用的

那如果此時傳入的 index 真的是負數怎麼辦?其實是會丟擲 ArrayIndexOutOfBoundsException ,因為remove 方法上加了 Range 註解。

完成後,還是會更新 modCount 的值,這也驗證了我們上面提到的 modCount 代表的更改中也包含了刪除。

接下來會計算一個 numMoved ,代表需要被移動的元素數量。add 一個元素,對應的下標的元素都需要往後順移一位,remove 同理,刪除了某個位置的元素後,其後面對應的所有的元素都需要往前順移一位,就像這樣:

知道了需要移動多少個元素之後,我們的 System.arraycopy 就又可以登場了。完成了元素的移動之後,陣列的末尾必然會空出來一個元素,直接將其設定為 null 然後交給 GC 回收即可,最後把被移除的值返回。

根據值移除

舉個例子,根據值移除就長下面這樣這樣。

廢話不多說,直接看核心原始碼

image-20210715094144015
image-20210715094144015

完了,第二行就給整懵了,移除一個 null 是什麼鬼?還要迴圈去移除?

實際上,ArrayList 允許我們傳 null 值進去,再舉個例子。

看完這個例子,應該就能夠明白為什麼要做 o == null 的判斷了。如果傳入的是 null ,ArrayList 會對底層的陣列進行遍歷,並移除匹配到的第一個值為 null 的元素。

如果值不為 null 也是同理,如果陣列中有多個一樣的值,ArrayList 也會對其進行遍歷,並且移除匹配到的第一個值。通過原始碼可以看到,無論值是否為 null ,其都會呼叫真正的刪除元素方法 fastRemove ,乾的事情就跟 remove 做的幾乎一樣。

他們的唯一的區別在於,按照下標移除,會返回被移除的元素;按照值移除只會返回是否移除成功

總結

所以,看完 ArrayList 的部分原始碼之後,我們就可以知道,ArrayList 的底層資料結構是陣列。雖然對於使用者來說 ArrayList 是個動態的陣列,但是實際上底層是個定長陣列,只是在必要的時候,對底層的陣列進行擴容,每次擴容 1.5 倍。但是從原始碼也看出來了,擴容、刪除都是有代價的,特別是在極端的情況,會需要將大量的元素進行移位。

所以我們得出結論,ArrayList 如果有頻繁的隨機插入、頻繁的刪除操作是會對其效能造成很大影響的, 總結來說,ArrayList 適合用於讀多寫少的場景。

另一個很重要很重要的點,這裡提一下,ArrayList 不是執行緒安全的。多執行緒的情況下會出現資料不一致或者會丟擲 ConcurrentModificationException 異常,關於這塊後面有機會再聊吧

瞭解完如何向一個資料結構中存取移除資料,其實就已經能夠順理成章的理解跟其相關的很多事情了。

舉個例子,indexOf 方法用於返回指定元素在陣列中的下標,瞭解完 remove 中的遍歷匹配,或者說你甚至可以直接靠直覺就應該想到,indexOf 不就是個 for 迴圈匹配嗎?lastIndexOf 不就是個反向的 for 迴圈匹配嗎?所以在這裡再貼出原始碼除了讓文章篇幅更長之外,沒有任何意義。這個感興趣的話可以找原始碼看一看。

本篇文章已放到我的 Github github.com/sh-blog 中,歡迎 Star。微信搜尋關注【SH的全棧筆記】,回覆【佇列】獲取MQ學習資料,包含基礎概念解析和RocketMQ詳細的原始碼解析,持續更新中。

如果你覺得這篇文章對你有幫助,還麻煩點個贊關個注分個享留個言

- END -

相關文章