每當你想要努力一把的時候,都是未來的你在求救!!!
1. 概述
HashMap
是我們開發中很常用的一個鍵值對集合。底層基於雜湊演算法實現,HashMap
允許 Null 值和 Null 鍵,並且鍵不能重複(重複會被覆蓋),計算鍵的 Hash 值時 Null 鍵的雜湊值是 0。另外,HashMap
不保證插入順序,並且 HashMap
是非執行緒安全的,在多執行緒下可能會導致一些問題,如:
HashMap
進行put操作會引起死迴圈,導致CPU利用率接近100%(後面有解釋為什麼會死迴圈哦);- 多執行緒本來一共要 put 進1000個不同的鍵,結果只 put 進了 998個。
2. 原理
HashMap
底層是基於拉鍊式的雜湊演算法實現的,在 JDK1.7
中是陣列+連結串列,在JDK1.8
中是 陣列+連結串列+紅黑樹,使用紅黑樹優化過長的連結串列。JDK1.8
中資料結構示意圖:
在JDK1.8
中是 陣列+連結串列+紅黑樹。在對HashMap
進行增刪查操作時,先要定位到元素所在桶陣列的位置,之後再通連結串列或者紅黑樹中定位對應的元素,比如現在要查詢上圖中的元素 35,就有以下步驟:
- 定位元素 3 所在的桶陣列的位置,index = 35 % 16 = 3;所以元素在陣列下標為 3 的位置
- 在 3 號下標的位置是一個連結串列,然後遍歷連結串列查詢元素 3
HashMap
的大致原理就是這樣,下面再看看HashMap
的原始碼分析。
3. 原始碼分析
3.1 構造方法分析
3.1.1 構造方法原始碼
構造方法只會初始化一些重要的變數,比如負載因子和容量。底層的資料結構(如桶陣列)是延遲在put
操作時進行初始化的。
// 構造方法 1
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
// 構造方法 2
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// 構造方法 3
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
// 構造方法 4
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
構造方法 1 是我們平時用的最多的,它預設初始容量為 16,負載因子是 0.75,閾值=初始容量*負載因子
。
構造方法 2 是自定義了初始容量,這裡負載因子還是使用預設的 0.75,這裡如果自定義的初始容量不是 2 的 n 次冪,會有一個坑,後面講解。
構造方法 3 是自定義了初始容量和負載因子。
構造方法 4 是將另一個 Map 中的對映拷貝一份到自己的儲存結構中來,這個方法不是很常用。
3.1.2 初始容量,負載因子,閾值
三個名詞介紹:
initialCapacity
:HashMap 初始容量,預設 16
loadFactor
:負載因子,預設 0.75
threshold
:當前 HashMap
所能容納鍵值對數量的最大值,超過這個值,則需擴容,等於 initialCapacity*loadFactor
原始碼如下:
// The default initial capacity - MUST be a power of two.
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
// The load factor used when none specified in constructor.
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// The next size value at which to resize (capacity * load factor).
int threshold;
// The load factor for the hash table.
final float loadFactor;
3.1.3 經典問題
問題一:為什麼HashMap
的載入因子是 0.75,不是 0.5 或者 1
如果負載因子是 1
如果負載因子是 1,意味著要把 HashMap
填滿之後才擴容,就會發生大量的雜湊衝突,然後連結串列變長,連結串列長度達到8就會轉化成紅黑樹,大量的雜湊衝突還會使紅黑樹更復雜,查詢效率就會變低。總結一句話就是:負載因子越大,空間利用率越高,時間效率就越低
如果負載因子是 0.5
如果負載因子是 0.5,就意味著容量達到一半時就要擴容,雜湊衝突會減少,查詢效率會變高,但是空間利用率就變低了。
如果負載因子是 0.75
負載因子預設 0.75,是時間效率和空間效率的權衡。
問題二:是HashMap
的陣列佔用達到閾值就擴容,還是集合中元素總和達到閾值就擴容
去 debug 一下 HashMap
的原始碼就可以知道了,是集合中元素總和達到閾值就會擴容。這裡我理解的是這樣可以避免小概率的大量雜湊衝突導致紅黑樹複雜度變高吧!
問題三:如果自定義初始化容量為 18,那實際初始化的容量是多少,經歷一次擴容後又是多少
先上結論:實際初始化的容量是 32(閾值是 24),經歷一次擴容之後容量是 64
看上面的構造方法 2 指定了初始化容量,然後構造方法 2是呼叫了構造方法 3,在構造方法 3 中有這麼一行程式碼this.threshold = tableSizeFor(initialCapacity);
,我們來看看tableSizeFor(initialCapacity)
做了什麼:
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
如果看不懂這個方法,沒關係,我們把這個方法拿出來測試一下,測試程式碼如下:
public class TestHashMap {
static final int MAXIMUM_CAPACITY = 1 << 30;
public static void main(String[] args) {
// 這裡傳入任何值 ,最後列印的都是大於傳入值的2的n次冪,
// 如傳入5,返回8;傳入 18,返回 32
System.out.println(tableSizeFor(5));
}
static int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
}
經過上面的測試應該就明白了吧,不管你自定義初始化容量是多大,它都會給你轉化成大於自定義初始化容量,並且最近的一個 2 的 n 次冪。可能有點繞,舉個例子:
- 如果你自定義初始化容量是 -1,它會給你轉成 1(2的0次冪);
- 如果你自定義初始化容量是 5,它會給你轉成 8(2的3次冪);
- 如果你自定義初始化容量是 18,它會給你轉成 32(2的5次冪);
那有人可能要說了,這裡在上面的構造方法中的程式碼this.threshold = tableSizeFor(initialCapacity);
,明明是給 threshold
賦值的, threshold
不是閾值嘛,它不是初始容量啊,初始容量不應該是 threshold/loadFactor
嗎?這個問題就要說HashMap
的資料結構初始化是在put
操作時延遲初始化的了,留在後面講插入(put 方法)的時候說吧(看了擴容的方法就明白了)。
問題四:多執行緒下使用 HashMap
為什麼會出現死迴圈
HashMap
是採用連結串列解決雜湊衝突,因為是連結串列結構,那麼就很容易形成閉合的鏈路,這樣在迴圈的時候只要有執行緒對這個HashMap
進行get
操作就會產生死迴圈。- 在單執行緒情況下,只有一個執行緒對
HashMap
的資料結構進行操作,是不可能產生閉合的迴路的。 - 那就只有在多執行緒併發的情況下才會出現這種情況,那就是在
put
操作的時候,如果size>initialCapacity*loadFactor
,那麼這時候HashMap
就會進行rehash
操作,隨之HashMap
的結構就會發生翻天覆地的變化。很有可能就是在兩個執行緒在這個時候同時觸發了rehash
操作,產生了閉合的迴路。
3.2 插入(put 方法)
3.2.1 補充說明
這裡先補充一下HashMap
的資料結構吧,前面說了在JDK1.8
中,HashMap
是 陣列+連結串列+紅黑樹實現的。看一下資料結構的原始碼:
// 陣列
transient Node<K,V>[] table;
// 連結串列
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
//...
}
// 紅黑樹
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;
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
//...
}
3.2.1 插入方法
話不多說,直接看我們常用的 put
方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
在這裡呼叫了putVal
方法進行插入,並計算了 key 的雜湊值,先看看雜湊值怎麼計算的:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
可以看到這裡並不是直接使用hashcode()
方法去計算雜湊值的,而是這樣操作的:
- 如果 key 是 null,雜湊值就會 0;
- 如果 key 不是 null,就獲取物件的雜湊值然後再進行位移和異或操作,獲取最終的雜湊值。
這裡引出一個問題,為什麼取到雜湊值之後要進行位移和異或操作?
先上結論:就是為了減少雜湊碰撞,讓元素更均勻的分佈在雜湊表中。
再看原因:
-
如果直接使用
key.hashCode()
作為hash值的話,存在一些問題:HashMap
的預設長度為16,並且是通過(table.length - 1) & hash
的方式得到 key 在 table 陣列中的下標。如果
key1.hashCode()
=1661580827(二進位制為0110,0011,0000,1001,1011,0110,0001,1011),key2.hashCode()
=1661711899(二進位制為0110,0011,0000,1011,1011,0110,0001,1011)。在與掩碼進行與的過程中,只有後4位起作用,導致
(table.length - 1) & hash
得到的下標值均為11,導致高位完全失效,加大了衝突的可能性。一句話總結:就是如果不進行位移和異或的操作,在計算元素所處的 table 陣列下標的位置時,雜湊值的二進位制高位就會失效,只有低位參與計算,增大了雜湊衝突的可能性
-
如果通過高位向低位異或傳播的話,高位同樣參與到key在table中下標的運算,減少了碰撞的可能性
key1.hashCode() ^ (key1.hashCode() >>>16)
=1661588754(二進位制為0110,0011,0000,1001,1101,0101,0001,0010)key2.hashCode() ^ (key2.hashCode() >>>16)
=1661719824(二進位制為0110,0011,0000,1011,1101,0101,0001,0000)在和掩碼進行與操作(
(table.length - 1) & hash
)得到的下標分別為2和0,減少了衝突的可能性。
上面說了hash(Object key)
方法,我們接著往下看,就是 put
方法實際呼叫的 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;
// 初始化桶陣列 table,table 被延遲到插入新資料時再進行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 計算當前元素的key的下標位置,並判斷這個下標位置是不是空的,不是空的就直接將元素放在這裡
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 如果鍵的值以及節點 hash 等於連結串列中的第一個鍵值對節點時,則將 e 指向該鍵值對
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果桶中的引用型別為 TreeNode,則呼叫紅黑樹的插入方法
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 對連結串列進行遍歷,並統計連結串列長度, binCount 就是連結串列的長度
for (int binCount = 0; ; ++binCount) {
// 連結串列中不包含要插入的鍵值對節點時,則將該節點接在連結串列的最後
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 如果連結串列長度大於或等於樹化閾值,則進行樹化操作
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 條件為 true,表示當前連結串列包含要插入的鍵值對,終止遍歷
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 判斷要插入的鍵值對是否存在 HashMap 中,如果存在,前面就已經給 e 賦值了
if (e != null) { // existing mapping for key
V oldValue = e.value;
// onlyIfAbsent 表示是否僅在 oldValue 為 null 的情況下更新鍵值對的值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 鍵值對數量超過閾值時,則進行擴容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
上面寫在程式碼中的註釋已經比較清楚了,putVal
主要做了以下幾件事情:
- 當桶陣列 table 為空時,通過擴容的方式初始化 table(這裡第一次put的時候就會進行一次擴容,其實就是初始化 table)
- 查詢要插入的鍵值對是否已經存在,存在的話根據條件判斷是否用新值替換舊值
- 如果不存在,則將鍵值對鏈入連結串列中(是放在連結串列尾部哦),並根據連結串列長度決定是否將連結串列轉為紅黑樹
- 判斷鍵值對數量是否大於閾值,大於的話則進行擴容操作
3.3 擴容(resize 方法)
擴容方法相對會複雜一點,先說一下擴容的大概過程:
- 在
put
第一個元素時,會先初始化資料結構,按照預設的是初始化容量為 16,負載因子是 0.75 - 當
HashMap
中的鍵值對達到 最大容量*負載因子(也就是 12)之後,會進行擴容。 - 擴容就是將陣列(table)的長度變為原來的 2 倍,閾值也會變為原來的 2 倍
- 擴容之後,要重新計算鍵值對的位置,並把它們移動到合適的位置上去
接下來看看 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;
// 如果 table 不為空,表明已經初始化過了
if (oldCap > 0) {
// 當 table 容量超過容量最大值,則不再擴容
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; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
// 初始化時,將 threshold 的值賦值給 newCap
// HashMap 使用 threshold 變數暫時儲存 initialCapacity 引數的值
newCap = oldThr;
else { // zero initial threshold signifies using defaults
// 呼叫無參構造方法時,桶陣列容量為預設容量
// 閾值為預設容量與預設負載因子乘積
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// newThr 為 0 時,按閾值計算公式進行計算
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
// 建立新的桶陣列,桶陣列的初始化也是在這裡完成的
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;
if (e.next == null)
// 如果 oldTable 此處下表位置就一個元素,則直接填充到 newTab
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 重新對映時,需要對紅黑樹進行拆分,後面紅黑樹和連結串列互相轉換時講解
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
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 {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 將分組後的連結串列對映到新桶中
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
這個原始碼稍微有點長,可以多看幾遍。主要做了以下幾件事情:
- 計算新桶陣列的容量 newCap 和新閾值 newThr
- 根據計算出的 newCap 建立新的桶陣列,桶陣列 table 也是在這裡進行初始化的
- 將鍵值對節點重新對映到新的桶陣列裡。如果節點是
TreeNode
型別,則需要拆分紅黑樹。如果是普通節點,則節點按原順序進行分組。(具體情況後面紅黑樹和連結串列互相轉換時講解)
這裡也查閱了一些資料,關於 JDK1.7
和 JDK1.8
擴容的區別:
JDK 1.8
版本下HashMap
擴容效率要高於之前版本。如果大家看過JDK 1.7
的原始碼會發現,JDK 1.7
為了防止因 hash 碰撞引發的拒絕服務攻擊,在計算 hash 過程中引入隨機種子。以增強 hash 的隨機性,使得鍵值對均勻分佈在桶陣列中。在擴容過程中,相關方法會根據容量判斷是否需要生成新的隨機種子,並重新計算所有節點的 hash。而在JDK 1.8
中,則通過引入紅黑樹替代了該種方式。從而避免了多次計算 hash 的操作,提高了擴容效率。
3.4 連結串列轉為紅黑樹
很多資料說當連結串列長度達到 8 的時候,就會轉化為紅黑樹,我們先看一下原始碼,到底是不是這樣:
來吧,看一下treeifyBin
方法的原始碼:
static final int TREEIFY_THRESHOLD = 8;
static final int MIN_TREEIFY_CAPACITY = 64;
// 將普通節點連結串列轉換成樹形節點連結串列
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 桶陣列容量小於 MIN_TREEIFY_CAPACITY,優先進行擴容而不是樹化
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
// hd 為頭節點(head),tl 為尾節點(tail)
TreeNode<K,V> hd = null, tl = null;
do {
// 將普通節點替換成樹形節點
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null); // 將普通連結串列轉成由樹形節點連結串列
if ((tab[index] = hd) != null)
// 將樹形連結串列轉換成紅黑樹
hd.treeify(tab);
}
}
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
return new TreeNode<>(p.hash, p.key, p.value, next);
}
看看上面的程式碼和截圖,可以很清楚,連結串列樹化需要兩個條件:
-
連結串列長度大於等於
TREEIFY_THRESHOLD
就是我們常說的當連結串列長度大於 8 的時候,連結串列轉化為紅黑樹
-
桶陣列容量大於等於
MIN_TREEIFY_CAPACITY
實際上進入轉化為紅黑樹的方法中(
treeifyBin
方法)可以看到:桶陣列(table陣列)長度小於 64 時會優先進行擴容操作,而不是直接將連結串列轉化為紅黑樹。我的理解是當 table 陣列長度比較小的時候,鍵的雜湊碰撞會比較嚴重,導致連結串列變長,這個時候可以選擇優先進行擴容。畢竟高碰撞率是因為桶陣列容量較小引起的。容量小時,優先擴容可以避免一些列的不必要的樹化過程。並且桶容量較小時,擴容會比較頻繁,擴容時需要拆分紅黑樹並重新對映(這個擴容方法中有)。所以在桶容量比較小的情況下,將長連結串列轉成紅黑樹,然後再經歷擴容,可能又將紅黑樹拆分轉成連結串列了,這是一件吃力不討好的事情。
我們繼續說上面的 treeifyBin
方法,這個方法主要是將普通連結串列轉化為由 TreeNode
節點組成的連結串列,再呼叫treeify
方法將其轉化為紅黑樹。可以瞭解一下,HashMap
中的內部類TreeNode
繼承了LinkedHashMap.Entry
,LinkedHashMap.Entry
又繼承了 HashMap.Node
。
所以 TreeNode
裡面還是包含有 next
引用的,這個在後面紅黑樹轉化為連結串列就會很有用了,後面再說哦。
先來畫個圖,假設在樹化之前,HashMap
裡面的資料是這樣的:
然後說說呼叫treeify
方法將TreeNode
連結串列轉化為紅黑樹吧,因為紅黑樹的結構特點(自己去了解哦),這個就涉及到 key 的比較了,這裡直接說一下怎麼比較的(就不再深追了):
- 比較鍵與鍵之間 hash 的大小,如果 hash 相同,繼續往下比較
- 檢測鍵類是否實現了 Comparable 介面,如果實現呼叫
compareTo
方法進行比較 - 如果仍未比較出大小,就需要進行仲裁了,仲裁方法為
tieBreakOrder
通過上面說的三種比較,就可以按照紅黑樹的結構特性去構造紅黑樹了,樹化後是這樣子的:
黃色箭頭代表TreeNode
保留的next
引用,其實還有 prev
引用,這裡就不畫了。這裡就是說明連結串列轉化為紅黑樹後,原來連結串列的順序結構依然保留著,這樣就可以按照連結串列的方式去遍歷紅黑樹,而且這個結構給後面紅黑樹轉化成連結串列提供了便利。
那下面再看一下紅黑樹轉化為連結串列是怎麼操作的吧!
3.5 紅黑樹轉為連結串列
3.5.1 紅黑樹拆分
再回去看看擴容的方法吧,裡面有這麼一行程式碼:((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
,它就是拆分紅黑樹的。擴容的時候所有節點都需要重新計算位置,所以肯定要拆分紅黑樹。
擴容後,所有節點(包括陣列,連結串列節點,紅黑樹節點)都要重新計算下標位置,上面說到連結串列轉化為紅黑樹時,通過兩個額外的引用 next
和 prev
保留了原連結串列的節點順序。這樣拆分紅黑樹重新計算下標時,就可以直接按照連結串列的方式拆分了,一定程度上是提升了效率的。
上面說了紅黑樹拆分的大致邏輯,下面看一下原始碼中怎麼拆分的:
// 紅黑樹轉連結串列閾值
static final int UNTREEIFY_THRESHOLD = 6;
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
// Relink into lo and hi lists, preserving order
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
// 紅黑樹節點仍然保留了 next 引用,故仍可以按連結串列方式遍歷紅黑樹。
// 下面的迴圈是對紅黑樹節點進行分組,與上面類似
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next;
e.next = null;
if ((e.hash & bit) == 0) {
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc;
}
else {
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}
if (loHead != null) {
// 如果 loHead 不為空,且連結串列長度小於等於 6,則將紅黑樹轉成連結串列
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
// hiHead == null 時,表明擴容後
// 所有節點仍在原位置,樹結構不變,無需重新樹化
if (hiHead != null) // (else is already treeified)
loHead.treeify(tab);
}
}
// 與上面類似
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
重新對映紅黑樹的邏輯和重新對映連結串列的邏輯基本一致。只是重新對映後,會將紅黑樹拆分成兩條由 TreeNode
組成的連結串列。如果連結串列長度小於 UNTREEIFY_THRESHOLD
,則將連結串列轉換成普通連結串列。否則根據條件重新將 TreeNode
連結串列樹化。舉個例子說明一下,假設擴容後,重新對映上圖的紅黑樹,對映結果如下:
3.5.2 紅黑樹鏈化
在上面的 split
方法中,當連結串列長度小於等於 6 時,就會呼叫 tab[index] = loHead.untreeify(map);
將紅黑樹轉化為連結串列。
上面說了,紅黑樹中仍然保留了原連結串列節點順序。這樣想將紅黑樹轉化為連結串列就很簡單了,只需要將TreeNode
連結串列轉化為 Node
連結串列就可以了。
final Node<K,V> untreeify(HashMap<K,V> map) {
Node<K,V> hd = null, tl = null;
// 遍歷 TreeNode 連結串列,並用 Node 替換
for (Node<K,V> q = this; q != null; q = q.next) {
// 替換節點型別
Node<K,V> p = map.replacementNode(q, null);
if (tl == null)
hd = p;
else
tl.next = p;
tl = p;
}
return hd;
}
Node<K,V> replacementNode(Node<K,V> p, Node<K,V> next) {
return new Node<>(p.hash, p.key, p.value, next);
}
3.6 查詢(get 方法)
到這裡,最難啃的骨頭基本上啃完了。
先說一下思想:先定位鍵值對所在的桶的下標位置(就是在陣列的哪個位置),然後去查詢連結串列或紅黑樹。
下面就看一下 HashMap
的查詢方法,上原始碼:
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
計算雜湊值的方法就不說了吧,和插入(put)資料時是一致的。如果 key 是 null,下標位置就是 0,就直接返回它的值。否則就呼叫 getNode
方法了。
看看getNode
方法:
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 1. 定位鍵值對所在桶的位置
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 如果下標位置的第一個節點就是要查詢的節點,就直接返回
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
// 2. 如果 first 是 TreeNode 型別,則呼叫黑紅樹查詢方法
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 2. 對連結串列進行查詢
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
上面的註釋比較清楚了,紅黑樹的查詢自己看看原始碼瞭解一下吧,這裡就不贅述了。
3.7 遍歷
遍歷操作也是一個高頻操作,我們一般使用的遍歷方式有以下幾種:
// 遍歷方式 1
for(Object key : map.keySet()) {
// do something
}
// 遍歷方式 2
for(HashMap.Entry entry : map.entrySet()) {
// do something
}
// 遍歷方式 3
Set keys = map.keySet();
Iterator ite = keys.iterator();
while (ite.hasNext()) {
Object key = ite.next();
// do something
}
上面的幾種遍歷方式,可以看出來遍歷Map集合時,都是對 HashMap
的 key 集合或 Entry 集合進行遍歷。
看一下遍歷相關的原始碼吧:
public Set<K> keySet() {
Set<K> ks = keySet;
if (ks == null) {
ks = new KeySet();
keySet = ks;
}
return ks;
}
// 鍵集合
final class KeySet extends AbstractSet<K> {
public final int size() { return size; }
public final void clear() { HashMap.this.clear(); }
public final Iterator<K> iterator() { return new KeyIterator(); }
public final boolean contains(Object o) { return containsKey(o); }
public final boolean remove(Object key) {
return removeNode(hash(key), key, null, false, true) != null;
}
public final Spliterator<K> spliterator() {
return new KeySpliterator<>(HashMap.this, 0, -1, 0, 0);
}
public final void forEach(Consumer<? super K> action) {
Node<K,V>[] tab;
if (action == null)
throw new NullPointerException();
if (size > 0 && (tab = table) != null) {
int mc = modCount;
for (int i = 0; i < tab.length; ++i) {
for (Node<K,V> e = tab[i]; e != null; e = e.next)
action.accept(e.key);
}
if (modCount != mc)
throw new ConcurrentModificationException();
}
}
}
// 鍵迭代器
final class KeyIterator extends HashIterator
implements Iterator<K> {
public final K next() { return nextNode().key; }
}
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() {
Node<K,V> p = current;
if (p == null)
throw new IllegalStateException();
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
current = null;
K key = p.key;
removeNode(hash(key), key, null, false, false);
expectedModCount = modCount;
}
}
不知道細心的你有沒有發現一個問題,多次遍歷一個 HashMap
集合時,遍歷出來的順序是一樣的,然而遍歷出來的這個順序又和插入順序不一樣。那我們就拿這個圖看一下吧:
不管插入順序是怎樣,最後存到HashMap
中的結構就是上圖這樣的,相信這篇文章看到這裡,也不用再解釋為什麼了吧。
然而在遍歷時,會先從table陣列的 0 下標開始往後遍歷。找到包含連結串列節點引用的桶,對應圖中就是 12 號桶,隨後由遍歷該桶所指向的連結串列。遍歷完 12 號桶後,會繼續尋找下一個不為空的桶,對應圖中的 28 號桶。之後流程和上面類似,直至遍歷完最後一個桶。
遍歷的方法就到這裡了,再思考一個問題,遍歷的過程中並沒有看到遍歷紅黑樹的程式碼,那紅黑樹怎麼遍歷?如果你還不知道,就再回到上面看一下連結串列是怎麼轉為紅黑樹的吧(紅黑樹保留了 原始的連結串列結構)!
3.8 刪除
刪除操作的三個步驟:
- 確定元素在table陣列的下標位置
- 遍歷連結串列並找到鍵值對應的節點
- 刪除節點
上原始碼:
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
if ((tab = table) != null && (n = tab.length) > 0 &&
// 1. 定位桶位置
(p = tab[index = (n - 1) & hash]) != null) {
// 這個 node 就是記錄要刪除的那個節點
Node<K,V> node = null, e; K k; V v;
// 如果鍵的值與連結串列第一個節點相等,也就是說第一個就是要刪除的節點,則將 node 指向該節點
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
// 如果是 TreeNode 型別,呼叫紅黑樹的查詢邏輯定位待刪除節點,將待刪除的節點賦值給node
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
// 2. 遍歷連結串列,找到待刪除節點,並將待刪除的節點賦值給 node
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
// 3. 刪除節點,並修復連結串列或紅黑樹
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
// 如果是紅黑樹的結構,就呼叫刪除紅黑樹節點的方法
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
紅黑樹中的節點怎麼刪除的就不要再看了,傷腦筋,哈哈!!!