注:感謝 美團點評技術團隊 的分享~~,博客部分內容摘抄自其中。侵刪!
今天我們來探究一下 HashMap 的內部實現機制。
明確 JDK 1.8 中的 HashMap 使用數組 + 鏈表 + 紅黑樹的結構進行實現。
HashMap 的底層思想主要是哈希表,我們來看看 Java 的設計者們是怎麼使用數組 + 鏈表 + 紅黑樹設計出 HashMap 的。
HashMap的基本屬性
既然是用哈希表進行實現,那麼基本的數據結構就是數組了,HashMap 部分源碼如下:
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
transient Node<K,V>[] table; // HashMap 底層數據結構(Node 數組)
transient int size; // HashMap 中實際存在的鍵值對數量
transient int modCount; // 記錄 HashMap 內部結構發生變化的次數,用於快速失敗機制
int threshold; // 所能容納的 key-value 對極限(我將之稱爲“負載”)
final float loadFactor; // 負載因子:默認 0.75
}
除了 table 數組之外,我將源碼中的常用字段也貼了出來。對於上面的代碼,我們需要注意以下幾點:
- 不瞭解 AbstractMap<K,V> 抽象類、Map<K,V>, Cloneable, Serializable 接口的請自行百度
- transient 關鍵字:阻止本字段進行序列化(具體使用請自行百度)
- threshold = length(哈希表長度) * loadFactor
- modCount 記錄的是 HashMap 內部結構發生變化的次數,內部結構發生變化指的是結構發生變化,例如 put 新鍵值對,但是某個 key 對應的 value 值被覆蓋不屬於結構變化。
有了對 table 數組的認識,那麼我們用一張圖來描述一下 HashMap 中的哈希表結構(來自 “美團點評技術團隊” 侵刪):
瞭解了 HashMap 中的成員變量,再來看一下 HashMap 中定義的常量:
// 默認的初始容量,必須是2的冪。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大容量(必須是 2 的冪且小於 2 的 30 次方,傳入容量過大將被這個值替換)
static final int MAXIMUM_CAPACITY = 1 << 30;
// 裝載因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// JDK1.8特有
// 當 hash 值相同的記錄超過 TREEIFY_THRESHOLD,會動態的使用一個專門的紅黑樹實現來代替鏈表結構,使得查找時間複雜度從 O(n) 變爲 O(logn)
static final int TREEIFY_THRESHOLD = 8;
// JDK1.8特有
// 也是閾值,同上一個相反,當桶(bucket)上的鏈表數小於 UNTREEIFY_THRESHOLD 時紅黑樹轉鏈表
static final int UNTREEIFY_THRESHOLD = 6;
// JDK1.8特有
// 樹的最小的容量,至少是 4 x TREEIFY_THRESHOLD = 32 然後爲了避免(resizing 和 treeification thresholds) 設置成64
static final int MIN_TREEIFY_CAPACITY = 64;
HashMap中的Node元素
現在,我們關心的是 table 數組中 Node 元素的實現,源碼如下:
// 靜態內部類、操縱了 Map 接口中的 Entry<K,V> 接口
static class Node<K,V> implements Map.Entry<K,V> {
// key 所產生的 hash 值 (不變)
final int hash;
// key (不變)
final K key;
// value
V value;
// 指向下一個 Node 節點、(鏈地址法解決衝突)
Node<K,V> next;
// 構造函數
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
// 這些方法都不可被重寫
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
// 計算 key 所產生的 hash 碼
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
// 設置新值,返回舊值
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
// 比較兩個對象是否相等
public final boolean equals(Object o) {
if (o == this)
return true;
// 是否都操作 Map.Entry 接口
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;
}
}
在這裏我們需要注意:
- Node 的實現是一個靜態內部類,有關內部類與靜態內部類的理解,請查看我的知乎回答:爲什麼Java內部類要設計成靜態和非靜態兩種?
- hash 值與 key 的不變性:即使在 HashMap 中對 key 及 hash 做了final 關鍵字的約束,但是我們還是需要注意,最好使用不變對象作爲 key。
首先我們來了解一下 final 關鍵字在基本類型與引用類型的使用上有什麼不同?
- 當 final 修飾基本變量類型時,不能對基本類型變量重新賦值,因此基本類型變量不能被改變。
- 當 final 修飾引用類型變量時,final 只保證這個引用類型變量所引用的地址不會改變,即一直引用同一個對象,但是這個對象(對象的非 final 成員變量的值可以改變)完全可以發生改變。
再來討論,我們在使用 HashMap 時,爲什麼最好選用不可變對象作爲 key。
來看一下選用可變對象作爲 HashMap 的 key 有可能會造成什麼影響?
import java.util.HashMap;
import java.util.Map;
public class MutableDemo1 {
public static void main(String[] args) {
Map<MutableKey, String> map = new HashMap<>();
MutableKey key = new MutableKey(10, 20);
map.put(key, "Robin");
System.out.println(map.get(key));
key.setI(30);
System.out.println(map.get(key));
}
}
輸出:
Robin
null
爲什麼最好不要使用可變對象作爲 HashMap 的 key,結論:
如果 key 對象是可變的,那麼 key 的哈希值就可能改變。在 HashMap 中可變對象作爲 key 會造成數據丟失。
怎麼解決?
- 在 HashMap 中,儘量使用 String、Integer 等不可變類型用作 key。
- 重寫自定義類的 hashcode 方法,保證在成員變量改變的同時該對象的哈希值不變即可。(具體實現參見:HashMap 的 key 可以是可變的對象嗎?)
HashMap中的put方法
Hash值的計算
我們對 HashMap 的基本組成結構已經有了完整的認識,接下來我們分析 HashMap 中最常用的方法之一:put()
。
直接上源碼:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
在分析 putVal 的源碼之前,我們先來看看 hash(key)
:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
key 的 hash 值就是這樣得到的,key.hashCode()
是一個本地方法,具體實現在源碼中並沒有給出,但這並不是重點,我們需要注意的是在計算出 hash 值後,它又與本身的高 16 位進行了異或。(hash 值本身是 32 位)
爲什麼這樣做?這樣做的好處是什麼呢?
主要是從速度、功效、質量來考慮的,這麼做可以在數組 table 的 length 比較小的時候,也能保證考慮到高低 Bit 都參與到 Hash 的計算中,同時不會有太大的開銷。在混合了原始 hashCode 值的高位和低位後,加大了低位的隨機性,而且混合後的低位摻雜了高位的部分特徵,這樣高位的信息也被變相保留下來,這就使得 hash 方法返回的值,具有更高的隨機性,減少了衝突。
下面舉例說明,n 爲 table 的長度(假設爲 16)。
put方法的解析
在分析 put 方法的源碼之前,我們先來看一張有關 put 方法執行過程的圖解,來自 美團點評技術團隊,侵刪~
根據圖片我們再對 put 方法的執行流程做一個總結,方便等下閱讀源碼:
- 判斷鍵值對數組 table 是否爲空或爲 null,否則執行 resize() 進行擴容;
- 根據鍵值 key 計算 hash 值得到插入的數組索引 i,如果 table[i] == null,直接新建節點添加,轉向 6,如果 table[i] 不爲空,轉向 3;
- 判斷 table[i] 的首個元素是否和 key 一樣,如果相同直接覆蓋 value,否則轉向 4,這裏的相同指的是 hashCode 以及 equals;
- 判斷 table[i] 是否爲 treeNode,即 table[i] 是否是紅黑樹,如果是紅黑樹,則直接在樹中插入鍵值對,否則轉向 5;
- 遍歷 table[i],判斷鏈表長度是否大於 8,大於 8 的話把鏈表轉換爲紅黑樹,在紅黑樹中執行插入操作,否則進行鏈表的插入操作;遍歷過程中若發現 key 已經存在直接覆蓋 value 即可;
- 插入成功後,判斷實際存在的鍵值對數量 size 是否超過了負載 threshold,如果超過,進行擴容。
putVal 方法源碼:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 哈希表爲null || 哈希表的長度爲 0(resize 也是一個經典的方法)
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// (n - 1) & hash 計算出 key 在哈希表中應該存儲的位置(除留餘數法,使用 & 運算 比 % 運算更快)
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 插入的 key 在 HashMap 中已經存在(之後進行 value 的直接覆蓋)
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 產生衝突,當前節點爲紅黑樹節點
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 普通節點,使用鏈地址法進行處理
else {
// 遍歷鏈表(插入新節點之後,判斷鏈表長度)
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 當處理衝突的鏈節點數大於等於 8 的時候,轉換紅黑樹
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
// 插入的 key 在 HashMap 中已經存在
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// key 已經存在,直接覆蓋舊值
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount; // 記錄 HashMap 內部結構發生變化的次數,用於快速失敗機制
if (++size > threshold)
resize(); // 擴容
afterNodeInsertion(evict); // 作用不明確
return null;
}
put 方法分析到這裏基本上就結束了,但是我們同樣有兩個值得思考的問題:
- 哈希表索引定位:
(n - 1) & hash
;- 擴容機制:
resize()
。
關於紅黑樹與快速失敗機制,不在這篇博客中進行講述。
索引定位
你不覺得以(n - 1) & hash
這種方式定位元素在哈希表中的位置很有趣嗎?
本質上,它還是“除留餘數法”,只不過由於位運算的緣故,會比取模運算要高效許多。
但是使用這種方法有一個前提,就是哈希表 table 的長度 n 必須滿足 2 冪次方,因爲 n-1 對應的二進制就是前面全是 0,後面全是 1,相與後,只留下 hash 的後幾位,正好在長度爲 n 的數組下標範圍內。
舉個例子,假設 hash 值爲 3,數組長度 n 爲 16,那麼我們使用取模運算得到:3 % 16 = 3
,使用 & 運算:0011 & (16 - 1)
即 0011 & 1111 = 0011
得到的還是 3。
而在 HashMap 中,哈希表 table 的默認初始值也爲 16(源碼如下):
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
擴容機制
我們不談紅黑樹,但必須探究包含在 put 方法中的 resize(擴容)機制。瞭解過 resize 方法之後,你會感嘆其設計之巧妙!
首先,對擴容機制做一個簡單的介紹:
擴容(resize)就是重新計算容量,向 HashMap 對象裏不停的添加元素,而 HashMap 對象內部的數組無法裝載更多的元素時,對象就需要擴大數組的長度,以便能裝入更多的元素。如果 `HashMap 的實際大小 > 負載,則 HashMap 中的 table 的容量擴充爲當前的一倍。容量翻倍後,重新計算每個 Node 的 index,將有限的元素映射到更大的數組中,減少 hash 衝突的概率。
我將擴容機制分爲了兩部分:1. 創建新的 table 數組;2. 對元素進行 rehash。
創建新的 table 數組,過程還是比較簡單的:
(1)原 table 數組的大小已經最大,無法擴容,則修改 threshold 的大小爲 Integer.MAX_VALUE。產生的效果就是隨你碰撞,不再擴容;
(2)原 table 數組正常擴容,更新 newCap(新數組的大小) 與 newThr(新數組的負載);
(3)原 table 數組爲 null || length 爲 0,則擴容使用默認值;
(4)原 table 數組的大小在擴容後超出範圍,將 threshold 的大小更改爲 Integer.MAX_VALUE。
我們先截取第一部分(創建新數組)的源碼進行研究:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
// 超過最大值就不再擴充,隨你去碰撞(將 threshold 設置爲 Integer.MAX_VALUE,則不會產生擴容)
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 擴容成功,更新 newCap 與 newThr 的大小(2 倍擴展)
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
}
// !!!對應的哪種情況?
else if (oldThr > 0)
newCap = oldThr;
// oldCap == 0 || oldTab == null
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 擴容失敗(擴容後 newCap >= MAXIMUM_CAPACITY)
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE);
}
// 更新負載的值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// rehash 的過程
... ...
return newTab;
}
JDK 1.7中的rehash
直接閱讀 JDK 1.8 中的 rehash 過程讓人有點頭大,爲了便於理解,我們先來看看 JDK 1.7 中的 rehash ,總體來說,兩個版本差別不大:
void transfer(Entry[] newTable) {
Entry[] src = table; // src 引用了舊的 Entry 數組
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) { // 遍歷舊的 Entry 數組
Entry<K,V> e = src[j]; // 取得舊 Entry 數組的每個元素
if (e != null) {
src[j] = null; // 釋放舊 Entry 數組的對象引用(for 循環後,舊的 Entry 數組不再引用任何對象)
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity); // 重新計算每個元素在數組中的位置
e.next = newTable[i]; // 頭插法
newTable[i] = e; // 將元素放在數組上
e = next; // 訪問下一個 Entry 鏈上的元素
} while (e != null);
}
}
}
爲了方便大家的理解,下面舉個例子說明下擴容過程:
注:JDK 1.7 中的 put 方法使用的是頭插法進行新節點的插入,在 JDK 1.8 中,則使用的是尾插法(見上述源碼)。對 JDK 1.7 put 方法感興趣的同學可自行查閱有關資料。
假設我們的 hash 算法就是簡單的用 key mod 一下表的大小。其中的哈希桶數組 table 的 size = 2,key = 3、7、5,put 順序依次爲 5、7、3(JDK 1.7 頭插法)。在 mod 2 以後都衝突在
table[1]
這裏了。這裏假設負載因子 loadFactor=1,即當鍵值對的實際大小 size 大於 table 的負載(threshold)時進行擴容。接下來的步驟是哈希桶數組 resize 成 4,然後所有的 Node 重新 rehash 的過程。
JDK 1.8中的rehash
JDK 1.8 中的 rehash 過程與 JDK 1.7 大同小異,相比 JDK 1.7, 它主要對重新定位元素在哈希表中的位置做了優化:
經過觀測可以發現,我們使用的是2次冪的擴展(指長度擴爲原來2倍),所以,元素的位置要麼是在原位置,要麼是在原位置再移動2次冪的位置。看下圖可以明白這句話的意思,n 爲 table 的長度,圖(a)表示擴容前的 key1 和 key2 兩種 key 確定索引位置的示例,圖(b)表示擴容後 key1 和 key2 兩種 key 確定索引位置的示例,其中hash1是key1對應的哈希與高位運算結果。
table 在擴容之後,因爲 n 變爲 2 倍,那麼 n-1 的 mask 範圍在高位多 1bit(紅色),因此新的 index 就會發生這樣的變化:
因此,我們在擴充 HashMap 的時候,不需要像 JDK1.7 的實現那樣重新計算 hash,只需要看看原來的 hash 值新增的那個 bit 是 1 還是 0 就好了,是 0 的話索引沒變,是 1 的話索引變成“原索引 + oldCap”。
瞭解了 JDK 1.8 相比 JDK 1.7 所做的優化之後,我們再看一下 JDK 1.8 中的 rehash 過程:
final Node<K,V>[] resize() {
... ...
// rehash 的過程
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
// 釋放舊 Node 數組的對象引用(for循環後,舊的 Node 數組不再引用任何對象)
oldTab[j] = null;
// oldTab[j] 只有一個元素,直接進行 rehash
if (e.next == null)
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;
// 原索引 + oldCap(頭指針與尾指針)
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 對元素進行 rehash 的過程
do {
next = e.next;
// 原索引(尾插法)
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 原索引 + oldCap(尾插法)
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 將建立的鏈表放到新 table 數組合適的位置上
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
HashMap的線程安全性
在多線程使用場景中,應該儘量避免使用線程不安全的 HashMap,而使用線程安全的 ConcurrentHashMap。那麼爲什麼說 HashMap 是線程不安全的,下面舉例子說明在併發的多線程使用場景中使用 HashMap 可能造成死循環。代碼例子如下(便於理解,仍然使用JDK1.7的環境):
public class HashMapInfiniteLoop {
private static HashMap<Integer,String> map = new HashMap<Integer,String>(2,0.75f);
public static void main(String[] args) {
map.put(5, "C");
new Thread("Thread1") {
public void run() {
map.put(7, "B");
System.out.println(map);
};
}.start();
new Thread("Thread2") {
public void run() {
map.put(3, "A);
System.out.println(map);
};
}.start();
}
}
其中,map 初始化爲一個長度爲 2 的數組,loadFactor = 0.75,threshold = 2 * 0.75 = 1,也就是說當 put 第二個 key 的時候,map 就需要進行 resize。
通過設置斷點讓線程1和線程2同時 debug 到 transfer 方法的首行。注意此時兩個線程已經成功添加數據。放開 thread1 的斷點至 transfer 方法的Entry next = e.next
這一行;然後放開線程2的的斷點,讓線程2進行 resize。結果如下圖。
newTable 是局部變量,所以兩個線程都有自己擴容後開闢的新的 table 數組。(對應圖中橙色與紫色方塊)
注意,由於 Thread1 執行到了Entry next = e.next
這一行,因此 e 指向了 key(3),而 next 指向了 key(7),其在線程二 rehash 後,指向了線程二重組後的鏈表(rehash 之後,會將 newtable 賦值給 HashMap 的成員變量 table)。
接着下一部分:
線程一被調度回來執行,先是執行 newTalbe[i] = e(對應圖中 thread1 的索引 3 處指向了 thread2 中 索引 3 處的 key = 3 的節點(thread2 中的 table 此時已經是成員變量了,因此共享)), 然後是 e = next,導致了 e 指向了 key(7),而下一次循環的 next = e.next 導致了 next 指向了 key(3)。
當 next 指向 key(3) 的時候,e 爲 key(7),又經過一次循環後,結果如下圖:
虛線也表示有引用指向 key(7),只不過是想將 thread1 所擁有的 table 與 成員變量 table 區分開。
此時再更新 e 與 next 的值,e 爲 key(3),next 爲 null,因此下一次循環就是最後一次循環。經過下一次循環之後,由於 e.next = newTable[i] 導致 key(3).next 指向了 key(7),而此時的 key(7).next 已經指向了 key(3),環形鏈表就此形成。結果如下圖:
於是,當我們用線程一調用 map.get(11) 時,悲劇就出現了——無限循環。
博主將這塊內容看了好幾遍,確實不好理解,如果大家對這部分內容還有任何疑惑的話,歡迎在評論區進行提問~~
總結
- 明白靜態內部類 Node 的相關實現,清楚 HashMap 的底層實現是有關 Node 的 table 數組(哈希表)。
- 注意使用 HashMap 時最好使用不變的對象作爲 key。
- 注意 HashMap 計算 key 的 hash 值時,使用了低位與高位異或的方式,返回最終的 hashcode。
- 瞭解 HashMap 中的定位方式:
(n - 1) & hash
。 - 在 HashMap 中使用鏈地址法解決衝突,並且當鏈表的節點個數大於 8 的時候,會轉換爲紅黑樹。(JDK 1.8 新特性)
- JDK 1.8 中使用尾插法進行 put 與 resize,JDK 1.7 中使用頭插法進行 put 與 resize。
- JDK 1.8 中的 rehash 過程不用重新計算元素的哈希值,因爲元素的位置只有兩種情況:原位置 與 原位置 + 原本哈希表的長度。
- 清楚多線程環境下使用 HashMap 可能會造成的一種錯誤—形成環形鏈表。
在 Java 8系列之重新認識HashMap 這篇文章中,美團點評技術團隊還對 JDK 1.8 與 JDK 1.7 做了性能上的比較,有興趣的同學可以自行查閱!