Java集合——ArrayList

SachinLea發表於2019-03-04

1. ArrayList概述

  在平時的開發中,我們經常使用List,而其中最常用的就是ArrayList,ArrayList的底層實現是一個長度可變的陣列,因為其使用陣列結構,所以根據索引操作ArrayList的方法會非常快,時間複雜度為0(1),例如:get(int index),set(int index, E element);但是,新增和刪除元素相對較慢。
  另外,ArrayList不是同步的,也就是在多執行緒的情況下,如果使用ArrayList可能存在安全問題。如果需要在多執行緒下使用ArrayList,可以使用Vector,或者使用Collections工具類List list = Collections.synchronizedList(new ArrayList(...)),將ArrayList轉化為執行緒安全的;還可以使用併發容器CopyOnWriteArrayList

2. 原始碼閱讀

  ArrayList繼承了AbstractList類,實現了List,RandomAccess(支援隨機訪問),CloneableSerializable介面。

2.1 成員變數

// 初始化時預設容量
private static final int DEFAULT_CAPACITY = 10;

/**
 * Shared empty array instance used for empty instances.
 */
private static final Object[] EMPTY_ELEMENTDATA = {};

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

/**
 * 實際儲存結構
 * 使用transient修飾的變數,不會被序列化
 */
transient Object[] elementData; 

/**
 * ArrayList的元素個數
 */
private int size;
// 最大長度
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
複製程式碼
  • ArrayList為什麼定義兩個空陣列?   根據以上程式碼,ArrayList的主要成員變數包括初始容量的大小,底層陣列,元素個數;此外,還有兩個空陣列物件,為什麼定義了兩個空陣列物件呢?根據原始碼註釋的描述,是為了瞭解列表何時新增第一個元素。
      而在後邊的程式碼中發現,預設情況下,即不指定ArrayList初始容量的大小,使用的是DEFAULTCAPACITY_EMPTY_ELEMENTDATA物件;而如果指定了初始容量的大小為0,使用的是EMPTY_ELEMENTDATA物件。同時,陣列擴容時,會判斷當前elementData是否是DEFAULTCAPACITY_EMPTY_ELEMENTDATA物件,如果是會設定為初始容量10。

  • ArrayList序列化問題?
      另外一個問題,elementData使用了transient修飾,而我們知道transient修飾的變數是不會被序列化和反序列化的,但是ArrayList的元素又儲存在elementData中,那麼它是如何序列化和反序列化的呢?

      類通過實現java.io.Serializable介面可以啟用其序列化功能。要序列化一個物件,必須與一定的物件輸出/輸入流聯絡起來,通過物件輸出流將物件狀態儲存下來,再通過物件輸入流將物件狀態恢復。   在序列化和反序列化過程中需要特殊處理的類必須使用下列準確簽名來實現特殊方法: private void writeObject(java.io.ObjectOutputStream out) throws IOException; private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException;

      ArrayList中實現了以上兩個方法,因此能夠實現對elementData的序列化,那麼為什麼要這麼做呢?因為,如果不使用transient,直接序列化elementData陣列,而elementData陣列的長度,實際會比元素個數長,這就可能造成序列化或者反序列化之後陣列有空值的情況,因此,ArrayList自己實現了writeObject(java.io.ObjectOutputStream out)readObject(java.io.ObjectInputStream in)方法,保證序列化之後陣列長度和元素個數相同。

2.2 構造方法

// 指定初始化容量的的構造方法
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);
    }
}

// 
public ArrayList() {
   this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
// 包含一個Collection集合的構造方法
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;
   }
}
複製程式碼

2.3 主要方法

2.3.1 新增元素

// 在陣列元素末尾新增元素
public boolean add(E e) {
	// <1>, 擴容操作
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    // 陣列新增元素
    elementData[size++] = e;
    
    return true;
}
// 在指定索引位置新增元素(插入元素)
public void add(int index, E element) {
    // 驗證索引位置是否在size返回內。
    rangeCheckForAdd(index);
	
	// 擴容
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    // 將索引後邊的元素往後移一位
    System.arraycopy(elementData, index, elementData, index + 1,
                     size - index);
    // 指定位置新增元素
    elementData[index] = element;
    size++;
}
// 新增一個集合
public boolean addAll(Collection<? extends E> c) {
    Object[] a = c.toArray();
    int numNew = a.length;
    // 擴容
    ensureCapacityInternal(size + numNew);  // Increments modCount
    // 將新增的陣列元素,複製到 elementData 中
    System.arraycopy(a, 0, elementData, size, numNew);
    // 修改元素個數
    size += numNew;
    return numNew != 0;
}
// 在指定索引位置新增集合元素
public boolean addAll(int index, Collection<? extends E> c) {
    // 索引是否在size返回內
    rangeCheckForAdd(index);

    Object[] a = c.toArray();
    int numNew = a.length;
    // 擴容
    ensureCapacityInternal(size + numNew);  // Increments modCount

    int numMoved = size - index;
    // 移動索引後邊的元素
    if (numMoved > 0)
        System.arraycopy(elementData, index, elementData, index + numNew,
                         numMoved);

    System.arraycopy(a, 0, elementData, index, numNew);
    size += numNew;
    return numNew != 0;
}
複製程式碼

  通過以上程式碼,ArrayList可以新增單個元素,也可以直接新增一個Collection集合元素,同時還能在指定索引位置新增。在指定位置新增元素,涉及到索引位置後的元素後移,使用了System.arrycopy方法,該方法引數具體含義,下邊介紹。
  另外,由於陣列的長度是一定的,而ArrayList是可以一直新增元素的,因此,在新增元素過程中,都會判斷是否需要擴容,而擴容的具體方式使用了Arrays.copyOf(T[] original, int newLength),其底層實際也是使用了System.arraycopy方法。擴容過程,效率會較低,因此,如果知道ArrayList長度的情況下,可以在初始化時直接指定對應的長度,防止擴容降低效率。

  • System.arraycopy方法:
/**
* 將一個陣列複製到另一個陣列
* src 原陣列
* srcPos 元素組起始位置
* dest 目標陣列
* destPos 目標陣列複製的起始位置
* length 複製的長度
*/
public static native void arraycopy(Object src,  int  srcPos,
                                        Object dest, int destPos,
                                        int length);
複製程式碼

擴容操作,具體實現如下:

// 確定容量
private void ensureCapacityInternal(int minCapacity) {
	// <1> 指定初始化容量
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
    }
	
    ensureExplicitCapacity(minCapacity);
}

private void ensureExplicitCapacity(int minCapacity) {
	   //<2> 修改次數 + 1
	   modCount++;
	
	   // <3>, 判斷是否需要擴容
	   if (minCapacity - elementData.length > 0)
	       grow(minCapacity);
}
// 擴容操作
private void grow(int minCapacity) {
       // 陣列的原長度
       int oldCapacity = elementData.length;
       // 新容量 = 原容量的1.5倍
       int newCapacity = oldCapacity + (oldCapacity >> 1);
       // <4> 如果新容量(原來的1.5倍),小於傳入的容量,就擴容傳入的容量數;
       // 否則,擴容為原來的1.5倍
       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);
   }
複製程式碼
  • <1> 處,主要進行設定初始化容量,如果是DEFAULTCAPACITY_EMPTY_ELEMENTDATA,那麼,就設定minCapacity為初始化容量10,並進入擴容操作;否則,minCapacity為元素個數+1。
  • <2> 處,modCount++,即修改次數加一,該變數在迭代器的迭代過程中,會用到,即迭代過程中如果該引數發生變化,可能會報錯,具體可以檢視上一篇Java集合學習記錄——Iterator中next()方法中的解釋。
  • <3> 處,判斷擴容條件,當minCapacity - elementData.length > 0時,才擴容,ArrayList初始化容量為0,或者未指定初始化容量時,肯定會進行擴容;另外,如果elementData.length大於0,只有等到陣列元素滿,也就是size == elementData.length時,才會擴容。
  • <4> 處,傳入的容量minCapacity,在初始化未指定容量的情況下,會是10,此時肯定是直接擴容為10了;其他情況下需要對比minCapacity(可能是size+1,或者size+num)和原容量的1.5倍的大小,擴容後容量為兩者中的較大者。

2.3.2 移除元素

移除指定索引位置的元素:

// 移除指定索引的元素,並返回該元素
public E remove(int index) {
	// 判斷索引是否在size返回內
    rangeCheck(index);
	// 修改集合修改次數
    modCount++;
    E oldValue = elementData(index);
	// 將索引後邊的元素前移一位
    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    // 最後一位設定為null,元素個數-1;
    elementData[--size] = null; // clear to let GC do its work

    return oldValue;
}
複製程式碼

  根據以上程式碼,移除指定索引位置元素,首先驗證索引是否合法,即在size範圍內;然後,集合修改次數+1,同時,獲取該索引位置元素值,用於返回結果,並且將後邊的元素都往前移一位;最後,將陣列之前最後一位元素所在位置設定為null,方便GC處理。

移除指定元素:

// 移除指定的元素
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 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
}
複製程式碼

  根據以上程式碼,移除指定元素,首先遍歷集合,找到該元素所在的索引位置,然後,根據索引移除,和上邊的方法重複了。另外,在遍歷過程中,因為,ArrayList是允許含有null值的,因此,需要區分是否傳入的值是否為null,否則,對null使用equals方法,會拋異常。

移除集合元素:

// 刪除集合中公共元素
public boolean removeAll(Collection<?> c) {
    Objects.requireNonNull(c);
    return batchRemove(c, false);
}

// 保留集合中公共元素
public boolean retainAll(Collection<?> c) {
    Objects.requireNonNull(c);
    return batchRemove(c, true);
}
複製程式碼

  通過以上的程式碼,看到刪除集合中公共元素和保留集合中公共元素,都使用了同一個方法batchRemove,下邊來看其具體實現:

private boolean batchRemove(Collection<?> c, boolean complement) {
    final Object[] elementData = this.elementData;
    // <1> 定義兩個變數
    int r = 0, w = 0;
    boolean modified = false;
    try {
        for (; r < size; r++)
            // <2> 根據complement,判斷將包含的還是不包含的元素覆蓋到陣列前邊
            if (c.contains(elementData[r]) == complement)
                elementData[w++] = elementData[r];
    } finally {
        // Preserve behavioral compatibility with AbstractCollection,
        // even if c.contains() throws.
        // <3> 如果發生了異常,將r後邊的元素,複製到w後邊
        if (r != size) {
            System.arraycopy(elementData, r,
                             elementData, w,
                             size - r);
            w += size - r;
        }
        
        // <4> 移除多餘的元素。
        if (w != size) {
            // clear to let GC do its work
            for (int i = w; i < size; i++)
                elementData[i] = null;
            modCount += size - w;
            size = w;
            modified = true;
        }
    }
    return modified;
}
複製程式碼

  根據以上程式碼,batchRemove方法,通過一個boolean型別的引數complement,來確定是儲存公共元素,還是刪除公共元素。具體思路:

  • <1>處,定義兩個變數,具體使用在<2>的迴圈中
  • <2>處,使用<1>處定義的一個變數r,遍歷集合;然後,通過與引數對比,如果需要刪除相同的元素,就將不同元素放在集合的前邊,使用另一個變數w,記錄其位置;反之,如果需要保留相同元素,則將相同的元素放在陣列前邊,具體都是與傳入的boolean引數complement比對完成的。
    另外,不使用額外陣列,使用多個變數運算元組的方式,在演算法題目也非常常見,例如:刪除排序陣列中的重複元素,也是使用兩個變數指向索引位置,其實現和上邊的方法類似。
  • <3> 處,正常情況下,r是會等於size的,只有在發生異常時,才會滿足r != size的條件,在該情況下,需要將r後邊的元素保留下來,因此,直接將後邊的元素,複製到w索引位置後邊。
  • <4> 處,移除w索引後邊的元素,因為前邊的方法,只是將需要保留的元素複製到了陣列前邊的位置,w後邊還有元素,因此需要刪除掉。同時,這種情況只有w後邊還有元素才需要處理,因此需要在w==size的情況下(即保留原來集合中所有元素時),不需要處理,該方法也會返回false。

刪除全部元素:

public void clear() {
    modCount++;

    // clear to let GC do its work
    // 遍歷集合,均設定為null
    for (int i = 0; i < size; i++)
        elementData[i] = null;

    size = 0;
}
複製程式碼
2.3.2.1 Java8中remove方法

removeIf方法:

public boolean removeIf(Predicate<? super E> filter) {
    Objects.requireNonNull(filter);
    // figure out which elements are to be removed
    // any exception thrown from the filter predicate at this stage
    // will leave the collection unmodified
    int removeCount = 0;
    final BitSet removeSet = new BitSet(size);
    final int expectedModCount = modCount;
    final int size = this.size;
    for (int i=0; modCount == expectedModCount && i < size; i++) {
        @SuppressWarnings("unchecked")
        final E element = (E) elementData[i];
        if (filter.test(element)) {
            removeSet.set(i);
            removeCount++;
        }
    }
    if (modCount != expectedModCount) {
        throw new ConcurrentModificationException();
    }

    // shift surviving elements left over the spaces left by removed elements
    final boolean anyToRemove = removeCount > 0;
    if (anyToRemove) {
        final int newSize = size - removeCount;
        for (int i=0, j=0; (i < size) && (j < newSize); i++, j++) {
            i = removeSet.nextClearBit(i);
            elementData[j] = elementData[i];
        }
        for (int k=newSize; k < size; k++) {
            elementData[k] = null;  // Let gc do its work
        }
        this.size = newSize;
        if (modCount != expectedModCount) {
            throw new ConcurrentModificationException();
        }
        modCount++;
    }

    return anyToRemove;
}
複製程式碼

  該方法,傳入一個lambda表示式,然後,使用了點陣圖,將符合條件的元素索引記錄在點陣圖中,然後,再遍歷一次集合,保留不需要刪除的元素。

replaceAll修改集合中的元素:

public void replaceAll(UnaryOperator<E> operator) {
    Objects.requireNonNull(operator);
    final int expectedModCount = modCount;
    final int size = this.size;
    for (int i=0; modCount == expectedModCount && i < size; i++) {
        elementData[i] = operator.apply((E) elementData[i]);
    }
    if (modCount != expectedModCount) {
        throw new ConcurrentModificationException();
    }
    modCount++;
}
複製程式碼

  該方法也是傳入一個lambda表示式,然後,遍歷集合,對每個元素執行傳入的lambda函式操作。

2.3.2.2 List迴圈中移除元素

  關於移除方法,還有一個常見的問題,就是迴圈中刪除元素。我們可以看一下具體實現: 使用for迴圈:

public static void testRemove() {
	// list中元素為["1","2","2","3"]
   List<String> list = createArrayList();
    for (int i = 0; i < list.size(); i++) {
        if (list.get(i).equals("2")) {
            list.remove(i);
        }
    }
    System.out.println(Arrays.toString(list.toArray()));
}
複製程式碼

  根據前邊ArrayList中remove方法,刪除了元素之後,後邊的元素會向移動一位,但是,在迴圈中又執行了i++操作,因此,如果有連續兩個元素相同的元素與要刪除的元素相同,會漏掉後邊的元素,例如上邊的例子,輸出結果為:

[1, 2, 3]
複製程式碼

使用forearch迴圈:

public static void testRemove1() {
    List<String> list = createArrayList();
    for (String item : list) {
        if (item.equals("2")) {
            list.remove(item);
        }
    }
    System.out.println(Arrays.toString(list.toArray()));
}
複製程式碼

  直接修改for迴圈中的方法,使用forEarch迴圈,java中forearch迴圈,實際是使用了迭代器,而在Java集合學習記錄——Iterator中,知道了迭代器迭代過程中,會判斷修改次數是否和期望修改次數相同,而上邊的remove方法,會修改集合的修改次數。因此,輸出結果為:

Exception in thread "main" java.util.ConcurrentModificationException
	at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:901)
	at java.util.ArrayList$Itr.next(ArrayList.java:851)
	at collections.iterator.Test.testRemove1(Test.java:20)
	at collections.iterator.Test.main(Test.java:15)
複製程式碼

正確的方法1——使用iterator中的remove方法:

public static void testRemove2() {
    List<String> list = createArrayList();
    Iterator<String> iterator = list.iterator();
    while (iterator.hasNext()) {
        if (iterator.next().equals("2")) {
            iterator.remove();
        }
    }
    System.out.println(Arrays.toString(list.toArray()));
}
複製程式碼

  正確的做法是,使用迭代器遍歷集合,然後,使用迭代器中的刪除方法,因為在上一篇關於iterator的文章中,我們知道iterator的remove方法,雖然,也會修改modCount,但是,會再次設定expectedModeCount的值,具體也可以再ArrayList的子類Itrremove方法中檢視。

方法2——使用Java8新增方法removeIf:

public static void testRemove3() {
    List<String> list = createArrayList();
    list.removeIf(s -> {
        return s.equals("2");
    });
    System.out.println(Arrays.toString(list.toArray()));
}
複製程式碼

2.3.3 查詢方法

獲取指定索引位置的元素:

// 獲取指定索引的元素
public E get(int index) {
	// 驗證索引位置是否合法,
    rangeCheck(index);
	// 返回陣列該索引位置的元素
    return elementData(index);
}

E elementData(int index) {
    return (E) elementData[index];
}
複製程式碼

判斷某個元素是否存在:

public boolean contains(Object o) {
    return indexOf(o) >= 0;
}
// 獲取元素第一次出現的位置
public int indexOf(Object o) {
	// 遍歷集合,查詢其位置,找到之後就返回
    if (o == null) {
        for (int i = 0; i < size; i++)
            if (elementData[i]==null)
                return i;
    } else {
        for (int i = 0; i < size; i++)
            if (o.equals(elementData[i]))
                return i;
    }
    return -1;
}

// 獲取某個元素,最後一次出現的索引位置,從後往前遍歷
public int lastIndexOf(Object o) {
    if (o == null) {
        for (int i = size-1; i >= 0; i--)
            if (elementData[i]==null)
                return i;
    } else {
        for (int i = size-1; i >= 0; i--)
            if (o.equals(elementData[i]))
                return i;
    }
    return -1;
}
複製程式碼

判斷集合是否為空,以及獲取元素個數

public boolean isEmpty() {
   return size == 0;
}

public int size() {
    return size;
}
複製程式碼

2.3.3 修改元素

  修改元素的方法,除了上邊replaceAll方法外,Java8之前,提供了set(int index, E element)方法,修改指定索引位置的元素:

// 修改指定索引的元素,並返回舊值
public E set(int index, E element) {
    rangeCheck(index);

    E oldValue = elementData(index);
    elementData[index] = element;
    return oldValue;
}
複製程式碼

總結:

  ArrayList其底層實現是一個陣列,因此具備陣列的特性,針對索引的相關操作會相對較快,例如get(int index),set(int index, E element)等;但是,由於陣列的長度是一定的,因此,新增元素可能會涉及到陣列的擴容操作,會相對較慢;另外,插入和刪除元素涉及到元素的移動,也會相對較慢。在使用List時,如果新增,插入等操作比較頻繁,建議使用LinkedList;如果使用ArrayList時,知道元素的個數,可以直接初始化時設定其容量,避免因為擴容導致效率降低。

相關文章