數據結構與算法
移步數據結構--容器匯總(java & Android)
內容:
- ArrayList 概述
- ArrayList 的構造函數,也就是我們創建一個 ArrayList 的方法。
- ArrayList 的添加元素的方法, 以及 ArrayList 的擴容機制
- ArrayList 的刪除元素的常用方法
- ArrayList 的 改查常用方法
- ArrayList 的 toArray 方法
- ArrayList 的遍歷方法,以及常見的錯誤操作即產生錯誤操作的原因
1 ArrayList 概述
1.1 ArrayList的基本特點
- ArrayList 底層是一個動態擴容的數組結構
- 允許存放(不止一個) null 元素
- 允許存放重復數據,存儲順序按照元素的添加順序
- ArrayList 并不是一個線程安全的集合。如果集合的增刪操作需要保證線程的安全性,可以考慮使用 CopyOnWriteArrayList 或者使用 collections.synchronizedList(List l)函數返回一個線程安全的ArrayList類.
1.2 ArrayList的繼承關系
- 從 ArrayList 的繼承關系來看, ArrayList 繼承自 AbstractList,實現了List<E>, RandomAccess, Cloneable, java.io.Serializable 接口。
- ArrayList 實現 RandomAccess 接口標識著其支持隨機快速訪問,查看源碼可以知道RandomAccess 其實只是一個標識,標識某個類擁有隨機快速訪問的能力,針對 ArrayList 而言通過 get(index)去訪問元素可以達到 O(1) 的時間復雜度。有些集合類不擁有這種隨機快速訪問的能力,比如 LinkedList 就沒有實現這個接口。
- ArrayList 實現 Cloneable 接口標識著他可以被克隆/復制,其內部實現了 clone 方法供使用者調用來對 ArrayList 進行克隆,但其實現只通過 Arrays.copyOf 完成了對 ArrayList 進行「淺復制」,也就是你改變 ArrayList clone后的集合中的元素,源集合中的元素也會改變
- 對于 java.io.Serializable 標識著集合可被被序列化。
2 ArrayList 的構造方法
2.1 全局變量
在說構造方法之前我們要先看下與構造參數有關的幾個全局變量:
/**
* 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 一共三種構造方式,我們先從無參的構造方法來開始:
2.2 無參構造方法
/**
* 構造一個初始容量為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的空列表?!挂簿褪沁@個意思。
2.3 指定初始容量的構造方法
/**
* 構造一個具有指定初始容量的空列表。
* @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);
}
}
- 每次擴容是需要有一定的內存開銷的,而這個開銷在預先知道容量的時候是可以避免的。
- 判斷了如果 我們指定容量大于 0 ,將會直接 new 一個數組,賦值給 elementData 引用作為集合真正的存儲數組,而指定容量等于 0 的時候講使用成員變量 EMPTY_ELEMENTDATA 作為暫時的存儲數組,這是 EMPTY_ELEMENTDATA 這個空數組的一個用處
2.4 使用另個一個集合 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() 這兩個方法的使用,看來想明白這個構造參數具體做了什么必須理解這兩個方法了。
2.4.1 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 類型的時候就會出現上邊的這種情況了。
2.4.2 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
-
System.arraycopy
該方法,本文不再展開討論,有一篇對于其分析很好的文章大家可以去參考System:System.arraycopy方法詳解
3 ArrayList的添加元素 & 擴容機制
3.1 在集合末尾添加一個元素的方法
//成員變量 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 個元素的。
3.2擴容機制
//擴容檢查
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 的擴容其實是相對來說比較消耗性能的。
3.3 在指定角標位置添加元素的方法
/**
* 將指定的元素插入該列表中的指定位置。將當前位置的元素(如果有)和任何后續元素移到右邊(將一個元素添加到它們的索引中)。
*
* @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 為同一個數組而已將一個數組 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);
3.4 批量添加元素
由于批量添加和添加一個元素邏輯大概相同則這里不詳細說了,代碼注釋可以了解整個添加流程。
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;
}
3.5 在數組指定角標位置添加
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
4ArrayList 刪除元素
4.1 根據角標移除元素
/**
* 將任何后續元素移到左邊(從它們的索引中減去一個)。
*/
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 的原因了。
4.2 移除指定元素
/**
* 刪除指定元素,如果它存在則反會 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 的時候判斷方法不一樣。
4.3 批量移除/保留 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 中的元素。
- 批量刪除和保存中,涉及高效的保存/刪除兩個集合公有元素的算法,是值得我們學習的地方。
5 ArraList 的改查
對于一個ArrayList 的改查方法就很簡單了,set 和 get 方法。下面我們看下源碼吧:
5.1 修改指定角標位置的元素
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];
}
5.2 查詢指定角標的元素
public E get(int index) {
//越界檢查
rangeCheck(index);
//下標取數據注意這里不是elementData[index] 而是 elementData(index) 方法
return elementData(index);
}
5.3 查詢指定元素的角標或者集合是否包含某個元素
//集合中是否包含元素 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;
}
6 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));
7 ArrayList 的遍歷
ArrayList 的遍歷方式 jdk 1.8 之前有三種 :for 循環遍歷, foreach 遍歷,迭代器遍歷,jdk 1.8 之后又引入了forEach 操作,我們先來看看迭代器的源碼實現:
7.1 迭代器
- 迭代器 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();
}
}
7.2 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;
}
7.2.1 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];
}
7.2.2 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() 也好,都是一樣的道理,如果不按照規定去進行操作,帶給使用者的只有異常。
7.3java8 新增加的遍歷方法 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 方法這里不再贅述。下面我們看下面試會問到一個問題,也是我們在單線程操作集合的時候需要注意的一個問題,如果正確的在遍歷過程中修改集合。
7.4 錯誤操作 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方法拋出異常。
7.5 錯誤操作導致 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 操作是錯誤的。
7.6 一道面試題
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)的時候就會拋出角標越界了。
8 總結
- ArrayList 底層是一個動態擴容的數組結構,每次擴容需要增加1.5倍的容量
- ArrayList 擴容底層是通過 Arrays.CopyOf 和 System.arraycopy 來實現的。每次都會產生新的數組,和數組中內容的拷貝,所以會耗費性能,所以在多增刪的操作的情況可優先考慮 LinkList 而不是 ArrayList。
- ArrayList 的 toArray 方法重載方法的使用。
- 允許存放(不止一個) null 元素,
- 允許存放重復數據,存儲順序按照元素的添加順序
- ArrayList 并不是一個線程安全的集合。如果集合的增刪操作需要保證線程的安全性,可以考慮使用 CopyOnWriteArrayList 或者使collections.synchronizedList(List l)函數返回一個線程安全的ArrayList類.
- 不正確訪問集合元素的時候 ConcurrentModificationException和 java.lang.IndexOutOfBoundsException 異常產生的時機和原理。