Java 集合(一)List
在 Java 中,主要存在以下三種類型的集合:Set、List 和 Map,按照更加粗略的劃分,可以分為:Collection 和 Map,這些型別的繼承關係如下圖所示:
Collection
是集合 List、Set、Queue 等最基本的介面Iterator
即迭代器,可以通過迭代器遍歷集合中的資料Map
是對映關係的基本介面
本文將主要介紹有關 List
集合的相關內容,ArrayList
和 LinkedList
是在實際使用中最常使用的兩種 List
,因此主要介紹這兩種型別的 List
本文的 JDK 版本為 JDK 17
ArrayList
具體的使用可以參考 List
初始化
ArrayList
存在三個建構函式,用於初始化 ArrayList
-
ArrayList()
對應的原始碼如下所示:
// 預設的空元素列表 private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; // 實際儲存元素的陣列 transient Object[] elementData; public ArrayList() { // 呼叫次無參建構函式時,首先將元素列表指向空的列表,在之後的擴容操作中再進行進一步的替換 this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; }
-
ArrayList(int)
對應的原始碼如下所示:
private static final Object[] 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); } }
-
ArrayList(Collection<? extend E>)
對應的原始碼:
public ArrayList(Collection<? extends E> c) { Object[] a = c.toArray(); if ((size = a.length) != 0) { if (c.getClass() == ArrayList.class) { /* 如果新增的集合和 ArrayList 相同,那麼直接修改當前的資料列表的引用 因此在某些情況下對於該陣列的修改會出現一些奇怪的問題, 因為對於當前陣列元素的修改也會導致原陣列元素的改變 */ elementData = a; } else { /* 想比較之下,複製一個數組會更加安全,缺點在於執行速度不是那麼的好 */ elementData = Arrays.copyOf(a, size, Object[].class); } } else { // 如果集合中元素的個數為 0,那麼替換為空陣列 elementData = EMPTY_ELEMENTDATA; } }
新增元素
新增元素是比較重要的部分,特別是 ArrayList
的自動擴容機制
在 ArrayList
中存在以下三個過載函式:
add(E, Object[], int)
add(E)
add(int, E)
一般情況下,都會呼叫 add(E)
的過載方法完成新增元素的功能,具體的原始碼如下所示:
public boolean add(E e) {
modCount++; // 記錄當前的陣列的修改次數,適用於併發環境下的檢測
add(e, elementData, size);
return true;
}
新增時呼叫過載函式 add(E, Object[], int)
,對應的原始碼如下所示:
private void add(E e, Object[] elementData, int s) {
if (s == elementData.length)
elementData = grow(); // 比較關鍵的地方在這,在這裡完成自動擴容
elementData[s] = e;
size = s + 1;
}
而 add(int, E)
的目的是向陣列中指定的位置插入對應的元素,對應的原始碼如下所示:
public void add(int index, E element) {
rangeCheckForAdd(index); // 首先,檢查插入的索引位置是否合法
modCount++; // 記錄當前的修改次數
final int s;
Object[] elementData;
if ((s = size) == (elementData = this.elementData).length)
elementData = grow(); // 此時的陣列長度不夠,需要進行擴容
// 將擴容後(已經複製了原有陣列的資料)的陣列按照指定的 index 分開復制到原有陣列中
System.arraycopy(elementData, index,
elementData, index + 1,
s - index);
// 修改 index 位置的陣列元素
elementData[index] = element;
size = s + 1;
}
擴容
grow()
擴容的實現的原始碼如下所示:
private Object[] grow() {
return grow(size + 1);
}
private Object[] grow(int minCapacity) {
int oldCapacity = elementData.length;
if (oldCapacity > 0 || elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { // 判斷當前愛你的陣列元素是否被修改過
// 首先,計算擴容後的陣列的大小
int newCapacity = ArraysSupport.newLength(oldCapacity,
minCapacity - oldCapacity, // 最少需要增長的空間
oldCapacity >> 1 /* 每次增大的預設空間大小 ,為 0.5 倍的舊空間大小*/ );
// 然後將建立原有的資料元素陣列的副本物件,再將資料填入到副本陣列中,完成擴容操作
return elementData = Arrays.copyOf(elementData, newCapacity);
} else {
/*
由於沒有被修改過,那麼直接擴容到目標大小即可,
DEFAULT_CAPACITY=10,這是為了提供一個最小的初始容量,以免擴容機制過於頻繁造成的效能損失
*/
return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)];
}
}
擴容時關鍵的是在於新的容量的計算,具體的原始碼如下所示:
// jdk.internal.util.ArraysSupport
public static int newLength(int oldLength, int minGrowth, int prefGrowth) {
/*
參照上文中 grow 中傳入的引數,最小大小擴容到原來陣列大小的 1.5 倍,
如果 minGrwoth 大於 oldCapacity,則以 minGrowth 為準
*/
int prefLength = oldLength + Math.max(minGrowth, prefGrowth);
// 加法操作可能會導致整形變數溢位
if (0 < prefLength && prefLength <= SOFT_MAX_ARRAY_LENGTH) {
return prefLength;
} else {
// 如果溢位的話則呼叫 hugeLength 進行計算
return hugeLength(oldLength, minGrowth);
}
}
/*
之所以最大值為 Integer.MAX_VALUE - 8 是由於有些 JVM 的實現會限制其不會達到 Integer.MAX_VALUE
*/
public static final int SOFT_MAX_ARRAY_LENGTH = Integer.MAX_VALUE - 8;
private static int hugeLength(int oldLength, int minGrowth) {
int minLength = oldLength + minGrowth;
if (minLength < 0) { // 這裡溢位的說明確實是沒有足夠的空間可以進行分配了
throw new OutOfMemoryError(
"Required array length " + oldLength + " + " + minGrowth + " is too large");
} else if (minLength <= SOFT_MAX_ARRAY_LENGTH) { // 小於最大限制,直接使用即可
return SOFT_MAX_ARRAY_LENGTH;
} else {
// 這種情況說明在 SOFT_MAX_ARRAY_LENGTH — Integer.MAX_VALUE 之間,依舊是一個有效的 int 值
return minLength;
}
}
擴容完成之後,將元素直接放入到末尾位置,完成元素的插入
移除元素
在 ArrayList
中移除元素也存在兩個過載函式:
remove(int)
:移除指定位置的元素remove(Object)
:移除列表中的指定元素(依據 Object 的equals
方法)
remove(int)
方法對應的原始碼如下所示:
public E remove(int index) {
Objects.checkIndex(index, size); // 檢查刪除的索引位置是否是有效的
final Object[] es = elementData;
// 獲取這個索引位置的舊有元素
@SuppressWarnings("unchecked") E oldValue = (E) es[index];
fastRemove(es, index); // 移除該索引位置的元素
return oldValue;
}
比較關鍵的 fastRemove
方法對應的原始碼如下所示:
private void fastRemove(Object[] es, int i) {
modCount++;
final int newSize;
// 直接呼叫 native 方法將 index 後的元素向前移動一個位置
if ((newSize = size - 1) > i)
System.arraycopy(es, i + 1, es, i, newSize - i);
// 最後一個位置現在已經是無效的,設定為 null 幫助 gc
es[size = newSize] = null;
}
remove(Object)
對應的原始碼如下所示:
public boolean remove(Object o) {
final Object[] es = elementData;
final int size = this.size;
int i = 0;
/*
這一步的目的是找到對應的元素位置的索引
可以看到,在找到第一個元素後就不會繼續向後找了,
因此使用該方法是需要注意這一點
*/
found: {
if (o == null) {
for (; i < size; i++)
if (es[i] == null)
break found;
} else {
for (; i < size; i++)
if (o.equals(es[i]))
break found;
}
return false;
}
// 同 remove(int) 的移除元素一致
fastRemove(es, i);
return true;
}
值得注意的是,在實際使用的過程中,由於 Java “自動裝箱/拆箱” 機制的存在,如果此時恰好列表的元素型別為 Integer
,那麼在呼叫 remove
方法時將會自動完成拆箱呼叫 remove(int)
過載方法。可以顯示地通過傳入 Integer
物件來避免自動拆箱錯誤地呼叫 remove(int)
,如 list.remove(Integer.valueOf(1))
就會正確地呼叫 remove(object )
方法
收縮列表
由於擴容機制的存在,因此出現列表很長,但是資料元素不多的情況是可能的。ArrayList
並不會自動呼叫收縮列表的方法來收縮列表的長度,但是我們自己可以顯示地通過呼叫 trimToSize
方法來收縮列表。
trimToSize
對應的原始碼如下所示:
public void trimToSize() {
modCount++;
if (size < elementData.length) {
elementData = (size == 0)
? EMPTY_ELEMENTDATA
: Arrays.copyOf(elementData, size);
}
}
LinkedList
除了 ArrayList
之外,對於 List
介面的實現類,LinkedList
可能是使用得比較多的實現類。和 ArrayList
的實現不同,LinkedList
的底層資料結構是雙向連結串列,具體的節點的定義如下所示:
private static class Node<E> {
E item; // 實際資料元素
Node<E> next; // 後繼節點指標
Node<E> prev; // 前驅節點指標
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
新增元素
同樣德,從 List
介面過來的兩個過載方法:
add(E)
:新增一個元素到列表的末尾add(int, E)
:新增一個元素到指定的索引位置
其中, add(E)
對應的原始碼如下所示:
public boolean add(E e) {
linkLast(e);
return true;
}
// 連結元素到連結串列的末尾
void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}
這個就是簡單的雙向連結串列的插入操作,不做過多的介紹。
add(int, E)
對應的原始碼如下所示:
public void add(int index, E element) {
checkPositionIndex(index); // 首先,檢查插入的索引位置是否合法
// 如果插入的位置就是在末尾,那麼直接連結到末尾即可
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
比較關鍵的是確定插入位置的元素節點,即 node(index)
方法的實現,對應的原始碼如下所示:
Node<E> node(int index) {
// 如果索引小於列表長度的一般,則從頭開始查詢位置節點
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
// 否則,從尾部節點開始,向前查詢到對應的位置節點
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
最後,將對應的元素插入到找到的元素節點之前即可完成該項操作
移除元素
依舊是從 List
介面帶過來的過載方法:
remove(int)
:移除指定位置的元素remove(Object)
:移除第一個出現的指定元素
remove(int)
對應的原始碼如下所示:
public E remove(int index) {
checkElementIndex(index); // 檢查索引位置是否合法
/*
首先通過 node(index) 方法找到對應的索引位置的節點,
然後移除它的連結即可(注意頭節點和尾節點的變化)
*/
return unlink(node(index));
}
E unlink(Node<E> x) {
final E element = x.item;
final Node<E> next = x.next;
final Node<E> prev = x.prev;
// 移除前驅節點,注意前驅節點為 null 的情況
if (prev == null) {
first = next;
} else {
prev.next = next;
x.prev = null;
}
// 移除後繼節點,注意後繼節點為 null 的情況
if (next == null) {
last = prev;
} else {
next.prev = prev;
x.next = null;
}
x.item = null; // 設定為 null 方便 gc
size--;
modCount++;
return element;
}
remove(Object)
對應的原始碼如下所示:
public boolean remove(Object o) {
/*
簡單來講就是遍歷整個連結串列,找到要移除的元素,
然後取消它在連結串列中的連結即可
unlink 方法在上文有所介紹
*/
if (o == null) {
for (Node<E> x = first; x != null; x = x.next) {
if (x.item == null) {
unlink(x);
return true;
}
}
} else {
for (Node<E> x = first; x != null; x = x.next) {
if (o.equals(x.item)) {
unlink(x);
return true;
}
}
}
return false;
}
兩者的比較
在實際使用中,ArrayList
的使用次數要比 LinkedList
的次數要高。這是由於使用 List
更加傾向於儲存資料,然後獲取資料的情況要多一些。
其實,在教科書上會提及連結串列和陣列的比較,諸如:連結串列使用了額外的空間來維護節點的前後指標、插入和刪除都在常數的時間複雜度內完成。但是實際上,這兩者的操作效能不會有太大的區別,甚至可能 LinkedList
的插入操作的效能還不及 ArrayList
(因為要找到插入的位置節點);同樣的,ArrayList
對於空間的利用率在某些情況下可能還沒有 LinkedList
高,這是由於擴容時預設會擴容到原來的 1.5 倍,有時那 0.5 倍的額外空間是完全沒有被使用的。
按照本人的使用情況,一般在選取 List
的實現時會採用 ArrayList
作為具體的實現類,因為 ArrayList
在轉換為陣列的這個過程會比 LinkedList
更加便捷。而 LinkedList
不僅僅實現了 List
介面,而且還實現了 Queue
、Deque
等其它的介面,因此 LinkedList
一般會作為這些介面的預設實現類來使用