HashMap1.8原始碼分析(紅黑樹)
轉載:https://segmentfault.com/a/1190000012926722?utm_source=tag-newest
https://blog.csdn.net/weixin_40255793/article/details/80748946(方法全面)
方法
treeifyBin(普通節點連結串列轉換成樹形節點連結串列)
static final int TREEIFY_THRESHOLD = 8; /** * 當桶陣列容量小於該值時,優先進行擴容,而不是樹化 */ static final int MIN_TREEIFY_CAPACITY = 64; staticView Codefinal 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); } } /** * 將普通節點連結串列轉換成樹形節點連結串列 */ 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 64
第一個條件比較好理解,這裡就不說了。這裡來說說加入第二個條件的原因,個人覺得原因如下:
當桶陣列容量比較小時,鍵值對節點 hash 的碰撞率可能會比較高,進而導致連結串列長度較長。這個時候應該優先擴容,而不是立馬樹化。畢竟高碰撞率是因為桶陣列容量較小引起的,這個是主因。容量小時,優先擴容可以避免一些列的不必要的樹化過程。同時,桶容量較小時,擴容會比較頻繁,擴容時需要拆分紅黑樹並重新對映。所以在桶容量比較小的情況下,將長連結串列轉成紅黑樹是一件吃力不討好的事。
我們繼續看一下 treeifyBin 方法。該方法主要的作用是將普通連結串列轉成為由 TreeNode 型節點組成的連結串列,並在最後呼叫 treeify 是將該連結串列轉為紅黑樹。TreeNode 繼承自 Node 類,所以 TreeNode 仍然包含 next 引用,原連結串列的節點順序最終通過 next 引用被儲存下來。我們假設樹化前,連結串列結構如下:
HashMap 在設計之初,並沒有考慮到以後會引入紅黑樹進行優化。所以並沒有像 TreeMap 那樣,要求鍵類實現 comparable 介面或提供相應的比較器。但由於樹化過程需要比較兩個鍵物件的大小,在鍵類沒有實現 comparable 介面的情況下,怎麼比較鍵與鍵之間的大小了就成了一個棘手的問題。為了解決這個問題,HashMap 是做了三步處理,確保可以比較出兩個鍵的大小,如下:
- 比較鍵與鍵之間 hash 的大小,如果 hash 相同,繼續往下比較
- 檢測鍵類是否實現了 Comparable 介面,如果實現呼叫 compareTo 方法進行比較
- 如果仍未比較出大小,就需要進行仲裁了,仲裁方法為 tieBreakOrder(大家自己看原始碼吧)
tie break 是網球術語,可以理解為加時賽的意思,起這個名字還是挺有意思的。
通過上面三次比較,最終就可以比較出孰大孰小。比較出大小後就可以構造紅黑樹了,最終構造出的紅黑樹如下:
橙色的箭頭表示 TreeNode 的 next 引用。由於空間有限,prev 引用未畫出。可以看出,連結串列轉成紅黑樹後,原連結串列的順序仍然會被引用仍被保留了(紅黑樹的根節點會被移動到連結串列的第一位),我們仍然可以按遍歷連結串列的方式去遍歷上面的紅黑樹。這樣的結構為後面紅黑樹的切分以及紅黑樹轉成連結串列做好了鋪墊,我們繼續往下分析。
split(紅黑樹拆分)
擴容後,普通節點需要重新對映,紅黑樹節點也不例外。按照一般的思路,我們可以先把紅黑樹轉成連結串列,之後再重新對映連結串列即可。這種處理方式是大家比較容易想到的,但這樣做會損失一定的效率。不同於上面的處理方式,HashMap 實現的思路則很好。如上節所說,在將普通連結串列轉成紅黑樹時,HashMap 通過兩個額外的引用 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) 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); } } }View Code
從原始碼上可以看得出,重新對映紅黑樹的邏輯和重新對映連結串列的邏輯基本一致。不同的地方在於,重新對映後,會將紅黑樹拆分成兩條由 TreeNode 組成的連結串列。如果連結串列長度小於 UNTREEIFY_THRESHOLD,則將連結串列轉換成普通連結串列。否則根據條件重新將 TreeNode 連結串列樹化。
被 transient 所修飾 table 變數
如果大家細心閱讀 HashMap 的原始碼,會發現桶陣列 table 被申明為 transient。transient 表示易變的意思,在 Java 中,被該關鍵字修飾的變數不會被預設的序列化機制序列化。我們再回到原始碼中,考慮一個問題:桶陣列 table 是 HashMap 底層重要的資料結構,不序列化的話,別人還怎麼還原呢?
這裡簡單說明一下吧,HashMap 並沒有使用預設的序列化機制,而是通過實現readObject/writeObject
兩個方法自定義了序列化的內容。這樣做是有原因的,試問一句,HashMap 中儲存的內容是什麼?不用說,大家也知道是鍵值對
。所以只要我們把鍵值對序列化了,我們就可以根據鍵值對資料重建 HashMap。有的朋友可能會想,序列化 table 不是可以一步到位,後面直接還原不就行了嗎?這樣一想,倒也是合理。但序列化 talbe 存在著兩個問題:
- table 多數情況下是無法被存滿的,序列化未使用的部分,浪費空間
- 同一個鍵值對在不同 JVM 下,所處的桶位置可能是不同的,在不同的 JVM 下反序列化 table 可能會發生錯誤。
以上兩個問題中,第一個問題比較好理解,第二個問題解釋一下。HashMap 的get/put/remove
等方法第一步就是根據 hash 找到鍵所在的桶位置,但如果鍵沒有覆寫 hashCode 方法,計算 hash 時最終呼叫 Object 中的 hashCode 方法。但 Object 中的 hashCode 方法是 native 型的,不同的 JVM 下,可能會有不同的實現,產生的 hash 可能也是不一樣的。也就是說同一個鍵在不同平臺下可能會產生不同的 hash,此時再對在同一個 table 繼續操作,就會出現問題。
綜上所述,大家應該能明白 HashMap 不序列化 table 的原因了。