上一篇文章中提到了ThreadLocalMap是使用開放地址法來解決衝突問題的,而我們今天的主角HashMap是採用了連結串列法來處理衝突的,什麼是連結串列法呢?
在雜湊表中,每個 “ 桶(bucket)” 或者 “ 槽(slot)” 會對應一條連結串列,所有雜湊值相同的元素我們都放到相同槽位對應的連結串列中。
jdk8和jdk7不一樣,jdk7中沒有紅黑樹,陣列中只掛載連結串列。而jdk8中在桶容量大於等於64且連結串列節點數大於等於8的時候轉換為紅黑樹。當紅黑樹節點數量小於6時又會轉換為連結串列。
插入
但插入的時候,我們只需要通過雜湊函式計算出對應的槽位,將其插入到對應連結串列或者紅黑樹即可。如果此時元素數量超過了一定值則會進行擴容,同時進行rehash.
查詢或者刪除
通過雜湊函式計算出對應的槽,然後遍歷連結串列或者刪除
連結串列為什麼會轉為紅黑樹?
上一篇文章有提到過通過裝載因子來判定空閒槽位還有多少,如果超過裝載因子的值就會動態擴容,HashMap會擴容為原來的兩倍大小(初始容量為16,即槽(陣列)的大小為16)。但是無論負載因子和雜湊函式設得再合理,也避免不了連結串列過長的情況,一旦連結串列過長查詢和刪除元素就比較耗時,影響HashMap效能,所以JDK8中對其進行了優化,當連結串列長度大於等於8的時候將連結串列轉換為紅黑樹,利用紅黑樹的特點(查詢、插入、刪除的時間複雜度最壞為O(logn)),可以提高HashMap的效能。當節點個數少於6個的時候,又會將紅黑樹轉化為連結串列。因為在資料量較小的情況下,紅黑樹要維持平衡,比起連結串列來,效能上的優勢並不明顯,而且編碼難度比連結串列要大上不少。
原始碼分析
構造方法以及重要屬性
public HashMap(int initialCapacity, float loadFactor);
public HashMap(int initialCapacity);
public HashMap();
複製程式碼
HashMap的構造方法中可以分別指定初始化容量(bucket大小)以及負載因子,如果不指定預設值分別是16和0.75.它幾個重要屬性如下:
// 初始化容量,必須要2的n次冪
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 負載因子預設值
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 需要從連結串列轉換為紅黑樹時,連結串列節點的最小長度
static final int TREEIFY_THRESHOLD = 8;
// 轉換為紅黑樹時陣列的最小容量
static final int MIN_TREEIFY_CAPACITY = 64;
// resize操作時,紅黑樹節點個數小於6則轉換為連結串列。
static final int UNTREEIFY_THRESHOLD = 6;
// HashMap閾值,用於判斷是否需要擴容(threshold = 容量*loadFactor)
int threshold;
// 負載因子
final float loadFactor;
// 連結串列節點
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
// 儲存資料的陣列
transient Node<K,V>[] table;
// 紅黑樹節點
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
}
複製程式碼
上面的table就是儲存資料的陣列(可以叫做桶或者槽),陣列掛載的是連結串列或者紅黑樹。值得一提的是構造HashMap的時候並沒有初始化陣列容量,而是在第一次put元素的時候才進行初始化的。
hash函式的設計
int hash = (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
int index = hash & (tab.length-1);
複製程式碼
從上面可以看出,key為null是時候放到陣列中的第一個位置的,我們一般定位key應當存放在陣列哪個位置的時候一般是這樣做的 key.hashCode() % tab.length
。但是當tab.length是2的n次冪的時候,就可以轉換為 A % B = A & (B-1)
;所以 index = hash & (tab.length-1)
就可以理解了。
這裡是使用了除留餘數法的理念來設計的,可以可能減少hash衝突 除留餘數法 : 用關鍵字K除以某個不大於hash表長度m的數p,將所得餘數作為hash表地址 比如x/8=x>>3,即把x右移3位,得到了x/8的商,被移掉的部分(後三位),則是x%8,也就是餘數。
而對於hash值的運算為什麼是(h = key.hashCode()) ^ (h >>> 16)
呢?也就是為什麼要向右移16位呢?直接使用 key.hashCode() & (tab.length -1)
不好嗎?
如果這樣做,由於tab.length肯定是遠遠小於hash值的,所以位運算的時候只有低位才參與運算,而高位毫無作為,會帶來hash衝突的風險。
而hashcode本身是一個32位整形值,向右移位16位之後再進行異或執行計算出來的整形將具有高位和低位的性質,就可以得到一個非常隨機的hash值,在通過除留餘數法,得到的index就更低概率的減少了衝突。
插入資料
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 1. 如果陣列未初始化,則初始化陣列
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2. 如果當前節點未被插入資料(未碰撞),則直接new一個節點進行插入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 3. 碰撞了,已存在相同的key,則進行覆蓋
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
// 4. 碰撞後發現為樹結構,則掛載在樹上
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
// 5. 進行尾插入,如果連結串列節點數達到上線則轉換為紅黑樹
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 6. 連結串列中碰撞了
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 7. 用新value替換舊的value
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 8. 操作閾值則進行擴容
if (++size > threshold)
resize();
// 給LinkedHashMap實現
afterNodeInsertion(evict);
return null;
}
複製程式碼
簡述下put的邏輯,它主要分為以下幾個步驟:
- 首先判斷是否初始化,如果未初始化則初始化陣列,初始容量為16
- 通過hash&(n-1)獲取陣列下標,如果該位置為空,表示未碰撞,直接插入資料
- 發生碰撞且存在相同的key,則在後面處理中直接進行覆蓋
- 碰撞後發現為樹結構,則直接掛載到紅黑樹上
- 碰撞後發現為連結串列結構,則進行尾插入,當連結串列容量大於等於8的時候轉換為樹節點
- 發現在連結串列中進行碰撞了,則在後面處理直接覆蓋
- 發現之前存在相同的key,只直接用新值替換舊值
- map的容量(儲存元素的數量)大於閾值則進行擴容,擴容為之前容量的2倍
擴容
resize()方法中,如果發現當前陣列未初始化,則會初始化陣列。如果已經初始化,則會將陣列容量擴容為之前的兩倍,同時進行rehash(將舊陣列的資料移動到新的陣列).JDK8的rehash過程很有趣,相比JDK7做了不少優化,我們來看下這裡的rehash過程。
// 陣列擴容為之前2倍大小的程式碼省略,這裡主要分析rehash過程。
if (oldTab != null) {
// 遍歷舊陣列
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 1. 如果舊陣列中不存在碰撞,則直接移動到新陣列的位置
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 2. 如果存在碰撞,且節點型別是樹節點,則進行樹節點拆分(掛載到擴容後的陣列中或者轉為連結串列)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 3. 處理衝突是連結串列的情況,會保留原有節點的順序
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 4. 判斷擴容後元素是否在原有的位置(這裡非常巧妙,下面會分析)
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 5. 元素不是在原有位置
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 6. 將擴容後未改變index的元素複製到新陣列
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 7. 將擴容後改變了index位置的元素複製到新陣列
if (hiTail != null) {
hiTail.next = null;
// 8. index改變後,新的下標是j+oldCap,這裡也很巧妙,下面會分析
newTab[j + oldCap] = hiHead;
}
}
}
}
}
複製程式碼
上面的程式碼中展現了整個rehash的過程,先遍歷舊陣列中的元素,接著做下面的事情
- 如果舊陣列中不存在資料碰撞(未掛載連結串列或者紅黑樹),那麼直接將元素賦值到新陣列中,其中
index=e.hash & (newCap - 1)
。 - 如果存在碰撞,且節點型別是樹節點,則進行樹節點拆分(掛載到擴容後的陣列中或者轉為連結串列)
- 如果存在碰撞,且節點是連結串列,則處理連結串列的情況,rehash過程會保留節點原始順序(JDK7中不會保留,這也是導致jdk7中多執行緒出現死迴圈的原因)
- 判斷元素在擴容後是否還處於原有的位置,這裡通過
(e.hash & oldCap) == 0
判斷,oldCap表示擴容前陣列的大小。 - 發現元素不是在原有位置,更新hiTail和hiHead的指向關係
- 將擴容後未改變index的元素複製到新陣列
- 將擴容後改變了index位置的元素複製到新陣列,新陣列的下標是
j + oldCap
。
其中第4點和第5點中將連結串列的元素分為兩部分(do..while部分),一部分是rehash後index未改變的元素,一部分是index被改變的元素。分別用兩個指標來指向頭尾節點。
比如當oldCap=8時,1-->9-->17都掛載在tab[1]上,而擴容後,1-->17掛載在tab[1]上,9掛載在tab[9]上。
那麼是如何確定rehash後index是否被改變呢?改變之後的index又變成了多少呢?
這裡的設計很是巧妙,還記得HashMap中陣列大小是2的n次冪嗎?當我們計算索引位置的時候,使用的是 e.hash & (tab.length -1)。
這裡我們討論陣列大小從8擴容到16的過程。
tab.length -1 = 7 0 0 1 1 1
e.hashCode = x 0 x x x x
==============================
0 0 y y y
複製程式碼
可以發現在擴容前index的位置由hashCode的低三位來決定。那麼擴容後呢?
tab.length -1 = 15 0 1 1 1 1
e.hashCode = x x x x x x
==============================
0 z y y y
複製程式碼
擴容後,index的位置由低四位來決定,而低三位和擴容前一致。也就是說擴容後index的位置是否改變是由高位元組來決定的,也就是說我們只需要將hashCode和高位進行運算即可得到index是否改變。
而剛好擴容之後的高位和oldCap的高位一樣。如上面的15二進位制是1111,而8的二進位制是1000,他們的高位都是一樣的。所以我們通過e.hash & oldCap運算的結果即可判斷index是否改變。
同理,如果擴容後index該變了。新的index和舊的index的值也是高位不同,其新值剛好是 oldIndex + oldCap的值。所以當index改變後,新的index是 j + oldCap。
至此,resize方法結束,元素被插入到了該有的位置。
get()
get()的方法就相對來說要簡單一些了,它最重要的就是找到key是存放在哪個位置
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 1. 首先(n-1) & hash確定元素位置
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 2. 判斷第一個元素是否是我們需要找的元素
if (first.hash == hash &&
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
// 3. 節點如果是樹節點,則在紅黑樹中尋找元素
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
4. 在連結串列中尋找對應的節點
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
複製程式碼
remove
remove方法尋找節點的過程和get()方法尋找節點的過程是一樣的,這裡我們主要分析尋找到節點後是如何處理的
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
// 1. 刪除樹節點,刪除時如果不平衡會重新移動節點位置
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
// 刪除的節點是連結串列第一個節點,則直接將第二個節點賦值為第一個節點
else if (node == p)
tab[index] = node.next;
// 刪除的節點是連結串列的中間節點,這裡的p為node的prev節點
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
複製程式碼
remove方法中,最為複雜的部分應該是removeTreeNode部分,因為刪除紅黑樹節點後,可能需要退化為連結串列節點,還可能由於不滿足紅黑樹特點,需要移動節點位置。 程式碼也比較多,這裡就不貼上來了。但也因此佐證了為什麼不全部使用紅黑樹來代替連結串列。
JDK7擴容時導致的死迴圈問題
/**
* Transfers all entries from current table to newTable.
*/
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
// B執行緒執行到這裡之後就暫停了
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
// 會把元素放到連結串列頭,所以擴容後資料會被倒置
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
複製程式碼
擴容時上面的程式碼容易導致死迴圈,是怎樣導致的呢?假設有兩個執行緒A和B都在執行這一段程式碼,陣列大小由2擴容到4,在擴容前tab[1]=1-->5-->9。
當B執行緒執行到 next = e.next時讓出時間片,A執行緒執行完整段程式碼但是還沒有將內部的table設定為新的newTable時,執行緒B繼續執行。
此時A執行緒執行完成之後,掛載在tab[1]的元素是9-->5-->1,注意這裡的順序被顛倒了。此時e = 1, next = 5;
tab[i]的按照迴圈次數變更順序, 1. tab[i]=1, 2. tab[i]=5-->1, 3. tab[i]=9-->5-->1
同樣B執行緒我們也按照迴圈次數來分析
- 第一次迴圈執行完成後,newTable[i]=1, e = 5
- 第二次迴圈完成後: newTable[i]=5-->1, e = 1。
- 第三次迴圈,e沒有next,所以next指向null。當執行e.next = newTable[i](1-->5)的時候,就形成了 1-->5-->1的環,再執行newTable[i]=e,此時newTable[i] = 1-->5-->1。
當在陣列該位置get尋找對應的key的時候,就發生了死迴圈,引起CPU 100%問題。
而JDK8就不會出現這個問題,它在這裡就有一個優化,它使用了兩個指標來分別指向頭節點和尾節點,而且還保證了元素原本的順序。 當然HashMap仍然是不安全的,所以在多執行緒併發條件下推薦使用ConcurrentHashMap。
你的點贊是對我最大的支援,當然你關注我就更好了