Java集合系列——ArrayList源碼解讀

都2019年了還來談ArrayList,不知道是不是太老套了,但是從另一個角度來想,Java集合本就是一個老少咸宜的話題,那就先從最簡單的ArrayList開始談吧。

概述

簡單說下ArrayList的特點:

  1. ArrayList使用可變長數組來實現List接口的功能。
  2. 實現了所有列表有關的操作,允許添加所有類型的元素,連null都可以添加。
  3. 該類和Vector類相似,但是該類是線程不安全的,Vector是線程安全的。

接下來開始進行源碼解讀,包括成員變量、構造方法、其它常用方法。
聲明:本筆記裏的源碼來源於官網安裝文件“jdk-8u201-windows-x64”

成員變量(字段)

我們先來讀懂字段。

private static final long serialVersionUID = 8683452581122892189L;
說明:ArrayList實現了java.io.Serializable接口,這個字段是針對該接口提供的。

private static final int DEFAULT_CAPACITY = 10;
說明:嚴格來講它並不是ArrayList的默認(或者叫初始)長度,如果你在創建ArrayList的時候沒有指定初始長度(即使用空參數的構造方法),其實底層數組的初始化長度是0,那麼這個字段有什麼用呢?它其實是在底層數組擴容的時候用的,如果你的ArrayList是使用空參數的構造方法創建的,在對數組進行擴容的時候,“DEFAULT_CAPACITY”是最小的擴容後的容量,也就是你會得到一個長度爲10的數組。由於使用空參數的構造方法得到的底層數組的初始長度是0,那麼第一次添加元素的時候,肯定就要進行擴容,所以也可以這麼描述:對於使用空參數的構造方法創建的ArrayList,它在第一次擴容的時候,長度就會擴大到10。

private static final Object[] EMPTY_ELEMENTDATA = {};
說明:就是一個“Object[]”類型的數組,在某些時候用它來初始化底層數組,具體是哪些時候在下文使用的時候說明。

private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
說明:還是一個“Object[]”類型的數組,也是在某些時候用它來初始化底層數組,那麼它和上面的EMPTY_ELEMENTDATA有什麼區別呢?主要在於你的ArrayList是用哪個構造方法創建的,ArrayList使用“Object[]”類型的名稱爲elementData的字段存儲元素,這個其實就是底層數組。只有使用空參數的構造方法創建ArrayList時,elementData會被指向DEFAULTCAPACITY_EMPTY_ELEMENTDATA。使用其它的構造方法,elementData會被指向EMPTY_ELEMENTDATA或者指向其它的數組對象,反正不會是DEFAULTCAPACITY_EMPTY_ELEMENTDATA。

transient Object[] elementData; // non-private to simplify nested class access
說明:這個就是真正存儲元素的底層數組。

private int size;
說明:注意某些字段裏面夾帶的“capacity(容量)”單詞和這個“size”的區別,capacity指的是底層數組的長度,size指的是這個數組裏面存儲的元素的數量,總是有capacity>=size,只有數組剛好裝滿的時候,capacity==size。

private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
說明:這個字段的結尾單詞雖然用的是“size”,但其實表示的還是容量的意思。這個字段在底層數組擴容的時候使用,表示數組的最大容量。數組的容量本質上是個整數,整數的最大值是Integer.MAX_VALUE,那麼爲什麼這裏要減8呢?有些虛擬機會在數組裏面額外存儲一些東西,如果你申請容量超過Integer.MAX_VALUE-8的數組,可能回導致OOM。雖然源碼裏面有“MAX_ARRAY_SIZE”這個字段表示數組可申請的最大容量,但是其實你還是有可能申請到容量爲“Integer.MAX_VALUE”數組,這個下文會提到。

構造方法

ArrayList共有三個構造方法,接下來對構造方法的源碼進行解讀。

源碼解讀

空參數的構造方法:

public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

簡單的不能再簡單了,對於空參數的構造方法,就是直接將表示底層數組的elementData字段指向了DEFAULTCAPACITY_EMPTY_ELEMENTDATA對象數組。

指定初始容量的構造方法:

public ArrayList(int initialCapacity) {
    if (initialCapacity > 0) {
        this.elementData = new Object[initialCapacity];
    } else if (initialCapacity == 0) {
        this.elementData = EMPTY_ELEMENTDATA;
    } else {
        throw new IllegalArgumentException("Illegal Capacity: "+
                                           initialCapacity);
    }
}

主要是對傳進來的初始容量做了判斷:

  1. 如果大於0,那就創建一個容量等於初始容量的Object[],並將elementData指向它。
  2. 如果等於0,就是用內部定義的字段EMPTY_ELEMENTDATA;
  3. 如果小於0,拋異常。

使用傳入的集合創建ArrayList:

public ArrayList(Collection<? extends E> c) {
    elementData = c.toArray();
    if ((size = elementData.length) != 0) {
        // c.toArray might (incorrectly) not return Object[] (see 6260652)
        if (elementData.getClass() != Object[].class)
            elementData = Arrays.copyOf(elementData, size, Object[].class);
    } else {
        // replace with empty array.
        this.elementData = EMPTY_ELEMENTDATA;
    }
}
  1. 首先將傳入的Collection變成了數組形式,並將其傳給elementData字段。
  2. 接着是對數組裏面存儲的元素數量進行判斷,如果數組裏面存有元素,進行類型檢查,要保證elementData指向的是Object[]類型的數組。這裏說說爲什麼要進行類型檢查,Collection接口的toArray()方法聲明的返回值其實是Object[]類型的,但是由於Java的多態性質,其實返回的對象不一定真的是Object[]類型,所以需要進行類型檢查。
  3. 如果數組裏面沒有元素,就不理會傳進來的集合對象了,直接將elementData指向內部定義的EMPTY_ELEMENTDATA。

流程圖形式描述源碼

ArrayList構造方法

以上,是通過流程圖的形式描述構造方法執行過程。

源碼小結

這裏主要圍繞elementData最終指向的對象來描述:

  • 只有空參數的構造方法,elementData會指向內部定義的DEFAULTCAPACITY_EMPTY_ELEMENTDATA。
  • 對於非空參數的構造方法,只有創建容量爲0的ArrayList時(比如,向構造方法指定容量爲0,或者傳入的Collection裏的元素個數爲0),elementData會指向內部定義的EMPTY_ELEMENTDATA。
  • 除了以上的情況,elementData均會指向新的Object[]對象(反正不是內部定義的),並且容量根據實際情況確定。

爲什麼要圍繞elementData最終指向的對象來描述呢,因爲elementData最終指向的對象是誰,會影響底層數組擴容過程

public boolean add(E e)

解讀完了構造方法,來看我們用的最多的方法之一,添加元素的方法。

源碼解讀

接下來對添加元素的add(E e)進行解讀,我們把add(E e)及其內部調用的方法從頭到尾列出來,全部如下:

/**
* 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) {
    ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}

private static int calculateCapacity(Object[] elementData, int minCapacity) {
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        return Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    return minCapacity;
}

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;
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    // minCapacity is usually close to size, so this is a win:
    elementData = Arrays.copyOf(elementData, newCapacity);
}

private static int hugeCapacity(int minCapacity) {
    if (minCapacity < 0) // overflow
        throw new OutOfMemoryError();
    return (minCapacity > MAX_ARRAY_SIZE) ?
        Integer.MAX_VALUE :
        MAX_ARRAY_SIZE;
}

這若干個方法從上往下看即可:

  1. add(E e)其實就只幹了兩件事情:1、確保數組容量足夠裝入一個新的元素;2、將新的元素添加到最後一個元素後面。通過調用ensureCapacityInternal()來確保數組容量夠用,注意傳進去的數字是數組已使用空間+1。接着來看怎麼確保數組容量,也就是ensureCapacityInternal(int minCapacity)。
  2. ensureCapacityInternal(int minCapacity),翻譯過來就是確保內部容量,意思就是確保內部數組的容量夠用。“minCapacity”指的是“minimum capacity”即最小容量。結合add(E e)傳進來的參數,就是每一次add(E e),在添加元素之前,確保數組的容量能達到已使用空間+1。這裏面只有一行代碼,調用了兩個方法,這兩個方法決定了期望容量是多少,注意啊只是期望容量,還不算是最終容量。先來看看calculateCapacity()。
  3. int calculateCapacity(Object[] elementData, int minCapacity),光看方法簽名你就可以知道,它會結合內部數組和傳進來的期望容量,算出一個新的期望容量,接下來看計算邏輯。首先判斷elementData是否等於DEFAULTCAPACITY_EMPTY_ELEMENTDATA,前面講解構造方法的時候說過,只有使用空參數的構造方法創建ArrayList,elementData纔會等於DEFAULTCAPACITY_EMPTY_ELEMENTDATA。那這個判斷其實就是在問ArrayList是否是使用空參數的構造方法創建的,如果是,返回Math.max(DEFAULT_CAPACITY, minCapacity)。由於DEFAULT_CAPACITY是個常量,minCapacity是個變量,這個數學運算的含義其實是不論minCapacity是多少,返回值要大於等於DEFAULT_CAPACITY。結合空參數構造方法這個前提,calculateCapacity()的計算邏輯就是:對於空參數的構造方法創建的ArrayList,計算出的期望容量最小要是DEFAULT_CAPACITY,對於其它構造方法創建的ArrayList,直接將傳進來的參數返回。對於API的使用者而言,其結果是當你使用空參數的構造方法創建ArrayList,一旦添加元素,底層數組的容量就會至少擴充到DEFAULT_CAPACITY,因爲所有和添加元素有關的方法都會調用ensureCapacityInternal(),進而將整個流程走一遍。
  4. ensureExplicitCapacity(int minCapacity),看看它對計算出來的期望容量做了什麼,其實它什麼都沒做,它只是判斷一下數組現有容量是否滿足期望容量,如果滿足,那就不需要擴容了。只有不滿足的時候,纔要擴容。
  5. grow(int minCapacity),前面的一大堆方法都只是在計算期望容量,這個方法是真正的對底層數組進行擴容的地方,來看看擴容的邏輯。回顧一下JAVA基礎,elementData.length即數組的length屬性,它和我們一直說的容量是一個意思,它指的是數組的長度,而不是數組裏面存放的元素的個數。“newCapacity”其實就是“oldCapacity”的1.5倍,也就是以50%爲基礎來進行擴容,爲什麼說是以此爲基礎呢?因爲這個“newCapacity”還要再進行處理,所謂處理其實就是判斷“newCapacity”的下限和上限。對於下限,就是判斷擴容50%是否達到傳進來的期望容量,如果達不到,“newCapacity”直接等於傳進來的期望容量。這意味着擴容後的容量,既要達到原有容量的1.5倍,還要達到傳進來的期望容量。對於上限,就是判斷“newCapacity”或者“minCapacity”是否超出了“MAX_ARRAY_SIZE”或者是“Integer.MAX_VALUE”具體的上限處理下文說。上下限都處理完成之後,進行擴容。擴容的方法簽名是這樣的:“public static T[] copyOf(T[] original, int newLength)”,其功能是傳入源數組“original”,將該數組裏的元素複製到新的新數組並返回,新數組的容量爲“newLength”。如果“newLength”小於源數組的容量,會截斷源數組剩餘部分的數據。如果“newLength”大於源數組的容量,多出來的地方會用“null”填充。從這裏我們就知道,所謂的“可變長數組”,就是在原有數組容量不夠用的時候,創建一個新的、容量更大的數組,然後將原數組的元素複製過去,這就是ArrayList實現可變長數組的原理
  6. hugeCapacity(int minCapacity),翻譯過來是巨大容量的意思,倒不是說傳進來的參數很巨大,而是這個方法返回的數值,只有“Integer.MAX_VALUE”、“MAX_ARRAY_SIZE”兩個可能,這兩個值很巨大。我們先就這個方法自身的代碼來看看它的功能,然後結合傳進來的參數的可能的值來看這個方法對最終的實際容量造成什麼影響。
    1. 自身功能:第一行的判斷可能讓你困惑,爲什麼要判斷它是否小於0,而且註釋居然還寫着“溢出(overflow)”,拋出來的異常是OOM?這個要從JAVA裏面整型的表示來說,JAVA裏面,整型的最大值“Integer.MAX_VALUE”的定義是這樣的:“public static final int MAX_VALUE = 0x7fffffff”,這個值有什麼特點呢?JAVA裏面,用4字節存儲整型,同時JAVA裏面不支持無符號數(這點和C語言不同,C語言支持無符號數),也就是整型都是有符號的,符號位用整型的最高位表示,也就是最高位不表示數值,表示符號。“0x7fffffff”這個轉化成二進制數,是一個32位(也就是4字節)的二進制數,最高位爲0,表示正數,低31位爲1,所以“0x7fffffff”其實是用4字節存儲的、有符號位的、最大的正整數。當你傳入一個超出它的數,最高位會變成1,此時這個數會被JAVA當成負整數。所以對於一個本該是正整數的數值,如果它小於0,不是因爲它太小了,而是因爲它太大了,4字節的有符號整數存儲形式已經裝不下這個正整數了,反而會被當成負整數。所以這裏的小於0不是擔心它太小,而是擔心它溢出了。如果溢出了,就拋異常,如果沒有溢出,返回“Integer.MAX_VALUE”或者“MAX_ARRAY_SIZE”。
    2. 對實際容量的影響:這個要結合grow()在調用hugeCapacity()時“newCapacity”和“minCapacity”所處的情況來說。如果底層數組原有容量擴大50%後,仍然不滿足期望容量,而且期望容量還超出了“MAX_ARRAY_SIZE”,此時就是對期望容量的上限進行約束,將其約束在爲“Integer.MAX_VALUE”。如果底層數組原有容量擴大50%後,可以滿足期望容量,但是超出了“MAX_ARRAY_SIZE”,那就是使用期望容量“minCapacity”來對擴容50%這個擴容結果的上限進行約束(或者叫修正),如果期望容量沒有超出“MAX_ARRAY_SIZE”,那就將擴容結果約束爲“MAX_ARRAY_SIZE”,如果期望容量超出了“MAX_ARRAY_SIZE”,將擴容結果約束爲“Integer.MAX_VALUE”。

流程圖形式描述源碼

接下來通過流程圖的形式梳理add(E e)的過程:
ArrayList_add()方法

以上是通過流程圖的形式描述add(E e)的過程。

源碼小結

既分析了源碼,又梳理了流程圖,接下來可以小結一下向ArrayList添加一個元素的過程了:

  1. 最外層的add(E e)其實就只幹了兩件事情:1、確保底層數組容量夠用;2、將新元素添加到數組最後一個元素後面。
  2. “確保底層數組容量夠用”這個行爲,也只幹了兩件事情:1、計算期望容量;2、擴容。從調用ensureCapacityInternal()開始一直到調用grow()之前,都是在計算期望容量。期望容量的計算主要受兩個因素的影響,一是ArrayList最初是否由空參數的構造方法創建,二是最外層傳進來的期望容量。計算出期望容量的結果後,進入grow()開始擴容。
  3. 擴容的結果(即最終容量)主要受兩個因素的影響:一是底層數組原有容量,二是前面“計算期望容量”時計算出來的結果。擴容的結果是對底層數組原有容量擴大50%作爲基礎的,然後對比傳進來的期望容量,約束下限。然後再和“MAX_ARRAY_SIZE”對比,約束上限。得出最終結果後,進行擴容。

最終我們可以得出,向ArrayList添加一個元素的過程,核心點在於計算期望容量和擴容。

思考

add(E e)的執行流程,有兩個值得思考的地方:

  • 爲什麼在計算期望容量的時候要考慮ArrayList是否由空參數的構造方法創建?
  • 爲什麼在擴容的時候不是直接按照最初傳進來的(已用空間+1)進行擴容,而是按照原有容量的比例進行擴容?

兩者都是出於性能考慮,我們知道數組在進行增刪改查的時候,“改”和“查”是很高效的,但是“增”和“刪”都要以改動的位置爲起點,對後面的所有元素進行移動,這是低效的,但是又是不可避免的。如果說“刪”只要將後面的元素向前移動的話,“增”還可能多了一步操作,如果數組已經被裝滿了,就不僅僅是將插入位置及其後面的元素向後移動,而是將整個數組拋棄,元素複製到新的數組裏去。而“增”多出來的這一步操作,也影響效率,這裏提出來的兩個思考問題,都是爲了減少創建新數組的次數,進而提高效率。

我們先來看第二個問題,爲什麼要按照原有容量的比例進行擴容?我們取一個差異比較明顯的情況,假設現在數組的容量和已使用空間都是100,繼續添加100個元素。如果按照ensureCapacityInternal()最初傳進來的容量進行擴容,也就是每次都只增加一個元素,那僅僅是在擴容的時候要在新、舊數組之間複製元素的個數就達到100+101+…+198+199 = 14950次。如果按照50%的增長比例進行擴容,只需要在第1次添加、以及第51次添加元素的時候創建新數組,在新、舊數組之間複製元素的個數只有100+150 = 250次,接近60倍。這種差異,在數組元素越少的時候越不明顯,數組元素越多的時候越明顯,這意味着如果每次只擴容一個元素,隨着數組越來越大,效率越低。至於擴容的比例爲什麼選擇50%,這個筆者也沒想通。

接着我們回過去看第一個問題,爲什麼要考慮ArrayList是否由空參數的構造方法創建?對於空參數的構造方法創建的ArrayList,底層數組初始化容量爲0,即便是有擴容50%的機制存在,像1、2、3、4這種數字,基數太小,即使擴容50%,在早期添加元素的時候,還是會導致過於頻繁的創建新數組,效率還是低下。ArrayList連這個都替我們考慮好了,直接替我們將容量增長到DEFAULT_CAPACITY(值爲10),避免了原來基數太小導致的頻繁創建數組。此時你可能會問,如果我用有參數的構造方法比如ArrayList(int initialCapacity)創建時傳入0,那不也是效率低了?確實是,但是API的設計者很顯然只會爲默認行爲進行優化,至於客戶端程序員(API的使用者)已經選擇了自己想要的容量,就不屬於默認行爲,自然是不會進行優化了。筆者從另一個角度考慮,對API不熟悉的使用者,一般是不會使用有參數的方法的,都會盡量選擇無參數的方法,以支持API設計者的默認行爲。那麼既然選擇了有參數的方法,自然是多少了解了傳入參數會來帶的影響,也就不會胡亂傳入參數。

public boolean add(int index, E e)

接下來看add(E e)的重載方法:在指定位置添加元素。

源碼解讀

該方法及其內部調用的方法源碼如下:

/**
* 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++;
}

/**
* A version of rangeCheck used by add and addAll.
*/
private void rangeCheckForAdd(int index) {
    if (index > size || index < 0)
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}

由於ensureCapacityInternal()前面已經分析過了,那麼這個重載的add()其實就很簡單,接下來開始分析:

  1. rangeCheckForAdd(int index),首先就是進行參數檢查,檢查的內容也很簡單。注意ArrayList的下標和一般數組的下標計算方法是一樣的:從0開始,對於裝填了size個元素的數組,最後一個元素的下標是(size - 1),那size就是原來已經裝有的最後一個元素的後邊那個位置。從這個檢查範圍可以知道ArrayList允許添加元素的位置範圍,最前面的地方是數組頭部,即在第一個元素前面插入元素,最後面的地方是數組尾部,即向最後一個元素後面插入元素。
  2. ensureCapacityInternal(int minCapacity)已經分析過了,不再重複。
  3. System.arraycopy(Object src, int srcPos, Object dest, int destPos, int length),是System類提供的對數組進行復制的方法,第一個參數表示源數組,第二個參數表示要從源數組的哪個位置開始複製元素。第三個參數表示目標數組,第四個參數表示從目標數組的哪個位置開始複製元素,第五個參數表示要複製幾個元素。概括起來就是從src數組的下標srcPos開始,複製length個元素到dest數組,目的地從dest數組的下標destPos開始。這裏的調用其實就是將底層數組從下標index開始的所有元素後移一個位置。將下標index的位置空出來了,用來裝填新的元素。
  4. 接下來就是裝填新的元素,並且將表示已使用空間的size變量+1。

源碼小結

這個方法很簡潔了,就不畫流程圖了,直接進入小結吧。其實該方法就只幹了這幾件事:

  1. 參數檢查。
  2. 確保底層數組容量夠用。
  3. 自下標index開始的元素後移一個位置。
  4. 裝填新元素並修改已使用空間。

public boolean remove(Object o)

添加元素的方法分析完了,現在來分析刪除元素的方法。

源碼解讀

類似的,列出方法源碼及其內部調用的方法:

/**
* Removes the first occurrence of the specified element from this list,
* if it is present.  If the list does not contain the element, it is
* unchanged.  More formally, removes the element with the lowest index
* <tt>i</tt> such that
* <tt>(o==null&nbsp;?&nbsp;get(i)==null&nbsp;:&nbsp;o.equals(get(i)))</tt>
* (if such an element exists).  Returns <tt>true</tt> if this list
* contained the specified element (or equivalently, if this list
* changed as a result of the call).
*
* @param o element to be removed from this list, if present
* @return <tt>true</tt> if this list contained the specified element
*/
public boolean remove(Object o) {
    if (o == null) {
        for (int index = 0; index < size; index++)
            if (elementData[index] == null) {
                fastRemove(index);
                return true;
            }
    } else {
        for (int index = 0; index < size; index++)
            if (o.equals(elementData[index])) {
                fastRemove(index);
                return true;
            }
    }
    return false;
}

/*
* Private remove method that skips bounds checking and does not
* return the value removed.
*/
private void fastRemove(int index) {
    modCount++;
    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    elementData[--size] = null; // clear to let GC do its work
}

remove()用於從集合裏面移除指定元素,如果集合裏面存在重複元素,只移除從底層數組頭部開始向後遍歷時出現的第一個元素。接下來開始分析:

  1. 根據傳進來的參數是否爲null,將主要邏輯分成兩大塊。
    1. 對於傳入參數爲null的情況,移除從頭到尾找到的第一個null元素。
    2. 對於傳入不爲null的情況,移除從頭到尾找到的第一個相同(由equals()方法確定是否相同)的元素。
    3. 只有被移除的元素存在集合裏面,才返回true,否則返回false。
  2. fastRemove(int index),這是真正移除元素的方法。取這個名字,一方面是如該方法的註釋所提到的,它沒有邊界檢查,也不會返回被移除的元素,所以比較快(fast)。另外一個原因是筆者自己猜的,因爲ArrayList裏面有個簽名爲“public E remove(int index)”的方法,這兩方法已經沒法重載了,只能起一個新的名字了。我們來看具體是怎麼移除元素的。numMoved是要移動的元素個數,我們知道在數組裏刪除元素,就要將這個元素後面的元素全都前移一個位置。只有numMoved大於0,纔有必要移動元素(如果刪除的元素是數組最後一個元素,就不需要移動了)。System.arraycopy()前文已經說過了,不再重複,這裏傳進去的參數會將下標index之後的所有元素全都向前移動一位。最後,釋放數組最後一個元素。

源碼小結

移除指定元素的步驟可以總結如下:

  1. 找到元素。
  2. 計算要移動的元素個數,將目標元素後面的所有元素前移一個位置。
  3. 釋放數組最後一個元素。

public E remove(int index)

這是remove(Object o)的重載方法,用於移除指定位置的元素。

源碼解讀

源碼如下:

/**
* Removes the element at the specified position in this list.
* Shifts any subsequent elements to the left (subtracts one from their
* indices).
*
* @param index the index of the element to be removed
* @return the element that was removed from the list
* @throws IndexOutOfBoundsException {@inheritDoc}
*/
public E remove(int index) {
    rangeCheck(index);
    modCount++;
    E oldValue = elementData(index);
    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    elementData[--size] = null; // clear to let GC do its work
    return oldValue;
}

前面的remove(Object o)就已經挺簡單了,這個remove(int index)就更簡單了,因爲它連查找元素這一步都沒有了,內容和fastRemove()差不多,這裏就不做過多解釋了,直接總結流程:

  1. 參數檢查。
  2. 存下被移除的元素,用於返回給API的調用者。
  3. 計算要移動的元素個數然後移動元素。
  4. 釋放數組最後一個元素,返回被移除的元素。

public void trimToSize()

我們分析了add()、remove()的所有重載方法,不知道大家有沒注意到一個事情,添加元素的時候,有對底層數組進行擴容(如果原有容量不滿足),但是在移除元素的時候,好像沒有相應地縮減底層數組的容量啊。如果我一開始裝了很多元素,後來移除了,那就一直佔着大片內存不放了?首先ArrayList在移除元素的時候確實沒有類似添加元素那樣自動調整底層數組容量的機制,但是ArrayList是可以縮減底層數組容量的,但是需要API的使用者主動調用,這就是public void trimToSize()。

源碼解讀

我們來看縮減數組容量的源碼:

/**
 1. Trims the capacity of this <tt>ArrayList</tt> instance to be the
 2. list's current size.  An application can use this operation to minimize
 3. the storage of an <tt>ArrayList</tt> instance.
*/
public void trimToSize() {
    modCount++;
    if (size < elementData.length) {
        elementData = (size == 0)
          ? EMPTY_ELEMENTDATA
          : Arrays.copyOf(elementData, size);
    }
}
  1. 首先判斷已用空間是否小於數組容量,這是需要進行縮減的前提。
  2. 接着看已使用空間是否爲0,如果是,直接將elemenetData指向字段EMPTY_ELEMENTDATA,因爲沒有元素,就沒必要複製元素了。如果不爲0,調用Arrays.copyOf()來複制數組,並獲取新數組。
  3. T[] Arrays.copyOf(T[] original, int newLength)方法的功能是將傳入的數組元素複製到新的數組對象裏面並返回,newLength表示要複製多少個元素,如果newLength大於傳入的數組容量,新數組多餘的空間補null。如果newLength小於傳入的數組容量,進行截斷,拋棄原數組超出部分的元素。

public void ensureCapacity(int minCapacity)

我們前面在解讀添加元素的過程時說過,爲了防止性能問題,底層數組並不是每次添加1個單位的容量,而是按照增加50%的容量進行擴容。如果你是使用空參數的構造方法創建ArrayList,第一次擴容時會直接擴充到“DEFAULT_CAPACITY”的長度,以防止1、2、3、4之類的基數過小導致頻繁創建數組。而如果你在創建ArrayList時指定了容量,爲了避免性能問題,你應當傳入不要太小的數字。

現在遇到這樣一個問題,如果我們使用前面說的trimToSize()縮減了數組的容量,而又那麼湊巧,縮減後的數組容量就是1、2、3、4之類的小基數,如果此時添加元素,由於elementData沒有指向DEFAULTCAPACITY_EMTPY_ELEMENTDATA,那不就和使用帶參數的構造方法創建ArrayList,然後輸入比較小的數字的情況是一樣的:當你逐一添加元素,也會導致頻繁創建新數組的問題。那怎麼辦?

這裏就要用到我們接下來要解讀ensureCapacity()方法,它和trimToSize()是對應的,trimToSize()用來縮減容量,ensureCapacity()是用來手動擴容的。實際上,該方法是除了在構造方法裏傳入容量之外,僅有的外界可以直接指定擴容數值的方法。

源碼解讀

我們來看它的源碼:

/**
* Increases the capacity of this <tt>ArrayList</tt> instance, if
* necessary, to ensure that it can hold at least the number of elements
* specified by the minimum capacity argument.
*
* @param   minCapacity   the desired minimum capacity
*/
public void ensureCapacity(int minCapacity) {
    int minExpand = (elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
        // any size if not default element table
        ? 0
        // larger than default for default empty table. It's already
        // supposed to be at default size.
        : DEFAULT_CAPACITY;
    if (minCapacity > minExpand) {
        ensureExplicitCapacity(minCapacity);
    }
}

我們來看擴容過程:

  1. 首先判斷elementData是否指向DEFAULTCAPACITY_EMPTY_ELEMENTDATA,如果不是,則minExpand爲0,如果是,則minExpand等於DEFAULT_CAPACITY。實際上,在該版本的源碼裏(本文開頭有說明源碼版本),僅有使用空參數的構造方法創建ArrayList時,elementData會指向DEFAULTCAPACITY_EMPTY_ELEMENTDATA。使用其它構造方法創建ArrayList或者對ArrayList進行過任何擴容、縮減容量的操作,都會導致elementData不再指向DEFAULTCAPACITY_EMPTY_ELEMENTDATA。
  2. 如果傳入的期望容量大於minExpand,調用ensureExplicitCapacity()進行擴容,這意味着大多數情況下,只要你不要傳入0,都能進行擴容。ensureExplicitCapacity()前文已經解讀過了,不再重複。

思考

通過該方法,解決了前面提出的問題,如果你使用trimToSize()將底層數組縮減到了一個很小的數,此時,只要調用ensureCapacity()將它擴大一些,就可以優化添加元素時的性能。接着我們再來思考一個問題,爲什麼minExpand取值的時候會判斷elementData是否指向DEFAULTCAPACITY_EMPTY_ELEMENTDATA?我們來假設這樣一個情況,如果外界進行了如下調用:

List list = new ArrayList();
list.ensureCapacity(1);
list.add(e);
list.add(e);
list.add(e);
list.add(e);

如果在ensureCapacity()裏面沒有對於elementData是否指向DEFAULTCAPACITY_EMPTY_ELEMENTDATA的判斷,這個時候就相當於這樣子:

List list = new ArrayList(1);
list.add(e);
list.add(e);
list.add(e);
list.add(e);

這就是前文說過的使用有參數的構造方法創建ArrayList,但是傳入較小的數值,在擴容時性能不好。筆者認爲,判斷elementData是否指向DEFAULTCAPACITY_EMPTY_ELEMENTDATA,是ArrayList爲了防止外界的“拙劣”使用方式做的預防。

總結

如果想要從功能、接口層面比較好的理解ArrayList,應當要先理解好List,也就是數據結構裏面的“線性表”。實際上,ArrayList就是用數組來實現List的功能(對應的有LinkedList用鏈表來實現List的功能)。

筆者認爲ArrayList的方法可以分成三類來看,分別是構造方法、添加元素類方法(add()、ensureCapacity())、移除元素類方法(remove()、trimToSize())。在學習添加、移除元素的源碼時,最基本的應當學會如何使用數組來實現可變長列表,我們看到並沒有真正的可變長數組,只不過是在數組容量不合適的時候(不論太大還是太小),創建新數組並將原有元素複製過去。進一步的,應當學習ArrayList在擴容和縮減容量的時候對性能的考慮和維護。ArrayList至少在三個地方對擴容性能進行了維護:

  1. 對於使用空參數的構造方法創建的ArrayList,在第一次添加元素的時候,在calculateCapacity()裏面會返回默認的容量DEFAULT_CAPACITY。
  2. 在grow()裏面以擴充50%爲基礎進行擴容。
  3. 在ensureCapacity()裏面避免外界使用空參數的構造方法創建ArrayList後,使用ensureCapacity()傳入較小數值導致性能不好。

本文解讀了ArrayList所有的字段、構造方法以及部分常用的其它方法,對“增刪改查”裏面的“增”、“刪”進行了詳細解讀,同時也詳細解讀了底層數組的擴容和縮減過程。對於複雜方法的解讀,都採用了先分析源碼、後用流程圖梳理一遍思路的方式,應該說是比較清晰的。由於ArrayList裏面的“改”、“查”比較簡單,就省略了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章