HashMap
在日常開發中非常常用,它基於雜湊表實現,以 key-value
形式儲存。本文通過 JDK1.8
的原始碼,分析一下 HashMap
的內部結構和實現原理。
HashMap 概述
在 JDK1.7
之前,HashMap
底層由陣列 + 連結串列實現,也就是連結串列雜湊。當向 HashMap
中新增一個鍵值對時,首先計算 key
的 hash
值,以此確定插入陣列中的位置,但可能會碰撞衝突,將其轉換為連結串列儲存。
而從 JDK1.8
開始,增加了紅黑樹,由陣列 + 連結串列 + 紅黑樹實現,當連結串列長度超過 8
時,連結串列轉換為紅黑樹以提高效能。它的儲存方式如下:
定義屬性
靜態常量
HashMap
的幾個靜態常量如下:
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
// 預設初始容量為 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量為 2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
// 預設負載因子為 0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 預設連結串列中元素大於 8 時轉為紅黑樹
static final int TREEIFY_THRESHOLD = 8;
// 擴容時,連結串列中元素小於這個值就會還原為連結串列
static final int UNTREEIFY_THRESHOLD = 6;
// 陣列的容量大於 64 時才允許被樹形化
static final int MIN_TREEIFY_CAPACITY = 64;
···
}
複製程式碼
重要變數
下面是 HashMap
中幾個重要的變數:
transient Node<K,V>[] table; // 儲存元素陣列
transient Set<Map.Entry<K,V>> entrySet; // 快取 entry 返回的 Set
transient int size; // 鍵值對個數
transient int modCount; // 內部結構修改次數
int threshold; // 臨界值
final float loadFactor; // 負載因子
複製程式碼
Node<K,V>[] table
Node<K,V>[] table
陣列用來儲存具體的元素,是 HashMap
底層陣列和連結串列的組成元素。在第一次使用時初始化(預設初始化容量為 16
),並在必要的時候進行擴容。
一般來說,由於素數導致衝突的概率較小,所以雜湊表陣列大小為素數。但 Java
的 HashMap
中採用非常規設計,陣列的長度總是 2
的 n
次方,這樣做可以在取模和擴容時做優化,同時也能減少碰撞衝突。
Node
是 HashMap
的一個內部類,實現了 Map.Entry
介面,本質上就是一個對映(鍵值對)。它的實現如下:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // 用來定位陣列索引位置
final K key; // 鍵
V value; // 值
Node<K,V> next; // 指向連結串列的下一個結點
Node(int hash, K key, V value, Node<K,V> next) { ··· }
public final K getKey() { ··· }
public final V getValue() { ··· }
public final String toString() { ··· }
// 重寫了 hashCode 和 equals 方法
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) { ··· }
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
複製程式碼
entrySet
entrySet
用於快取 entrySet()
方法返回的 Set
。後面會詳細分析。
size
size
是 HashMap
中鍵值對的數量。注意,鍵值對的數量 size
和雜湊表陣列的長度 capacity
不同。
modCount
modCount
用於記錄 HashMap
內部結構發生變化的次數,用於使用迭代器遍歷集合時修改內部結構,而快速失敗。需要注意的是,這裡指的是結構發生變化,例如增加或刪除一個鍵值對或者擴容,但是修改鍵值對的值不屬於結構變化。
threshold 和 loadFactor
threshold
是 HashMap
能容納的最大鍵值對個數,loadFactor
是負載因子,預設為 0.75
。有如下等式(capacity
是陣列容量):
threshold = capacity * loadFactor;
複製程式碼
可以得出,在陣列長度定義好之後,負載因子越大,所能容納鍵值對越多。如果儲存元素個數大於 threshold
,就要進行擴容,擴容後的容量是之前的兩倍。
TreeNode
當連結串列長度超過 8
(閾值)時,將連結串列轉換為紅黑樹儲存,以提高查詢的效率。下面是 TreeNode
的定義:
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // 父節點
TreeNode<K,V> left; //左子樹
TreeNode<K,V> right; //右子樹
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red; //顏色屬性
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
// 返回當前節點的根節點
final TreeNode<K,V> root() {
for (TreeNode<K,V> r = this, p;;) {
if ((p = r.parent) == null)
return r;
r = p;
}
}
······
}
複製程式碼
構造方法
HashMap
主要提供了四種構造方法:
1). 構造一個預設初始容量 16
和預設載入因子 0.75
的空 HashMap
。
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
複製程式碼
2). 構造一個指定的初始容量和預設載入因子 0.75
的空 HashMap
。
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
複製程式碼
3). 構造一個指定的初始容量和載入因子的空 HashMap
。
public HashMap(int initialCapacity, float loadFactor) {
// check
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
複製程式碼
4). 使用給定的 map
構造一個新 HashMap
。
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
複製程式碼
基本方法
HashMap
內部功能實現很多,這裡主要從 hash
方法、put
方法、get
方法、resize
方法和 entrySet
方法進行分析。
hash 方法
HashMap
中,增刪改查都需要用 hash
演算法來計算元素在陣列中的位置,所以 hash
演算法是否均勻高效,對效能影響很大。看一下它的實現:
static final int hash(Object key) {
int h;
// 優化了高位運算演算法
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// tab[i = (n - 1) & hash] 取模
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
···
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
···
}
複製程式碼
hash
演算法計算物件的儲存位置,分為三步:取 key
的 hashCode
值、高位運算、取模運算。
由於取模元素消耗較大,HashMap
中用了一個很巧妙的方法,利用的就是底層陣列長度總是 2
的 n
次方。通過 hash & (table.length - 1)
就可以得到物件的儲存位置,相較於對 length
取模效率更高。
JDK1.8
中優化了高位運算的演算法,通過 hashCode
的高 16
位異或低 16
位實現。下面舉例說明,n
為 table
的長度:
put 方法
來看一下 HashMap
的 put
方法:
public V put(K key, V value) {
// 呼叫 hash 計算 key 的雜湊值
return putVal(hash(key), key, value, false, true);
}
複製程式碼
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 如果 table 為空或長度為 0,則呼叫 resize 進行擴容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 根據 key 的 hash 計算陣列索引值,如果當前位置為 null,則直接建立新節點插入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// table[i] 不為空
Node<K,V> e; K k;
// 如果 table[i] 的首元素和傳入的 key 相等(hashCode 和 equals),則直接覆蓋,這裡容許 key 和 value 為 null
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 判斷 table[i] 是否為 treeNode,即 table[i] 是否為紅黑樹,如果是則在樹中插入
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 否則遍歷連結串列
else {
for (int binCount = 0; ; ++binCount) {
// 如果 key 不存在
if ((e = p.next) == null) {
// 則新建一個結點
p.next = newNode(hash, key, value, null);
// 如果長度大於8,則轉為紅黑樹處理
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 如果 key 已經存在,則直接覆蓋
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// 內部結構發生變化
++modCount;
// 如果超過最大容量就擴容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
複製程式碼
下面是 put
方法的幾個步驟::
- 判斷雜湊表陣列
table[]
為空或者長度為0
,如果是則呼叫resize()
進行擴容; - 通過
hash & (table.length - 1)
計算插入的陣列索引值,如果當前位置為null
,則直接建立節點插入 - 判斷
table[i]
的首個元素是否和key
相等(hashCode
和equals
),如果相等則直接覆蓋value
; - 判斷
table[i]
是否為treeNode
,即table[i]
是否是紅黑樹,如果是紅黑樹,則直接在樹中插入鍵值對; - 否則遍歷連結串列,如果
key
不存在,則直接建立節點插入,並判斷連結串列長度是否大於8
,如果是紅黑樹則轉為紅黑樹處理;如果遍歷中發現key
已經存在,則直接覆蓋即可; - 插入成功後,判斷實際存在鍵值對是否超過了最大容量,如果是則進行擴容;
HashMap
的 put
方法可以通過下圖理解:
get 方法
來看一下 HashMap
的 get
方法:
public V get(Object key) {
Node<K,V> e;
// 呼叫 getNode 方法,如果通過 key 獲取的 Node 為 null,則返回 null;否則返回 node.value
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
複製程式碼
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 如果陣列不為空,陣列長度大於 0
// 通過 hash & (length - 1) 計算陣列的索引值,並且對應的位置不為 null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 如果桶中第一個元素與 key 相等,則直接返回
if (first.hash == hash &&
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
// 如果當前桶是紅黑樹,則轉換處理
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 否則,遍歷連結串列處理
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
複製程式碼
resize 方法
下面來分析一下 resize
方法的原始碼:
final Node<K,V>[] resize() {
// 儲存原先的陣列、容量、臨界值
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 如果擴容前容量 > 0
if (oldCap > 0) {
// 如果陣列大小已經達到最大 2^30,則修改閾值為最大值 2^31-1,以後也就不會再擴容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 如果沒有超過最大值,就擴充為原來的 2 倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
}
else if (oldThr > 0) // 如果擴容前容量 <= 0,舊臨界值 > 0
// 將陣列的新容量設定為 舊陣列擴容的臨界值
newCap = oldThr;
else { // 容量 <= 0,舊臨界值 <= 0
// 否則設定為預設值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 計算新的臨界值
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
// 建立新的 table,容量為 newCap
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 遍歷舊雜湊表的每個桶,將舊雜湊表中的桶複製到新的雜湊表中
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 如果舊桶中只有一個 node
if (e.next == null)
// 則將 oldTab[j] 放入新雜湊表中 e.hash & (newCap - 1) 的位置
newTab[e.hash & (newCap - 1)] = e;
// 如果舊桶中為紅黑樹,則轉換處理
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
Node<K,V> loHead = null, loTail = null; // 將下標不變的節點組織成一條連結串列
Node<K,V> hiHead = null, hiTail = null; // 將下標增加 oldCapaciry 的節點組織成另一條連結串列
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
// 原索引
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
} else {
// 原索引 + oldCap
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原索引放到新陣列中
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引 + oldCap 放到新陣列中
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
複製程式碼
resize
方法在擴容時,由於每次陣列的長度變為原先的 2
倍,所以元素要麼在原位置,要麼在“原始位置 + 原陣列長度”的位置。通過計算 e.hash & oldCap
來判斷是否需要移動。
看下圖,n
為 table
的長度,圖 (a)
為擴容前的 key1
和 key2
確定索引位置的示例,圖 (b)
為擴容後的 key1
和 key2
確定索引位置的示例,其中 key1(hash1)
是 key1
對應的雜湊與高位運算的結果:
元素在重新計算 hash
後,因為 n
變為 2
倍,那麼 n - 1
的 mask
的範圍(紅色)在高位多 1bit
,因此新的 index
就會這樣變化:
因此,在擴容時,只需看看原來的 hash
值新增的 bit
位是 1
還是 0
,如果是 0
,索引不變,否則變成 "原索引 + oldCapacity
",可以看看下圖 16
擴充為 32
的示意圖:
entrySet 方法
HashMap
的一種遍歷方式就是使用 entrySet
方法返回的迭代器進行遍歷。先來看一下 entrySet
方法:
public Set<Map.Entry<K,V>> entrySet() {
Set<Map.Entry<K,V>> es;
return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}
複製程式碼
可以看到,如果快取 map
中鍵值對的 Set
不為 null
,則直接返回,否則會建立一個 EntrySet
物件。
EntrySet
類的 iterator
方法會返回一個 EntryIterator
迭代器物件,另外還有兩個迭代器 KeyIterator
、ValueIterator
:
final class EntryIterator extends HashIterator
implements Iterator<Map.Entry<K,V>> {
public final Map.Entry<K,V> next() { return nextNode(); }
}
final class KeyIterator extends HashIterator
implements Iterator<K> {
public final K next() { return nextNode().key; }
}
final class ValueIterator extends HashIterator
implements Iterator<V> {
public final V next() { return nextNode().value; }
}
複製程式碼
它們三個都繼承自 HashIterator
,分別用於鍵遍歷、值遍歷、鍵值對遍歷,它們都重寫了 Iterator
的 next
方法,其中呼叫了 HashIterator
的 nextNode
方法。
而 HashIterator
是一個抽象類,實現了迭代器的大部分方法:
abstract class HashIterator {
Node<K,V> next; // next entry to return
Node<K,V> current; // current entry
int expectedModCount; // for fast-fail
int index; // current slot
HashIterator() {
expectedModCount = modCount;
Node<K,V>[] t = table;
current = next = null;
index = 0;
if (t != null && size > 0) { // advance to first entry
do {} while (index < t.length && (next = t[index++]) == null);
}
}
public final boolean hasNext() {
return next != null;
}
final Node<K,V> nextNode() {
Node<K,V>[] t;
Node<K,V> e = next;
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
if ((next = (current = e).next) == null && (t = table) != null) {
do {} while (index < t.length && (next = t[index++]) == null);
}
return e;
}
public final void remove() { ··· }
}
複製程式碼
可以看出 HashIterator
迭代器的預設構造器中,將 current
設定為 null
,然後迴圈在陣列中查詢不為 null
的桶, 讓 next
指向第一個桶中的第一個節點 Node
。
在遍歷時,next
方法會呼叫 nextNode()
方法,這個方法首先把 next
賦給 e
以稍後返回,並把 e
賦給 current
。然後判斷 next
是否為空,如果不為空,返回 e
即可。
如果為空,就在陣列中繼續查詢不為空的桶,找到後退出迴圈,最後返回 e
。這樣就能都遍歷出來了。
小結
HashMap
的特點主要有:
HashMap
根據鍵的hashCode
值來儲存資料,大多數情況下可以直接定位它的值,因而訪問速度很快。HashMap
不保證插入的順序。- 擴容是一個特別耗能的操作,在使用
HashMap
時,最好估算map
的大小,初始化時給定一個大致的數值,避免進行頻繁的擴容。 threshold = capacity * loadFactor;
如果儲存元素個數大於threshold
,就要進行擴容,擴容後的容量是之前的兩倍。- 預設的負載因子
0.75
是時間和空間之間的一個平衡,一般不建議修改。 HashMap
中key
和value
允許為null
,最多允許一條記錄的鍵為null
,允許多條記錄的值為null
。- 它是非執行緒安全的。如果需要執行緒安全,可以使用
Collections
的synchronizedMap
方法使HashMap
具有執行緒安全的能力,或使用ConcurrentHashMap
。
參考資料
- 美團技術團隊:Java8系列之重新認識HashMap
- 潘威威:Java8原始碼-HashMap