Java修煉之道--集合框架
前言
Java集合框架 (Java Collections Framework, JCF) 也稱容器,這裡可以類比 C++ 中的 STL,在市面上似乎還沒能找到一本詳細介紹的書籍。在這裡主要對如下部分進行原始碼分析,及在面試中常見的問題。
例如,在阿里面試常問到的 HashMap 和 ConcurrentHashMap 原理等等。深入原始碼分析是面試中必備的技能,通過本文的閱讀會對集合框架有更深一步的瞭解。
本文參考:
一、概述
Java集合框架提供了資料持有物件的方式,提供了對資料集合的操作。Java 集合框架位於 java.util
包下,主要有三個大類:Collection(介面)、Map(介面)
集合框架圖
Collection
ArrayList
:執行緒不同步。預設初始容量為 10,當陣列大小不足時容量擴大為 1.5 倍。為追求效率,ArrayList 沒有實現同步(synchronized),如果需要多個執行緒併發訪問,使用者可以手動同步,也可使用 Vector 替代。LinkedList
:執行緒不同步。雙向連結實現。LinkedList 同時實現了 List 介面和 Deque 介面,也就是說它既可以看作一個順序容器,又可以看作一個佇列(Queue),同時又可以看作一個棧(Stack)。這樣看來,LinkedList 簡直就是個全能冠軍。當你需要使用棧或者佇列時,可以考慮使用 LinkedList,一方面是因為 Java 官方已經宣告不建議使用 Stack 類,更遺憾的是,Java 里根本沒有一個叫做 Queue 的類(它是個介面名字)。關於棧或佇列,現在的首選是 ArrayDeque,它有著比 LinkedList(當作棧或佇列使用時)有著更好的效能。Stack and Queue
:Java 裡有一個叫做 Stack 的類,卻沒有叫做 Queue 的類(它是個介面名字)。當需要使用棧時,Java 已不推薦使用 Stack,而是推薦使用更高效的 ArrayDeque;既然 Queue 只是一個介面,當需要使用佇列時也就首選 ArrayDeque 了(次選是 LinkedList )。Vector
:執行緒同步。預設初始容量為 10,當陣列大小不足時容量擴大為 2 倍。它的同步是通過Iterator
方法加synchronized
實現的。Stack
:執行緒同步。繼承自 Vector,添加了幾個方法來完成棧的功能。現在已經不推薦使用 Stack,在棧和佇列中有限使用 ArrayDeque,其次是 LinkedList。TreeSet
:執行緒不同步,內部使用NavigableMap
操作。預設元素 “自然順序” 排列,可以通過Comparator
改變排序。TreeSet 裡面有一個 TreeMap(介面卡模式)HashSet
:執行緒不同步,內部使用 HashMap 進行資料儲存,提供的方法基本都是呼叫 HashMap 的方法,所以兩者本質是一樣的。集合元素可以為 NULL。Set
:Set 是一種不包含重複元素的 Collection,Set 最多隻有一個 null 元素。Set 集合通常可以通過 Map 集合通過介面卡模式得到。PriorityQueue
:Java 中 PriorityQueue 實現了 Queue 介面,不允許放入 null 元素;其通過堆實現,具體說是通過完全二叉樹(complete binary tree)實現的小頂堆(任意一個非葉子節點的權值,都不大於其左右子節點的權值),也就意味著可以通過陣列來作為 PriorityQueue 的底層實現。- 優先佇列的作用是能保證每次取出的元素都是佇列中權值最小的(Java 的優先佇列每次取最小元素,C++ 的優先佇列每次取最大元素)。這裡牽涉到了大小關係,元素大小的評判可以通過元素本身的自然順序(natural ordering),也可以通過構造時傳入的比較器(Comparator,類似於 C++ 的仿函式)。
NavigableSet
:添加了搜尋功能,可以對給定元素進行搜尋:小於、小於等於、大於、大於等於,放回一個符合條件的最接近給定元素的 key。EnumSet
:執行緒不同步。內部使用 Enum 陣列實現,速度比HashSet
快。只能儲存在建構函式傳入的列舉類的列舉值。
註釋:更多設計模式,請轉向 Java 設計模式
Map
TreeMap
:執行緒不同步,基於 紅黑樹 (Red-Black tree)的 NavigableMap 實現,能夠把它儲存的記錄根據鍵排序,預設是按鍵值的升序排序,也可以指定排序的比較器,當用 Iterator 遍歷 TreeMap 時,得到的記錄是排過序的。- TreeMap 底層通過紅黑樹(Red-Black tree)實現,也就意味著
containsKey()
,get()
,put()
,remove()
都有著log(n)
的時間複雜度。其具體演算法實現參照了《演算法導論》。
- TreeMap 底層通過紅黑樹(Red-Black tree)實現,也就意味著
HashTable
:執行緒安全,HashMap 的迭代器 (Iterator) 是fail-fast
迭代器。HashTable 不能儲存 NULL 的 key 和 value。HashMap
:執行緒不同步。根據key
的hashcode
進行儲存,內部使用靜態內部類Node
的陣列進行儲存,預設初始大小為 16,每次擴大一倍。當發生 Hash 衝突時,採用拉鍊法(連結串列)。JDK 1.8中:當單個桶中元素個數大於等於8時,連結串列實現改為紅黑樹實現;當元素個數小於6時,變回連結串列實現。由此來防止hashCode攻擊。- Java HashMap 採用的是衝突連結串列方式。
- HashMap 是 Hashtable 的輕量級實現,可以接受為 null 的鍵值 (key) 和值 (value),而 Hashtable 不允許。
LinkedHashMap
:儲存了記錄的插入順序,在用 Iterator 遍歷 LinkedHashMap 時,先得到的記錄肯定是先插入的。也可以在構造時用帶引數,按照應用次數排序。在遍歷的時候會比 HashMap 慢,不過有種情況例外,當 HashMap 容量很大,實際資料較少時,遍歷起來可能會比 LinkedHashMap 慢,因為 LinkedHashMap 的遍歷速度只和實際資料有關,和容量無關,而 HashMap 的遍歷速度和他的容量有關。WeakHashMap
:從名字可以看出它是某種 Map。它的特殊之處在於 WeakHashMap 裡的 entry 可能會被 GC 自動刪除,即使程式設計師沒有呼叫remove()
或者clear()
方法。 WeakHashMap 的儲存結構類似於HashMap- 既然有 WeekHashMap,是否有 WeekHashSet 呢?答案是沒有!不過 Java Collections 工具類給出瞭解決方案,
Collections.newSetFromMap(Map<E,Boolean> map)
方法可以將任何 Map包裝成一個Set。
- 既然有 WeekHashMap,是否有 WeekHashSet 呢?答案是沒有!不過 Java Collections 工具類給出瞭解決方案,
工具類
-
Collections
、Arrays
:集合類的一個工具類幫助類,其中提供了一系列靜態方法,用於對集合中元素進行排序、搜尋以及執行緒安全等各種操作。 -
Comparable
、Comparator
:一般是用於物件的比較來實現排序,兩者略有區別。- 類設計者沒有考慮到比較問題而沒有實現 Comparable 介面。這是我們就可以通過使用 Comparator,這種情況下,我們是不需要改變物件的。
- 一個集合中,我們可能需要有多重的排序標準,這時候如果使用 Comparable 就有些捉襟見肘了,可以自己繼承 Comparator 提供多種標準的比較器進行排序。
說明:執行緒不同步的時候可以通過,Collections.synchronizedList() 方法來包裝一個執行緒同步方法
通用實現
Implementations | ||||
---|---|---|---|---|
Hash Table | Resizable Array | Balanced Tree | Linked List | Hash Table + Linked List |
Interfaces | Set | HashSet | TreeSet | LinkedHashSet |
List | ArrayList | LinkedList | ||
Deque | ArrayDeque | LinkedList | ||
Map | HashMap | TreeMap | LinkedHashMap |
參考資料:
二、深入原始碼分析
原始碼分析基於 JDK 1.8 / JDK 1.7,在 IDEA 中 double shift 調出 Search EveryWhere,查詢原始碼檔案,找到之後就可以閱讀原始碼。
ArrayList
1. 概覽
實現了 RandomAccess 介面,因此支援隨機訪問,這是理所當然的,因為 ArrayList 是基於陣列實現的。
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
陣列的預設大小為 10。
private static final int DEFAULT_CAPACITY = 10;
2. 序列化
基於陣列實現,儲存元素的陣列使用 transient 修飾,該關鍵字宣告陣列預設不會被序列化。ArrayList 具有動態擴容特性,因此儲存元素的陣列不一定都會被使用,那麼就沒必要全部進行序列化。ArrayList 重寫了 writeObject() 和 readObject() 來控制只序列化陣列中有元素填充那部分內容。
transient Object[] elementData; // non-private to simplify nested class access
3. 擴容
新增元素時使用 ensureCapacityInternal() 方法來保證容量足夠,如果不夠時,需要使用 grow() 方法進行擴容,新容量的大小為 oldCapacity + (oldCapacity >> 1)
,也就是舊容量的 1.5 倍。
擴容操作需要呼叫 Arrays.copyOf()
把原陣列整個複製到新陣列中,這個操作代價很高,因此最好在建立 ArrayList 物件時就指定大概的容量大小,減少擴容操作的次數。
// JDK 1.8
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
// 判斷陣列是否越界
private void ensureCapacityInternal(int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
// 擴容
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1); // 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);
}
private static int hugeCapacity(int minCapacity) {
if (minCapacity < 0) // overflow
throw new OutOfMemoryError();
return (minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE;
}
4. 刪除元素
需要呼叫 System.arraycopy() 將 index+1 後面的元素都複製到 index 位置上。
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;
}
5. Fail-Fast
開始之前我們想講講,什麼是 fail-fast 機制?
fail-fast 機制在遍歷一個集合時,當集合結構被修改,會丟擲 Concurrent Modification Exception。
fail-fast 會在以下兩種情況下丟擲 Concurrent Modification Exception
(1)單執行緒環境
-
集合被建立後,在遍歷它的過程中修改了結構。
-
注意 remove() 方法會讓 expectModcount 和 modcount 相等,所以是不會丟擲這個異常。
(2)多執行緒環境
- 當一個執行緒在遍歷這個集合,而另一個執行緒對這個集合的結構進行了修改。
modCount 用來記錄 ArrayList 結構發生變化的次數。結構發生變化是指新增或者刪除至少一個元素的所有操作,或者是調整內部陣列的大小,僅僅只是設定元素的值不算結構發生變化。
在進行序列化或者迭代等操作時,需要比較操作前後 modCount 是否改變,如果改變了需要丟擲 Concurrent Modification Exception。
private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException{
// Write out element count, and any hidden stuff
int expectedModCount = modCount;
s.defaultWriteObject();
// Write out size as capacity for behavioural compatibility with clone()
s.writeInt(size);
// Write out all elements in the proper order.
for (int i=0; i<size; i++) {
s.writeObject(elementData[i]);
}
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
}
Vector
1. 同步
它的實現與 ArrayList 類似,但是使用了 synchronized 進行同步。
public synchronized boolean add(E e) {
modCount++;
ensureCapacityHelper(elementCount + 1);
elementData[elementCount++] = e;
return true;
}
public synchronized E get(int index) {
if (index >= elementCount)
throw new ArrayIndexOutOfBoundsException(index);
return elementData(index);
}
2. ArrayList 與 Vector
- Vector 是同步的,因此開銷就比 ArrayList 要大,訪問速度更慢。最好使用 ArrayList 而不是 Vector,因為同步操作完全可以由程式設計師自己來控制;
- Vector 每次擴容請求其大小的 2 倍空間,而 ArrayList 是 1.5 倍。
3. Vector 替代方案
synchronizedList
為了獲得執行緒安全的 ArrayList,可以使用 Collections.synchronizedList();
得到一個執行緒安全的 ArrayList。
List<String> list = new ArrayList<>();
List<String> synList = Collections.synchronizedList(list);
CopyOnWriteArrayList
也可以使用 concurrent 併發包下的 CopyOnWriteArrayList 類。
List<String> list = new CopyOnWriteArrayList<>();
CopyOnWrite 容器即寫時複製的容器。通俗的理解是當我們往一個容器新增元素的時候,不直接往當前容器新增,而是先將當前容器進行 Copy,複製出一個新的容器,然後新的容器裡新增元素,新增完元素之後,再將原容器的引用指向新的容器。這樣做的好處是我們可以對 CopyOnWrite 容器進行併發的讀,而不需要加鎖,因為當前容器不會新增任何元素。所以 CopyOnWrite 容器也是一種讀寫分離的思想,讀和寫不同的容器。
public boolean add(T e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
// 複製出新陣列
Object[] newElements = Arrays.copyOf(elements, len + 1);
// 把新元素新增到新數組裡
newElements[len] = e;
// 把原陣列引用指向新陣列
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
final void setArray(Object[] a) {
array = a;
}
讀的時候不需要加鎖,如果讀的時候有多個執行緒正在向 ArrayList 新增資料,讀還是會讀到舊的資料,因為寫的時候不會鎖住舊的 ArrayList。
public E get(int index) {
return get(getArray(), index);
}
CopyOnWrite的缺點
- CopyOnWrite 容器有很多優點,但是同時也存在兩個問題,即記憶體佔用問題和資料一致性問題。所以在開發的時候需要注意一下。
記憶體佔用問題。
-
因為 CopyOnWrite 的寫時複製機制,所以在進行寫操作的時候,記憶體裡會同時駐紮兩個物件的記憶體,舊的物件和新寫入的物件(注意:在複製的時候只是複製容器裡的引用,只是在寫的時候會建立新物件新增到新容器裡,而舊容器的物件還在使用,所以有兩份物件記憶體)。如果這些物件佔用的記憶體比較大,比如說 200M 左右,那麼再寫入 100M 資料進去,記憶體就會佔用 300M,那麼這個時候很有可能造成頻繁的 Yong GC 和 Full GC。之前我們系統中使用了一個服務由於每晚使用 CopyOnWrite 機制更新大物件,造成了每晚 15 秒的 Full GC,應用響應時間也隨之變長。
-
針對記憶體佔用問題,可以通過壓縮容器中的元素的方法來減少大物件的記憶體消耗,比如,如果元素全是 10 進位制的數字,可以考慮把它壓縮成 36 進位制或 64 進位制。或者不使用 CopyOnWrite 容器,而使用其他的併發容器,如 ConcurrentHashMap 。
資料一致性問題。
-
CopyOnWrite 容器只能保證資料的最終一致性,不能保證資料的實時一致性。所以如果你希望寫入的的資料,馬上能讀到,請不要使用 CopyOnWrite 容器。
-
關於 C++ 的 STL 中,曾經也有過 Copy-On-Write 的玩法,參見陳皓的《C++ STL String類中的Copy-On-Write》,後來,因為有很多執行緒安全上的事,就被去掉了。
參考資料:
LinkedList
1. 概覽
LinkedList 底層是基於雙向連結串列實現的,也是實現了 List 介面,所以也擁有 List 的一些特點 (JDK1.7/8 之後取消了迴圈,修改為雙向連結串列) 。
LinkedList 同時實現了 List 介面和 Deque 介面,也就是說它既可以看作一個順序容器,又可以看作一個佇列(Queue),同時又可以看作一個棧(Stack)。這樣看來, LinkedList 簡直就是個全能冠軍。當你需要使用棧或者佇列時,可以考慮使用 LinkedList ,一方面是因為 Java 官方已經宣告不建議使用 Stack 類,更遺憾的是,Java里根本沒有一個叫做 Queue 的類(它是個介面名字)。
關於棧或佇列,現在的首選是 ArrayDeque,它有著比 LinkedList (當作棧或佇列使用時)有著更好的效能。
基於雙向連結串列實現,內部使用 Node 來儲存連結串列節點資訊。
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
}
每個連結串列儲存了 Head 和 Tail 指標:
transient Node<E> first;
transient Node<E> last;
LinkedList 的實現方式決定了所有跟下標相關的操作都是線性時間,而在首段或者末尾刪除元素只需要常數時間。為追求效率LinkedList沒有實現同步(synchronized),如果需要多個執行緒併發訪問,可以先採用 Collections.synchronizedList()
方法對其進行包裝。
2. add()
add() 方法有兩個版本,一個是 add(E e)
,該方法在 LinkedList 的末尾插入元素,因為有 last 指向連結串列末尾,在末尾插入元素的花費是常數時間。只需要簡單修改幾個相關引用即可;另一個是 add(int index, E element)
,該方法是在指定下表處插入元素,需要先通過線性查詢找到具體位置,然後修改相關引用完成插入操作。
// JDK 1.8
public boolean add(E e) {
linkLast(e);
return true;
}
/**
* Links e as last element.
*/
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 index, E element)
的邏輯稍顯複雜,可以分成兩部分
-
先根據 index 找到要插入的位置;
-
修改引用,完成插入操作。
public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
private void checkPositionIndex(int index) {
if (!isPositionIndex(index))
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
上面程式碼中的 node(int index)
函式有一點小小的 trick,因為連結串列雙向的,可以從開始往後找,也可以從結尾往前找,具體朝那個方向找取決於條件 index < (size >> 1)
,也即是 index 是靠近前端還是後端。
3. remove()
remove() 方法也有兩個版本,一個是刪除跟指定元素相等的第一個元素 remove(Object o)
,另一個是刪除指定下標處的元素 remove(int index)
。
兩個刪除操作都要:
- 先找到要刪除元素的引用;
- 修改相關引用,完成刪除操作。
在尋找被刪元素引用的時候 remove(Object o)
呼叫的是元素的 equals 方法,而 remove(int index)
使用的是下標計數,兩種方式都是線性時間複雜度。在步驟 2 中,兩個 revome()
方法都是通過 unlink(Node<E> x)
方法完成的。這裡需要考慮刪除元素是第一個或者最後一個時的邊界情況。
4. get()
public E get(int index) {
checkElementIndex(index);
return node(index).item;
}
Node<E> node(int index) {
// assert isElementIndex(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;
}
}
由此可以看出是使用二分查詢來看 index
離 size 中間距離來判斷是從頭結點正序查還是從尾節點倒序查。
- node() 會以
O(n/2)
的效能去獲取一個結點- 如果索引值大於連結串列大小的一半,那麼將從尾結點開始遍歷
這樣的效率是非常低的,特別是當 index 越接近 size 的中間值時。
5. 總結
- LinkedList 插入,刪除都是移動指標效率很高。
- 查詢需要進行遍歷查詢,效率較低。
6. ArrayList 與 LinkedList
- ArrayList 基於動態陣列實現,LinkedList 基於雙向連結串列實現;
- ArrayList 支援隨機訪問,LinkedList 不支援;
- LinkedList 在任意位置新增刪除元素更快。
HashMap
我們這篇文章就來試著分析下 HashMap 的原始碼,由於 HashMap 底層涉及到太多方面,一篇文章總是不能面面俱到,所以我們可以帶著面試官常問的幾個問題去看原始碼:
- 瞭解底層如何儲存資料的
- HashMap 的幾個主要方法
- HashMap 是如何確定元素儲存位置的以及如何處理雜湊衝突的
- HashMap 擴容機制是怎樣的
- JDK 1.8 在擴容和解決雜湊衝突上對 HashMap 原始碼做了哪些改動?有什麼好處?
HashMap 的內部功能實現很多,本文主要從根據 key 獲取雜湊桶陣列索引位置、put 方法的詳細執行、擴容過程三個具有代表性的點深入展開講解。
1. 儲存結構
JDK1.7 的儲存結構
在 1.7 之前 JDK 採用「拉鍊法」來儲存資料,即陣列和連結串列結合的方式:
「拉鍊法」用專業點的名詞來說叫做鏈地址法。簡單來說,就是陣列加連結串列的結合。在每個陣列元素上儲存的都是一個連結串列。
我們之前說到不同的 key 可能經過 hash 運算可能會得到相同的地址,但是一個數組單位上只能存放一個元素,採用鏈地址法以後,如果遇到相同的 hash 值的 key 的時候,我們可以將它放到作為陣列元素的連結串列上。待我們去取元素的時候通過 hash 運算的結果找到這個連結串列,再在連結串列中找到與 key 相同的節點,就能找到 key 相應的值了。
JDK1.7 中新新增進來的元素總是放在陣列相應的角標位置,而原來處於該角標的位置的節點作為 next 節點放到新節點的後邊。稍後通過原始碼分析我們也能看到這一點。
JDK1.8 的儲存結構
對於 JDK1.8 之後的 HashMap
底層在解決雜湊衝突的時候,就不單單是使用陣列加上單鏈表的組合了,因為當處理如果 hash 值衝突較多的情況下,連結串列的長度就會越來越長,此時通過單鏈表來尋找對應 Key 對應的 Value 的時候就會使得時間複雜度達到 O(n),因此在 JDK1.8 之後,在連結串列新增節點導致連結串列長度超過 TREEIFY_THRESHOLD = 8
的時候,就會在新增元素的同時將原來的單鏈錶轉化為紅黑樹。
對資料結構很在行的讀者應該,知道紅黑樹是一種易於增刪改查的二叉樹,他對與資料的查詢的時間複雜度是 O(logn)
級別,所以利用紅黑樹的特點就可以更高效的對 HashMap
中的元素進行操作。
從結構實現來講,HashMap 是陣列+連結串列+紅黑樹(JDK1.8增加了紅黑樹部分)實現的,如下如所示。
這裡需要講明白兩個問題:資料底層具體儲存的是什麼?這樣的儲存方式有什麼優點呢?
(1)從原始碼可知,HashMap 類中有一個非常重要的欄位,就是 Node[] table,即雜湊桶陣列,明顯它是一個 Node 的陣列。我們來看 Node( JDK1.8 中) 是何物。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //用來定位陣列索引位置
final K key;
V value;
Node<K,V> next; //連結串列的下一個node
Node(int hash, K key, V value, Node<K,V> next) { ... }
public final K getKey(){ ... }
public final V getValue() { ... }
public final String toString() { ... }
public final int hashCode() { ... }
public final V setValue(V newValue) { ... }
public final boolean equals(Object o) { ... }
}
Node 是 HashMap 的一個內部類,實現了 Map.Entry 介面,本質是就是一個對映(鍵值對)。上圖中的每個黑色圓點就是一個Node物件。
(2)HashMap 就是使用雜湊表來儲存的。雜湊表為解決衝突,可以採用開放地址法和鏈地址法等來解決問題, Java 中 HashMap 採用了鏈地址法。鏈地址法,簡單來說,就是陣列加連結串列的結合。在每個陣列元素上都一個連結串列結構,當資料被 Hash 後,得到陣列下標,把資料放在對應下標元素的連結串列上。例如程式執行下面程式碼:
map.put("美團","小美");
系統將呼叫 “美團” 這個 key 的 hashCode() 方法得到其 hashCode 值(該方法適用於每個 Java 物件),然後再通過 Hash 演算法的後兩步運算(高位運算和取模運算,下文有介紹)來定位該鍵值對的儲存位置,有時兩個 key 會定位到相同的位置,表示發生了 Hash 碰撞。當然 Hash 演算法計算結果越分散均勻,Hash 碰撞的概率就越小,map 的存取效率就會越高。
如果雜湊桶陣列很大,即使較差的 Hash 演算法也會比較分散,如果雜湊桶陣列陣列很小,即使好的 Hash 演算法也會出現較多碰撞,所以就需要在空間成本和時間成本之間權衡,其實就是在根據實際情況確定雜湊桶陣列的大小,並在此基礎上設計好的 hash 演算法減少 Hash 碰撞。
那麼通過什麼方式來控制 map 使得 Hash 碰撞的概率又小,雜湊桶陣列(Node[] table)佔用空間又少呢?
答案就是好的 Hash 演算法和擴容機制。
在理解 Hash 和擴容流程之前,我們得先了解下 HashMap 的幾個欄位。從 HashMap 的預設建構函式原始碼可知,建構函式就是對下面幾個欄位進行初始化,原始碼如下:
int threshold; // 所能容納的key-value對極限
final float loadFactor; // 負載因子
int modCount;
int size;
首先,Node[] table的初始化長度 length (預設值是16),Load factor 為負載因子(預設值是0.75),threshold 是 HashMap 所能容納的最大資料量的 Node (鍵值對)個數。threshold = length * Load factor。也就是說,在陣列定義好長度之後,負載因子越大,所能容納的鍵值對個數越多。
結合負載因子的定義公式可知,threshold 就是在此 Load factor 和 length (陣列長度)對應下允許的最大元素數目,超過這個數目就重新 resize(擴容),擴容後的 HashMap 容量是之前容量的兩倍。預設的負載因子 0.75 是對空間和時間效率的一個平衡選擇,建議大家不要修改,除非在時間和空間比較特殊的情況下,如果記憶體空間很多而又對時間效率要求很高,可以降低負載因子 Load factor 的值;相反,如果記憶體空間緊張而對時間效率要求不高,可以增加負載因子 loadFactor 的值,這個值可以大於1。
size 這個欄位其實很好理解,就是 HashMap 中實際存在的鍵值對數量。注意和 table 的長度 length、容納最大鍵值對數量 threshold 的區別。而 modCount 欄位主要用來記錄 HashMap 內部結構發生變化的次數,主要用於迭代的快速失敗。強調一點,內部結構發生變化指的是結構發生變化,例如 put 新鍵值對,但是某個 key 對應的 value 值被覆蓋不屬於結構變化。
在 HashMap 中,雜湊桶陣列 table 的長度 length 大小必