ArrayList 原始碼分析
不知道各位朋友,還記得開工前制定的學習目標麼? 有沒有一直為了那個目標廢寢忘食呢?繼 搞懂 Java 內部類 後開始探索總結 Java 集合框架原始碼的知識,希望能給自己夯實基礎,也希望能為自己實現目標更近一步。
ArrayList 原始碼分析思路
ArrayList 是我們 App 開發中常用的 Java 集合類,從學習 Java 開始我們基本上就對它天天相見了,但是通過探索ArrayList 原始碼,我們將會把它從普通朋友變成知根知底的老朋友,本文將從以下幾部分開始分析 ArrayList
:
- ArrayList 概述
- ArrayList 的建構函式,也就是我們建立一個 ArrayList 的方法。
- ArrayList 的新增元素的方法, 以及 ArrayList 的擴容機制
- ArrayList 的刪除元素的常用方法
- ArrayList 的 改查常用方法
- ArrayList 的 toArray 方法
- ArrayList 的遍歷方法,以及常見的錯誤操作即產生錯誤操作的原因
ArrayList 概述
ArrayList的基本特點
- ArrayList 底層是一個動態擴容的陣列結構
- 允許存放(不止一個) null 元素
- 允許存放重複資料,儲存順序按照元素的新增順序
- ArrayList 並不是一個執行緒安全的集合。如果集合的增刪操作需要保證執行緒的安全性,可以考慮使用
CopyOnWriteArrayList
或者使用collections.synchronizedList(List l)
函式返回一個執行緒安全的ArrayList類.
ArrayList 的繼承關係
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
複製程式碼
從 ArrayList
的繼承關係來看, ArrayList
繼承自 AbstractList
,實現了List<E>, RandomAccess, Cloneable, java.io.Serializable
介面。
-
其中
AbstractList
和List<E>
是規定了ArrayList
作為一個集合框架必須具備的一些屬性和方法,ArrayList
本身覆寫了基類和介面的大部分方法,這就包含我們要分析的增刪改查操作。 -
ArrayList
實現RandomAccess
介面標識著其支援隨機快速訪問,檢視原始碼可以知道RandomAccess
其實只是一個標識,標識某個類擁有隨機快速訪問的能力,針對 ArrayList 而言通過get(index)
去訪問元素可以達到 O(1) 的時間複雜度。有些集合類不擁有這種隨機快速訪問的能力,比如LinkedList
就沒有實現這個介面。 -
ArrayList
實現Cloneable
介面標識著他可以被克隆/複製,其內部實現了 clone 方法供使用者呼叫來對 ArrayList 進行克隆,但其實現只通過Arrays.copyOf
完成了對 ArrayList 進行「淺複製」,也就是你改變ArrayList clone
後的集合中的元素,源集合中的元素也會改變,對於深淺複製我以後會單獨整理一篇文章來講述這裡不再過多的說。 -
對於
java.io.Serializable
標識著集合可被被序列化。
我們發現了一些有趣的事情,除了List<E>
以外,ArrayList
實現的介面都是標識介面,標識著這個類具有怎樣的特點,看起來更像是一個屬性。
ArrayList 的構造方法
在說構造方法之前我們要先看下與構造引數有關的幾個全域性變數:
/**
* ArrayList 預設的陣列容量
*/
private static final int DEFAULT_CAPACITY = 10;
/**
* 這是一個共享的空的陣列例項,當使用 ArrayList(0) 或者 ArrayList(Collection<? extends E> c)
* 並且 c.size() = 0 的時候講 elementData 陣列講指向這個例項物件。
*/
private static final Object[] EMPTY_ELEMENTDATA = {};
/**
* 另一個共享空陣列例項,再第一次 add 元素的時候將使用它來判斷陣列大小是否設定為 DEFAULT_CAPACITY
*/
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
/**
* 真正裝載集合元素的底層陣列
* 至於 transient 關鍵字這裡簡單說一句,被它修飾的成員變數無法被 Serializable 序列化
* 有興趣的可以去網上查相關資料
*/
transient Object[] elementData; // non-private to simplify nested class access
複製程式碼
對於上述幾個成員變數,我們只是在註釋中簡單的說明,對於他們具體有什麼作用,在下邊分析構造方法和擴容機制的時候將會更詳細的講解。
ArrayList 一共三種構造方式,我們先從無參的構造方法來開始:
無參構造方法
/**
* 構造一個初始容量為10的空列表。
*/
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
複製程式碼
這是我們經常使用的一個構造方法,其內部實現只是將 elementData
指向了我們剛才講得 DEFAULTCAPACITY_EMPTY_ELEMENTDATA
這個空陣列,這個空陣列的容量是 0, 但是原始碼註釋卻說這是構造一個初始容量為10的空列表。這是為什麼?其實在集合呼叫 add 方法新增元素的時候將會呼叫 ensureCapacityInternal
方法,在這個方法內部判斷了:
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
複製程式碼
可見,如果採用無引數構造方法的時候第一次新增元素肯定走進 if 判斷中 minCapacity 將被賦值為 10,所以「構造一個初始容量為10的空列表。」也就是這個意思。
指定初始容量的構造方法
/**
* 構造一個具有指定初始容量的空列表。
* @param 初始容量
* @throws 如果引數小於 0 將會丟擲 IllegalArgumentException 引數不合法異常
*/
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);
}
}
複製程式碼
如果我們預先知道一個集合元素的容納的個數的時候推薦使用這個構造方法,比如我們有個一 FragmentPagerAdapter
一共需要裝 15 個 Fragment
,那麼我們就可以在構造集合的時候生成一個初始容量為 15 的一個集合。有人會認為 ArrayList
自身具有動態擴容的機制,無需這麼麻煩,下面我們講解擴容機制的時候我們就會發現,每次擴容是需要有一定的記憶體開銷的,而這個開銷在預先知道容量的時候是可以避免的。
原始碼中指定初始容量的構造方法實現,判斷了如果 我們指定容量大於 0 ,將會直接 new 一個陣列,賦值給 elementData
引用作為集合真正的儲存陣列,而指定容量等於 0 的時候講使用成員變數 EMPTY_ELEMENTDATA
作為暫時的儲存陣列,這是 EMPTY_ELEMENTDATA
這個空陣列的一個用處(不必太過於糾結 EMPTY_ELEMENTDATA 的作用,其實它的在原始碼中出現的頻率並不高)。
使用另個一個集合 Collection 的構造方法
/**
* 構造一個包含指定集合元素的列表,元素的順序由集合的迭代器返回。
*
* @param 源集合,其元素將被放置到這個集合中。
* @如果引數為 null,將會丟擲 NullPointerException 空指標異常
*/
public ArrayList(Collection<? extends E> c) {
elementData = c.toArray();
if ((size = elementData.length) != 0) {
// c.toArray 可能(錯誤地)不返回 Object[]型別的陣列 參見 jdk 的 bug 列表(6260652)
if (elementData.getClass() != Object[].class)
elementData = Arrays.copyOf(elementData, size, Object[].class);
} else {
// 如果集合大小為空將賦值為 EMPTY_ELEMENTDATA 等同於 new ArrayList(0);
this.elementData = EMPTY_ELEMENTDATA;
}
}
複製程式碼
看完這個程式碼我最疑惑的地方是 Collection.toArray()
和 Arrays.copyOf()
這兩個方法的使用,看來想明白這個構造引數具體做了什麼必須理解這兩個方法了。
Object[] Collection.toArray() 方法
我們都知道 Collection 是集合框架的超類,其實 Collection.toArray
是交給具體的集合子類去實現的,這就說明不同的集合可能有不同的實現。他用來將一個集合轉化為一個 Object[] 陣列,事實上的真的是這樣的麼?參見 jdk 的 bug 列表(6260652)又是什麼意思呢 ?我們來看下下邊的這個例子:
List<String> subClasses = Arrays.asList("abc","def");
// class java.util.Arrays$ArrayList
System.out.println(list.getClass());
Object[] objects = subClasses.toArray();
// class java.lang.String;
Object[] objArray = list.toArray();
//這裡返回的是 String[]
System.out.println(objects.getClass().getSimpleName());
objArray[0] = new Object(); // cause ArrayStoreException
複製程式碼
咦?為啥這裡並不是一個 Object 陣列呢?其實我們注意到,list.getClass
得到的並不是我們使用的 ArrayList
而是 Arrays
的內部類 Arrays$ArrayList
。
ArrayList(E[] array) {
//這裡只是檢查了陣列是否為空,不為空直接將原陣列賦值給這個 ArrayList 的儲存陣列。
a = Objects.requireNonNull(array);
}
@Override
public Object[] toArray(){
return a.clone();
}
複製程式碼
而我們呼叫的 toArray 方法就是這個內部對於 Collection.toArray 的實現,a.clone()
,這裡 clone 並不會改變一個陣列的型別,所以當原始陣列中放的 String 型別的時候就會出現上邊的這種情況了。
其實我們可以認為這是 jdk 的一個 bug,早在 05年的時候被人提出來了,但是一直沒修復,但是在新的 「jdk 1.9」 種這個 bug 被修復了。
有興趣的可以追蹤 bug 6260652 看下。
Arrays.copyOf 方法
這個方法是在集合原始碼中常見的一個方法,他有很多過載方式,我們來看下最根本的方法:
public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
@SuppressWarnings("unchecked")
//根據class的型別是否是 Object[] 來決定是 new 還是反射去構造一個泛型陣列
T[] copy = ((Object)newType == (Object)Object[].class)
? (T[]) new Object[newLength]
: (T[]) Array.newInstance(newType.getComponentType(), newLength);
//使用 native 方法批量賦值元素至新陣列中。
System.arraycopy(original, 0, copy, 0,
Math.min(original.length, newLength));
return copy;
}
複製程式碼
上邊的註釋也看出來了,Arrays.copyOf
方法複製陣列的時候先判斷了指定的陣列型別是否為 Object[] 型別,否則使用反射去構造一個指定型別的陣列。最後使用 System.arraycopy
這個 native 方法,去實現最終的陣列賦值,newLength
如果比 original.length
大的時候會將多餘的空間賦值為 null
由下邊的例子可見:
String[] arrString = {"abc","def"};
Object[] copyOf = Arrays.copyOf(arrString, 5, Object[].class);
//[abc, def, null, null, null]
System.out.println(Arrays.toString(copyOf));
複製程式碼
當然 ArrayList(Collection<? extends E> c)
複製的時候傳遞的是 c.size()
所以不會出現 null
。
ex: 對於
System.arraycopy
該方法,本文不再展開討論,有一篇對於其分析很好的文章大家可以去參考System:System.arraycopy方法詳解
ok,繞了這麼大的圈子終於明白了,ArrayList(Collection<? extends E> c)
幹了啥了,其實就是將一個集合中的元素塞到 ArrayList 底層的陣列中。至此我們也將 ArrayList 的構造研究完了。
ArrayList的新增元素 & 擴容機制
敲黑板了!這塊是面試的常客了,所以必須仔細研究下了。我們先看下如何給一個 ArrayList
新增一個元素:
在集合末尾新增一個元素的方法
//成員變數 size 標識集合當前元素個數初始為 0
int size;
/**
* 將指定元素新增到集合(底層陣列)末尾
* @param 將要新增的元素
* @return 返回 true 表示新增成功
*/
public boolean add(E e) {
//檢查當前底層陣列容量,如果容量不夠則進行擴容
ensureCapacityInternal(size + 1); // Increments modCount!!
//將陣列新增一個元素,size 加 1
elementData[size++] = e;
return true;
}
複製程式碼
呼叫 add 方法的時候總會呼叫 ensureCapacityInternal
來判斷是否需要進行陣列擴容,ensureCapacityInternal
引數為當前集合長度 size + 1
,這很好理解,是否需要擴充長度,需要看當前底層陣列是否夠放 size + 1
個元素的。
擴容機制
//擴容檢查
private void ensureCapacityInternal(int minCapacity) {
//如果是無參構造方法構造的的集合,第一次新增元素的時候會滿足這個條件 minCapacity 將會被賦值為 10
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
// 將 size + 1 或 10 傳入 ensureExplicitCapacity 進行擴容判斷
ensureExplicitCapacity(minCapacity);
}
private void ensureExplicitCapacity(int minCapacity) {
//運算元加 1 用於保證併發訪問
modCount++;
// 如果 當前陣列的長度比新增元素後的長度要小則進行擴容
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
複製程式碼
上邊的原始碼主要做了擴容前的判斷操作,注意引數為當前集合元素個數+1,第一次新增元素的時候 size + 1 = 1
,而 elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA
, 長度為 0 ,1 - 0 > 0
, 所以需要進行 grow 操作也就是擴容。
/**
* 集合的最大長度 Integer.MAX_VALUE - 8 是為了減少出錯的機率 Integer 最大值已經很大了
*/
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
/**
* 增加容量,以確保它至少能容納最小容量引數指定的元素個數。
* @param 滿足條件的最小容量
*/
private void grow(int minCapacity) {
//獲取當前 elementData 的大小,也就是 List 中當前的容量
int oldCapacity = elementData.length;
//oldCapacity >> 1 等價於 oldCapacity / 2 所以新容量為當前容量的 1.5 倍
int newCapacity = oldCapacity + (oldCapacity >> 1);
//如果擴大1.5倍後仍舊比 minCapacity 小那麼直接等於 minCapacity
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
//如果新陣列大小比 MAX_ARRAY_SIZE 就需要進一步比較 minCapacity 和 MAX_ARRAY_SIZE 的大小
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity通常接近 size 大小
//使用 Arrays.copyOf 構建一個長度為 newCapacity 新陣列 並將 elementData 指向新陣列
elementData = Arrays.copyOf(elementData, newCapacity);
}
/**
* 比較 minCapacity 與 Integer.MAX_VALUE - 8 的大小如果大則放棄-8的設定,設定為 Integer.MAX_VALUE
*/
private static int hugeCapacity(int minCapacity) {
if (minCapacity < 0) // overflow
throw new OutOfMemoryError();
return (minCapacity > MAX_ARRAY_SIZE) ?
Integer.MAX_VALUE :
MAX_ARRAY_SIZE;
}
複製程式碼
由此看來 ArrayList 的擴容機制的知識點一共又兩個
- 每次擴容的大小為原來大小的 1.5倍 (當然這裡沒有包含 1.5倍後大於 MAX_ARRAY_SIZE 的情況)
- 擴容的過程其實是一個將原來元素拷貝到一個擴容後陣列大小的長度新陣列中。所以 ArrayList 的擴容其實是相對來說比較消耗效能的。
在指定角標位置新增元素的方法
/**
* 將指定的元素插入該列表中的指定位置。將當前位置的元素(如果有)和任何後續元素移到右邊(將一個元素新增到它們的索引中)。
*
* @param 要插入的索引位置
* @param 要新增的元素
* @throws 如果 index 大於集合長度 小於 0 則丟擲角標越界 IndexOutOfBoundsException 異常
*/
public void add(int index, E element) {
// 檢查角標是否越界
rangeCheckForAdd(index);
// 擴容檢查
ensureCapacityInternal(size + 1);
//呼叫 native 方法新型陣列拷貝
System.arraycopy(elementData, index, elementData,
index + 1,size - index);
// 新增新元素
elementData[index] = element;
size++;
}
複製程式碼
我們知道一個陣列是不能在角標位置直接插入元素的,ArrayList 通過陣列拷貝的方法將指定角標位置以及其後續元素整體向後移動一個位置,空出 index 角標的位置,來賦值新的元素。
將一個陣列 src
起始 srcPos
角標之後 length 長度間的元素,賦值到 dest
陣列中 destPos
到 destPos + length -1
長度角標位置上。只是在 add 方法中 src
和 destPos
為同一個陣列而已。
public static native void arraycopy(Object src, int srcPos,
Object dest, int destPos,
int length);
複製程式碼
批量新增元素
由於批量新增和新增一個元素邏輯大概相同則這裡不詳細說了,程式碼註釋可以瞭解整個新增流程。
在陣列末尾新增
public boolean addAll(Collection<? extends E> c) {
// 呼叫 c.toArray 將集合轉化陣列
Object[] a = c.toArray();
// 要新增的元素的個數
int numNew = a.length;
//擴容檢查以及擴容
ensureCapacityInternal(size + numNew); // Increments modCount
//將引數集合中的元素新增到原來陣列 [size,size + numNew -1] 的角標位置上。
System.arraycopy(a, 0, elementData, size, numNew);
size += numNew;
//與單一新增的 add 方法不同的是批量新增有返回值,如果 numNew == 0 表示沒有要新增的元素則需要返回 false
return numNew != 0;
}
複製程式碼
在陣列指定角標位置新增
public boolean addAll(int index, Collection<? extends E> c) {
//同樣檢查要插入的位置是否會導致角標越界
rangeCheckForAdd(index);
Object[] a = c.toArray();
int numNew = a.length;
ensureCapacityInternal(size + numNew);
//這裡做了判斷,如果要numMoved > 0 代表插入的位置在集合中間位置,和在 numMoved == 0最後位置 則表示要在陣列末尾新增 如果 < 0 rangeCheckForAdd 就跑出了角標越界
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;
}
private void rangeCheckForAdd(int index) {
if (index > size || index < 0)
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
複製程式碼
兩個方法不同的地方在於如果移動角標即之後的元素,addAll(int index, Collection<? extends E> c)
裡做了判斷,如果要 numMoved > 0
代表插入的位置在集合中間位置,和在 numMoved == 0
最後位置 則表示要在陣列末尾新增 如果 numMoved < 0
,rangeCheckForAdd
就丟擲了角標越界異常了。
與單一新增的 add 方法不同的是批量新增有返回值,如果 numNew == 0 表示沒有要新增的元素則需要返回 false
ArrayList 刪除元素
根據角標移除元素
/**
* 將任何後續元素移到左邊(從它們的索引中減去一個)。
*/
public E remove(int index) {
//檢查 index 是否 >= size
rangeCheck(index);
modCount++;
//index 位置的元素
E oldValue = elementData(index);
// 需要移動的元素個數
int numMoved = size - index - 1;
if (numMoved > 0)
//採用拷貝賦值的方法將 index 之後所有的元素 向前移動一個位置
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
// 將 element 末尾的元素位置設為 null
elementData[--size] = null; // clear to let GC do its work
// 返回 index 位置的元素
return oldValue;
}
// 比較要移除的角標位置和當前 elementData 中元素的個數
private void rangeCheck(int index) {
if (index >= size)
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
複製程式碼
根絕角標移除元素的方法原始碼如上所示,值得注意的地方是:
rangeCheck
和 rangeCheckForAdd
方法不同 ,rangeCheck
只檢查了 index是否大於等於 size
,因為我們知道 size
為elementData
已儲存資料的個數,我們只能移除 elementData
陣列中 [0 , size -1]
的元素,否則應該丟擲角標越界。
但是為什麼沒有 和 rangeCheckForAdd
一樣檢查小於0的角標呢,是不是remove(-1)
不會拋異常呢? 其實不是的,因為 rangeCheck(index); 後我們去呼叫 elementData(index)
的時候也會丟擲 IndexOutOfBoundsException
的異常,這是陣列本身丟擲的,不是 ArrayList 丟擲的。那為什麼要檢查>= size
呢? 陣列本身不也會檢查麼? 哈哈.. 細心的同學肯定知道 elementData.length
並不一定等於 size
,比如:
ArrayList<String> testRemove = new ArrayList<>(10);
testRemove.add("1");
testRemove.add("2");
// java.lang.IndexOutOfBoundsException: Index: 2, Size: 2
String remove = testRemove.remove(2);
System.out.println("remove = " + remove + "");
複製程式碼
new ArrayList<>(10) 表示 elementData
初始容量為10,所以elementData.length = 10
而我們只給集合新增了兩個元素所以 size = 2
這也就是為啥要 rangeCheck
的原因了。
移除指定元素
/**
* 刪除指定元素,如果它存在則反會 true,如果不存在返回 false。
* 更準確地說是刪除集合中第一齣現 o 元素位置的元素 ,
* 也就是說只會刪除一個,並且如果有重複的話,只會刪除第一個次出現的位置。
*/
public boolean remove(Object o) {
// 如果元素為空則只需判斷 == 也就是記憶體地址
if (o == null) {
for (int index = 0; index < size; index++)
if (elementData[index] == null) {
//得到第一個等於 null 的元素角標並移除該元素 返回 ture
fastRemove(index);
return true;
}
} else {
// 如果元素不為空則需要用 equals 判斷。
for (int index = 0; index < size; index++)
if (o.equals(elementData[index])) {
//得到第一個等於 o 的元素角標並移除該元素 返回 ture
fastRemove(index);
return true;
}
}
return false;
}
//移除元素的邏輯和 remve(Index)一樣
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
}
複製程式碼
由上邊程式碼可以看出來,移除元素和移除指定角標元素一樣最終都是 通過 System.arraycopy
將 index 之後的元素前移一位,並釋放原來位於 size 位置的元素。
還可以看出,如果陣列中有指定多個與 o 相同的元素只會移除角標最小的那個,並且 null 和 非null 的時候判斷方法不一樣。至於 equals 和 == 的區別,還有 hashCode 方法,我會之後在總結一篇單獨的文章。等不急的可以先去網上找找嘍。
批量移除/保留 removeAll/retainAll
ArrayList 提供了 removeAll/retainAll
操作,這兩個操作分別是 批量刪除與引數集合中共同享有的元素 和 批量刪除與引數集合中不共同享有的元素,保留共同享有的元素,由於兩個方法只有一個引數不同
/** 批量刪除與引數集合中共同享有的元素*/
public boolean removeAll(Collection<?> c) {
//判空 如果為空則丟擲 NullPointerException 異常 Objects 的方法
Objects.requireNonNull(c);
return batchRemove(c, false);
}
/** 只保留與 c 中元素相同的元素相同的元素*/
public boolean retainAll(Collection<?> c) {
Objects.requireNonNull(c);
return batchRemove(c, true);
}
/** 批量刪除的指定方法 */
private boolean batchRemove(Collection<?> c, boolean complement) {
final Object[] elementData = this.elementData;
// r w 兩個角標 r 為 elementData 中元素的索引
// w 為刪除元素後集合的長度
int r = 0, w = 0;
boolean modified = false;
try {
for (; r < size; r++)
// 如果 c 當前集合中不包含當前元素,那麼則保留
if (c.contains(elementData[r]) == complement)
elementData[w++] = elementData[r];
} finally {
// 如果c.contains(o)可能會丟擲異常,如果丟擲異常後 r!=size 則將 r 之後的元素不在比較直接放入陣列
if (r != size) {
System.arraycopy(elementData, r,
elementData, w,
size - r);
// w 加上剩餘元素的長度
w += size - r;
}
// 如果集合移除過元素,則需要將 w 之後的元素設定為 null 釋放記憶體
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;
}
複製程式碼
可以看到移除指定集合中包含的元素的方法程式碼量是目前分析程式碼中最長的了,但是邏輯也很清晰:
- 從 0 開始遍歷
elementData
如果 r 位置的元素不存在於指定集合 c 中,那麼我們就將他複製給陣列 w 位置, 整個遍歷過程中w <= r
。 - 由於
c.contains(o)
可能會丟擲異常ClassCastException/NullPointerException
,如果因為異常而終止(這兩個異常是可選操作,集合原始碼中並沒有顯示生命該方法一定會拋異常),那麼我們將會產生一次錯誤操作,所以 finally 中執行了判斷操作,如果r!= size
那麼肯定是發生了異常,那麼則將 r 之後的元素不在比較直接放入陣列。最終得到的結果並不一定正確是刪除了所有與 c 中的元素。 - 批量刪除和儲存中,涉及高效的儲存/刪除兩個集合公有元素的演算法,是值得我們學習的地方。
ArraList 的改查
對於一個ArrayList 的改查方法就很簡單了,set 和 get 方法。下面我們看下原始碼吧:
修改指定角標位置的元素
public E set(int index, E element) {
//角標越界檢查
rangeCheck(index);
//下標取資料注意這裡不是elementData[index] 而是 elementData(index) 方法
E oldValue = elementData(index);
//將 index 位置設定為新的元素
elementData[index] = element;
// 返回之前在 index 位置的元素
return oldValue;
}
E elementData(int index) {
return (E) elementData[index];
}
複製程式碼
查詢指定角標的元素
public E get(int index) {
//越界檢查
rangeCheck(index);
//下標取資料注意這裡不是elementData[index] 而是 elementData(index) 方法
return elementData(index);
}
複製程式碼
查詢指定元素的角標或者集合是否包含某個元素
//集合中是否包含元素 indexOf 返回 -1 表示不包含 return false 否則返回 true
public boolean contains(Object o) {
return indexOf(o) >= 0;
}
/**
* 返回集合中第一個與 o 元素相等的元素角標,返回 -1 表示集合中不存在這個元素
* 這裡還做了空元素直接判斷 == 的操作
*/
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;
}
/**
* 從 elementData 末尾開始遍歷遍歷陣列,所以返回的是集合中最後一個與 o 相等的元素的角標
*/
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;
}
複製程式碼
ArrayList 集合的 toArry 方法
其實 Object[] toArray();
方法,以及其過載函式 <T> T[] toArray(T[] a);
是介面 Collection
的方法,ArrayList 實現了這兩個方法,很少見ArrayList 原始碼分析的文章分析這兩個方法,顧名思義這兩個方法的是用來,將一個集合轉為陣列的方法,那麼兩者的不同之處是,後者可以指定陣列的型別,前者返回為一個 Object[] 超類陣列。那麼我們具體下原始碼實現:
public Object[] toArray() {
return Arrays.copyOf(elementData, size);
}
@SuppressWarnings("unchecked")
public <T> T[] toArray(T[] a) {
if (a.length < size)
// Make a new array of a's runtime type, but my contents:
return (T[]) Arrays.copyOf(elementData, size, a.getClass());
System.arraycopy(elementData, 0, a, 0, size);
if (a.length > size)
a[size] = null;
return a;
}
複製程式碼
可以看到 Object[] toArray()
只是呼叫了一次 Arrays.copyOf
將集合中元素拷貝到一個新的 Object[]
陣列並返回。這個 Arrays.copyOf
方法前邊已經講了。所以 toArray 方法並沒有什麼疑問,有疑問的地方在於toArray(T[] a)
。
我們可以傳入一個指定型別的標誌陣列作為引數,toArray(T[] a)
方法最終會返回這個型別的包含集合元素的新陣列。但是原始碼判斷了 :
-
如果
a.length < size
即當前集合元素的個數與引數 a 陣列元素的大小的時候將和toArray()
一樣返回一個新的陣列。 -
如果
a.length == size
將不會產生新的陣列直接將集合中的元素呼叫System.arraycopy
方法將元素複製到引數陣列中,返回 a。 -
a.length > size
也不會產生新的陣列,但是值得注意的是a[size] = null;
這一句改變了原陣列中 index = size 位置的元素,被重新設定為 null 了。
下面我們來看下第三種情況的例子:
SubClass[] sourceMore = new SubClass[4];
for (int i = 0; i < sourceMore.length; i++) {
sourceMore[i] = new SubClass(i);
}
//當 List.toArray(T[] a) 中 a.length == list.size 的時候使用 Array.copyOf 會將 list 中的內容賦值給 sourceMore 並將其返回
//sourceMore[0,size-1] = list{0, size-1} 而 sourceMore[size] = null
SubClass[] sourceMore = new SubClass[4];
for (int i = 0; i < sourceMore.length; i++) {
sourceMore[i] = new SubClass(i);
}
//list to Array 之前 sourceMore [SubClass{test=0}, SubClass{test=1}, SubClass{test=2}, SubClass{test=3}] sourceEqual.length:: 4
System.out.println("list to Array 之前 sourceMore " + Arrays.toString(sourceMore) + " sourceEqual.length:: " + sourceMore.length);
SubClass[] desSourceMore = tLists.toArray(sourceMore);
//list to Array 之後 desSourceMore [SubClass{test=1}, SubClass{test=2}, null, SubClass{test=3}]desSourceMore.length:: 4
System.out.println("list to Array 之後 desSourceMore " + Arrays.toString(desSourceMore) + "desSourceMore.length:: " + desSourceMore.length);
//list to Array 之後 source [SubClass{test=1}, SubClass{test=2}, null, SubClass{test=3}]sourceEqual.length:: 4
System.out.println("list to Array 之後 source " + Arrays.toString(sourceMore) + "sourceEqual.length:: " + sourceMore.length);
//source == desSource true
System.out.println("source == desSource " + (sourceMore == desSourceMore));
複製程式碼
ArrayList 的遍歷
ArrayList 的遍歷方式 jdk 1.8 之前有三種 :for 迴圈遍歷, foreach 遍歷,迭代器遍歷,jdk 1.8 之後又引入了forEach 操作,我們先來看看迭代器的原始碼實現:
迭代器
迭代器 Iterator
模式是用於遍歷各種集合類的標準訪問方法。它可以把訪問邏輯從不同型別的集合類中抽象出來,從而避免向客戶端暴露集合的內部結構。 ArrayList
作為集合類也不例外,迭代器本身只提供三個介面方法:
public interface Iterator {
boolean hasNext();//是否還有下一個元素
Object next();// 返回當前元素 可以理解為他相當於 fori 中 i 索引
void remove();// 移除一個當前的元素 也就是 next 元素。
}
複製程式碼
ArrayList
中呼叫 iterator()
將會返回一個內部類物件 Itr
其實現了 Iterator
介面。
public Iterator<E> iterator() {
return new Itr();
}
複製程式碼
下面讓我們看下其實現的原始碼:
正如我們的 for 迴圈遍歷一樣,陣列角標總是從 0 開始的,所以 cursor
初始值為 0 , hasNext
表示是否遍歷到陣列末尾,即 i < size 。對於 modCount 變數之所以一直沒有介紹是因為他集合併發訪問有關係,用於標記當前集合被修改(增刪)的次數,如果併發訪問了集合那麼將會導致這個 modCount 的變化,在遍歷過程中不正確的操作集合將會丟擲 ConcurrentModificationException
,這是 Java 「fast-fail 的機制」,對於如果正確的在遍歷過程中操作集合稍後會有說明。
private class Itr implements Iterator<E> {
int cursor; // 對照 hasNext 方法 cursor 應理解為下個呼叫 next 返回的元素 初始為 0
int lastRet = -1; // 上一個返回的角標
int expectedModCount = modCount;//初始化的時候將其賦值為當前集合中的運算元,
// 是否還有下一個元素 cursor == size 表示當前集合已經遍歷完了 所以只有當 cursor 不等於 size 的時候 才會有下一個元素
public boolean hasNext() {
return cursor != size;
}
複製程式碼
next 方法是我們獲取集合中元素的方法,next 返回當前遍歷位置的元素,如果在呼叫 next 之前集合被修改,並且迭代器中的期望運算元並沒有改變,將會引發ConcurrentModificationException
。next 方法多次呼叫 checkForComodification
來檢驗這個條件是否成立。
@SuppressWarnings("unchecked")
public E next() {
// 驗證期望的運算元與當前集合中的運算元是否相同 如果不同將會丟擲異常
checkForComodification();
// 如果迭代器的索引已經大於集合中元素的個數則丟擲異常,這裡不丟擲角標越界
int i = cursor;
if (i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
// 由於多執行緒的問題這裡再次判斷是否越界,如果有非同步執行緒修改了List(增刪)這裡就可能產生異常
if (i >= elementData.length)
throw new ConcurrentModificationException();
// cursor 移動
cursor = i + 1;
//最終返回 集合中對應位置的元素,並將 lastRet 賦值為已經訪問的元素的下標
return (E) elementData[lastRet = i];
}
複製程式碼
只有 Iterator
的 remove
方法會在呼叫集合的 remove
之後讓 期望 運算元改變使expectedModCount
與 modCount
再相等,所以是安全的。
// 實質呼叫了集合的 remove 方法移除元素
public void remove() {
// 比如操作者沒有呼叫 next 方法就呼叫了 remove 操作,lastRet 等於 -1的時候拋異常
if (lastRet < 0)
throw new IllegalStateException();
//檢查運算元
checkForComodification();
try {
//移除上次呼叫 next 訪問的元素
ArrayList.this.remove(lastRet);
// 集合中少了一個元素,所以 cursor 向前移動一個位置(呼叫 next 時候 cursor = lastRet + 1)
cursor = lastRet;
//刪除元素後賦值-1,確保先前 remove 時候的判斷
lastRet = -1;
//修改運算元期望值, modCount 在呼叫集合的 remove 的時候被修改過了。
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
// 集合的 remove 會有可能丟擲 rangeCheck 異常,catch 掉統一丟擲 ConcurrentModificationException
throw new ConcurrentModificationException();
}
}
複製程式碼
檢查期望的運算元與當前集合的運算元是否相同。Java8 釋出了很多函數語言程式設計的特性包括 lamada
和Stream
操作。迭代器也因此新增了 forEachRemaining
方法,這個方法可以將當前迭代器訪問的元素(next 方法)後的元素傳遞出去還沒用到過,原始碼就不放出來了,大家有興趣自己瞭解下。
@Override
@SuppressWarnings("unchecked")
public void forEachRemaining(Consumer<? super E> consumer) {
... Java8 的新特性,可以將當前迭代器訪問的元素(next 方法)後的元素傳遞出去還沒用到過,原始碼就不放出來了,大家有興趣自己瞭解下。
}
// 檢查期望的運算元與當前集合的運算元是否相同
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
複製程式碼
ListIterator 迭代器
ArrayList
可以通過以下兩種方式獲取 ListIterator
迭代器,區別在於初始角標的位置。不帶引數的迭代器預設的cursor = 0
。
public ListIterator<E> listIterator(int index) {
if (index < 0 || index > size)
throw new IndexOutOfBoundsException("Index: "+index);
return new ListItr(index);
}
public ListIterator<E> listIterator() {
return new ListItr(0);
}
複製程式碼
ListItr
物件繼承自前邊分析的 Itr
,也就是說他擁有 Itr 的所有方法,並在此基礎上進行擴充套件,其擴充套件了訪問當前角標前一個元素的方法。以及在遍歷過程中新增元素和修改元素的方法。
ListItr
的構造方法如下:
private class ListItr extends Itr implements ListIterator<E> {
ListItr(int index) {
super();
cursor = index;
}
複製程式碼
ListItr
的 previous
方法:
public boolean hasPrevious() {
// cursor = 0 表示遊標在陣列第一個元素的左邊,此時 `hasPrevious` 返回false
return cursor != 0;
}
public int nextIndex() {
return cursor;//呼叫返回當前角標位置
}
public int previousIndex() {
return cursor - 1;//呼叫返回上一個角標
}
//返回當前角標的上一個元素,並前移移動角標
@SuppressWarnings("unchecked")
public E previous() {
// fast-fail 檢查
checkForComodification();
int i = cursor - 1;
// 如果前移角標 <0 代表遍歷到陣列遍歷完成,一般在呼叫 previous 要呼叫 hasPrevious 判斷
if (i < 0)
throw new NoSuchElementException();
//獲取元素
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
//獲取成功後修改角標位置和 lastRet 位置
cursor = i;
return (E) elementData[lastRet = i];
}
複製程式碼
ListItr
的 add
方法
public void add(E e) {
// fast-fail 檢查
checkForComodification();
try {
// 獲取當前角標位置,一般的是呼叫 previous 後,角標改變後後去 cursor
int i = cursor;
//新增元素在角標位置
ArrayList.this.add(i, e);
//集合修改完成後要改變當前角標位置
cursor = i + 1;
//重新置位 -1 如果使用迭代器修改了角標位置元素後不允許立刻使用 set 方法修改修改後角標未知的額元素 參考 set 的原始碼
lastRet = -1;
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
複製程式碼
可能對比兩個迭代器後,會對 curor
指向的位置有所疑惑,現在我們來看下一段示例程式碼對應的圖:
private void testListItr(){
ArrayList<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
list.add(4);
ListIterator<Integer> listIterator = list.listIterator(list.size());
while (listIterator.hasPrevious()){
if (listIterator.previous() == 2){
listIterator.add(0);
// listIterator.set(10); //Exception in thread "main" java.lang.IllegalStateException
}
}
System.out.println("list " + list.toString());
}
複製程式碼
由此可以看 cursor
於 陣列角標不同,它可以處的位置總比角標多一個,因為在我們使用 Iterator
操作集合的時候,總是要先操作 cursor
移動, listIterator.previous
也好 iterator.next()
也好,都是一樣的道理,如果不按照規定去進行操作,帶給使用者的只有異常。
java8 新增加的遍歷方法 forEach
java8增加很多好用的 API,工作和學習中也在慢慢接觸這些 API,forEach
操作可能是我繼 lambda 後,第一個使用的 API 了(囧),jdk doc 對這個方法的解釋是:
對此集合的每個條目執行給定操作,直到處理完所有條目或操作丟擲異常為止。 除非實現類另有規定,否則按照條目集迭代的順序執行操作(如果指定了迭代順序)。操作丟擲的異常需要呼叫者自己處理。
其實其內部實現也很簡單,只是一個判斷了運算元的 for 迴圈,所以在效率上不會有提升,但是在安全性上的確有提升,也少些很多程式碼不是麼?
@Override
public void forEach(Consumer<? super E> action) {
//檢查呼叫者傳進來的操作函式是否為空
Objects.requireNonNull(action);
//與迭代不同期望操作被賦值為 final 也就是 forEach 過程中不允許併發修改集合否則會丟擲異常
final int expectedModCount = modCount;
@SuppressWarnings("unchecked")
final E[] elementData = (E[]) this.elementData;
final int size = this.size;
//每次取元素之前判斷運算元,確保操作正常
for (int i=0; modCount == expectedModCount && i < size; i++) {
action.accept(elementData[i]);
}
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
}
複製程式碼
對於高階 for 迴圈以及最普通的 fori 方法這裡不再贅述。下面我們看下面試會問到一個問題,也是我們在單執行緒操作集合的時候需要注意的一個問題,如果正確的在遍歷過程中修改集合。
錯誤操作 1 在 for迴圈修改集合後繼續遍歷
第一個例子:
List<SubClass> list2 = new ArrayList<>();
list2.add(new SubClass(1));
list2.add(new SubClass(2));
list2.add(new SubClass(3));
list2.add(new SubClass(3));
for (int i = 0; i < list2.size(); i++) {
if (list2.get(i).test == 3) {
list2.remove(i);
}
}
System.out.println(list2);
//[SubClass{test=1}, SubClass{test=2}, SubClass{test=3}]
複製程式碼
這個例子我們會發現,程式並沒有丟擲異常,但是從執行經過上來看並不是我們想要的,因為還有 SubClass.test = 3
的資料在,這是因為 remove 操作改變了list.size()
,而 fori 中每次執行都會重新呼叫一次lists2.size()
,當我們刪除了倒數第二個元素後,list2.size() = 3,i = 3 < 3
不成立則沒有在進行 remove 操作,知道了為什麼以後我們試著這樣改變了迴圈方式:
int size = list2.size();
for (int i = 0; i < size; i++) {
if (list2.get(i).test == 3) {
list2.remove(i);//remove 以後 list 內部將 size 重新改變了 for 迴圈下次呼叫的時候可能就不進去了
}
}
System.out.println(list2);
//Exception in thread "main" java.lang.IndexOutOfBoundsException: Index: 3, Size: 3
複製程式碼
果真程式丟擲了角標越界的異常,因為這樣每次 fori 的時候我們不去拿更新後的 list 元素的 size 大小,所以當我們刪除一個元素後,size = 3 當我們 for 迴圈去list2.get(3)
的時候就會被 rangeCheck
方法丟擲異常。
錯誤操作導致 ConcurrentModificationException 異常
我們分析迭代器的時候,知道 ConcurrentModificationException
是指因為迭代器呼叫 checkForComodification
方法比較 modCount
和 expectedModCount
方法大小的時候丟擲異常。我們在分析 ArrayList 的時候在每次對集合進行修改, 即有 add 和 remove 操作的時候每次都會對 modCount ++
。
modCount 這個變數主要用來記錄 ArrayList
被修改的次數,那麼為什麼要記錄這個次數呢?是為了防止多執行緒對同一集合進行修改產生錯誤,記錄了這個變數,在對 ArrayList 進行迭代的過程中我們能很快的發現這個變數是否被修改過,如果被修改了 ConcurrentModificationException
將會產生。下面我們來看下例子,這個例子並不是在多執行緒下的,而是因為我們在同一執行緒中對 list 進行了錯誤操作導致的:
Iterator<SubClass> iterator = lists.iterator();
while (iterator.hasNext()) {
SubClass next = iterator.next();
int index = next.test;
if (index == 3) {
list2.remove(index);//操作1: 注意是 list2.remove 操作
//iterator.remove();/操作2 注意是 iterator.remove 操作
}
}
//操作1: Exception in thread "main" java.util.ConcurrentModificationException
//操作2: [SubClass{test=1}, SubClass{test=2}]
System.out.println(list2);
複製程式碼
我們對操作1,2分別執行程式,可以看到,操作1很快就丟擲了 java.util.ConcurrentModificationException
異常,操作2 則順利執行出正常結果,如果對 modCount
注意了的話,我們很容易理解,list.remove(index)
操作會修改List
的 modCount
,而 iterator.next()
內部每次會檢驗 expectedModCount != modCount
,所以當我們使用 list.remove
下一次再呼叫 iterator.next()
就會報錯了,而iterator.remove
為什麼是安全的呢?因為其操作內部會在呼叫 list.remove
後重新將新的 modCount
賦值給 expectedModCount
。所以我們直接呼叫 list.remove 操作是錯誤的。對於多執行緒的影響這裡不在展開這裡推薦有興趣的朋友看下這個文章 Java ConcurrentModificationException異常原因和解決方法;
經過了一輪分析我們我們知道了錯誤產生原因了,但是大家是否能真的分辨出什麼操作是錯誤的呢?我們來看下邊這個面試題,這是我在網上無意中看到的一道大眾點評的面試題:
ArrayList<String> list = new ArrayList<String>();
for (int i = 0; i < 10; i++) {
list.add("sh" + i);
}
for (int i = 0; list.iterator().hasNext(); i++) {
list.remove(i);
System.out.println("祕密" + list.get(i));
}
複製程式碼
一道面試題
相信大家肯定知道這樣操作是會產生錯誤的,但是最終會丟擲角標越界還是ConcurrentModificationException
呢?
其實這裡會丟擲角標越界異常,為什麼呢,因為 for 迴圈的條件 list.iterator().hasNext()
,我們知道 list.iterator()
將會new 一個新的 iterator 物件,而在 new 的過程中我們將 每次 list.remove
後的 modCount
賦值給了新的 iterator
的 expectedModCount
,所以不會丟擲 ConcurrentModificationException
異常,而 hasNext
內部只判斷了 size 是否等於 cursor != size
當我們刪除了一半元素以後,size 變成了 5 而新的 list.iterator()
的 cursor 等於 0 ,0!=5
for 迴圈繼續,那麼當執行到 list.remove(5)
的時候就會丟擲角標越界了。
總結
- ArrayList 底層是一個動態擴容的陣列結構,每次擴容需要增加1.5倍的容量
- ArrayList 擴容底層是通過
Arrays.CopyOf
和System.arraycopy
來實現的。每次都會產生新的陣列,和陣列中內容的拷貝,所以會耗費效能,所以在多增刪的操作的情況可優先考慮 LinkList 而不是 ArrayList。 - ArrayList 的 toArray 方法過載方法的使用。
- 允許存放(不止一個) null 元素,
- 允許存放重複資料,儲存順序按照元素的新增順序
- ArrayList 並不是一個執行緒安全的集合。如果集合的增刪操作需要保證執行緒的安全性,可以考慮使用
CopyOnWriteArrayList
或者使collections.synchronizedList(List l)
函式返回一個執行緒安全的ArrayList類. - 不正確訪問集合元素的時候
ConcurrentModificationException
和java.lang.IndexOutOfBoundsException
異常產生的時機和原理。
本文又長篇大論的分析了一波 ArrayList
的原始碼,對我個人而言這很有意義,在檢視原始碼的過程中,注意到了平時很少有機會接觸的知識點。當然這只是集合原始碼分析的開端,以後還會更細,其他常用集合原始碼的分析。如果大家感覺我寫的還可以, 請留言 + 點贊 + 關注。