HashMap 在 JDK 1.8 後新增的紅黑樹結構
讀完本文你將瞭解到:
傳統 HashMap 的缺點
JDK 1.8 以前 HashMap 的實現是 陣列+連結串列,即使雜湊函式取得再好,也很難達到元素百分百均勻分佈。
當 HashMap 中有大量的元素都存放到同一個桶中時,這個桶下有一條長長的連結串列,這個時候 HashMap 就相當於一個單鏈表,假如單鏈表有 n 個元素,遍歷的時間複雜度就是 O(n),完全失去了它的優勢。
針對這種情況,JDK 1.8 中引入了 紅黑樹(查詢時間複雜度為 O(logn))來優化這個問題。
HashMap 在 JDK 1.8 中新增的資料結構 – 紅黑樹
JDK 1.8 中 HashMap 中除了連結串列節點:
static class Node<K,V> implements Map.Entry<K,V> {
//雜湊值,就是位置
final int hash;
//鍵
final K key;
//值
V value;
//指向下一個幾點的指標
Node<K,V> next;
//...
}
還有另外一種節點:TreeNode,它是 1.8 新增的,屬於資料結構中的 紅黑樹(不瞭解紅黑樹的同學可以 點選這裡瞭解紅黑樹):
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; }
可以看到就是個紅黑樹節點,有父親、左右孩子、前一個元素的節點,還有個顏色值。
另外由於它繼承自 LinkedHashMap.Entry ,而 LinkedHashMap.Entry 繼承自 HashMap.Node ,因此還有額外的 6 個屬性:
//繼承 LinkedHashMap.Entry 的
Entry<K,V> before, after;
//HashMap.Node 的
final int hash;
final K key;
V value;
Node<K,V> next;
HashMap 中關於紅黑樹的三個關鍵引數
HashMap 中有三個關於紅黑樹的關鍵引數:
- TREEIFY_THRESHOLD
- UNTREEIFY_THRESHOLD
- MIN_TREEIFY_CAPACITY
值及作用如下:
//一個桶的樹化閾值
//當桶中元素個數超過這個值時,需要使用紅黑樹節點替換連結串列節點
//這個值必須為 8,要不然頻繁轉換效率也不高
static final int TREEIFY_THRESHOLD = 8;
//一個樹的連結串列還原閾值
//當擴容時,桶中元素個數小於這個值,就會把樹形的桶元素 還原(切分)為連結串列結構
//這個值應該比上面那個小,至少為 6,避免頻繁轉換
static final int UNTREEIFY_THRESHOLD = 6;
//雜湊表的最小樹形化容量
//當雜湊表中的容量大於這個值時,表中的桶才能進行樹形化
//否則桶內元素太多時會擴容,而不是樹形化
//為了避免進行擴容、樹形化選擇的衝突,這個值不能小於 4 * TREEIFY_THRESHOLD
static final int MIN_TREEIFY_CAPACITY = 64;
HashMap 在 JDK 1.8 中新增的操作:桶的樹形化 treeifyBin()
在Java 8 中,如果一個桶中的元素個數超過 TREEIFY_THRESHOLD(預設是 8 ),就使用紅黑樹來替換連結串列,從而提高速度。
這個替換的方法叫 treeifyBin() 即樹形化。
//將桶內所有的 連結串列節點 替換成 紅黑樹節點
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//如果當前雜湊表為空,或者雜湊表中元素的個數小於 進行樹形化的閾值(預設為 64),就去新建/擴容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
//如果雜湊表中的元素個數超過了 樹形化閾值,進行樹形化
// e 是雜湊表中指定位置桶裡的連結串列節點,從第一個開始
TreeNode<K,V> hd = null, tl = null; //紅黑樹的頭、尾節點
do {
//新建一個樹形節點,內容和當前連結串列節點 e 一致
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);
}
上述操作做了這些事:
- 根據雜湊表中元素個數確定是擴容還是樹形化
- 如果是樹形化
- 遍歷桶中的元素,建立相同個數的樹形節點,複製內容,建立起聯絡
- 然後讓桶第一個元素指向新建的樹頭結點,替換桶的連結串列內容為樹形內容
但是我們發現,之前的操作並沒有設定紅黑樹的顏色值,現在得到的只能算是個二叉樹。在 最後呼叫樹形節點 hd.treeify(tab) 方法進行塑造紅黑樹,來看看程式碼:
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null;
for (TreeNode<K,V> x = this, next; x != null; x = next) {
next = (TreeNode<K,V>)x.next;
x.left = x.right = null;
if (root == null) { //頭回進入迴圈,確定頭結點,為黑色
x.parent = null;
x.red = false;
root = x;
}
else { //後面進入迴圈走的邏輯,x 指向樹中的某個節點
K k = x.key;
int h = x.hash;
Class<?> kc = null;
//又一個迴圈,從根節點開始,遍歷所有節點跟當前節點 x 比較,調整位置,有點像氣泡排序
for (TreeNode<K,V> p = root;;) {
int dir, ph; //這個 dir
K pk = p.key;
if ((ph = p.hash) > h) //當比較節點的雜湊值比 x 大時, dir 為 -1
dir = -1;
else if (ph < h) //雜湊值比 x 小時 dir 為 1
dir = 1;
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
// 如果比較節點的雜湊值、 x
dir = tieBreakOrder(k, pk);
//把 當前節點變成 x 的父親
//如果當前比較節點的雜湊值比 x 大,x 就是左孩子,否則 x 是右孩子
TreeNode<K,V> xp = p;
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp;
if (dir <= 0)
xp.left = x;
else
xp.right = x;
root = balanceInsertion(root, x);
break;
}
}
}
}
moveRootToFront(tab, root);
}
可以看到,將二叉樹變為紅黑樹時,需要保證有序。這裡有個雙重迴圈,拿樹中的所有節點和當前節點的雜湊值進行對比(如果雜湊值相等,就對比鍵,這裡不用完全有序),然後根據比較結果確定在樹種的位置。
HashMap 在 JDK 1.8 中新增的操作: 紅黑樹中新增元素 putTreeVal()
上面介紹瞭如何把一個桶中的連結串列結構變成紅黑樹結構。
在新增時,如果一個桶中已經是紅黑樹結構,就要呼叫紅黑樹的新增元素方法 putTreeVal()。
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
int h, K k, V v) {
Class<?> kc = null;
boolean searched = false;
TreeNode<K,V> root = (parent != null) ? root() : this;
//每次新增元素時,從根節點遍歷,對比雜湊值
for (TreeNode<K,V> p = root;;) {
int dir, ph; K pk;
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
//如果當前節點的雜湊值、鍵和要新增的都一致,就返回當前節點(奇怪,不對比值嗎?)
return p;
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) {
//如果當前節點和要新增的節點雜湊值相等,但是兩個節點的鍵不是一個類,只好去挨個對比左右孩子
if (!searched) {
TreeNode<K,V> q, ch;
searched = true;
if (((ch = p.left) != null &&
(q = ch.find(h, k, kc)) != null) ||
((ch = p.right) != null &&
(q = ch.find(h, k, kc)) != null))
//如果從 ch 所在子樹中可以找到要新增的節點,就直接返回
return q;
}
//雜湊值相等,但鍵無法比較,只好通過特殊的方法給個結果
dir = tieBreakOrder(k, pk);
}
//經過前面的計算,得到了當前節點和要插入節點的一個大小關係
//要插入的節點比當前節點小就插到左子樹,大就插到右子樹
TreeNode<K,V> xp = p;
//這裡有個判斷,如果當前節點還沒有左孩子或者右孩子時才能插入,否則就進入下一輪迴圈
if ((p = (dir <= 0) ? p.left : p.right) == null) {
Node<K,V> xpn = xp.next;
TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
if (dir <= 0)
xp.left = x;
else
xp.right = x;
xp.next = x;
x.parent = x.prev = xp;
if (xpn != null)
((TreeNode<K,V>)xpn).prev = x;
//紅黑樹中,插入元素後必要的平衡調整操作
moveRootToFront(tab, balanceInsertion(root, x));
return null;
}
}
}
//這個方法用於 a 和 b 雜湊值相同但是無法比較時,直接根據兩個引用的地址進行比較
//這裡原始碼註釋也說了,這個樹裡不要求完全有序,只要插入時使用相同的規則保持平衡即可
static int tieBreakOrder(Object a, Object b) {
int d;
if (a == null || b == null ||
(d = a.getClass().getName().
compareTo(b.getClass().getName())) == 0)
d = (System.identityHashCode(a) <= System.identityHashCode(b) ?
-1 : 1);
return d;
}
通過上面的程式碼可以知道,HashMap 中往紅黑樹中新增一個新節點 n 時,有以下操作:
- 從根節點開始遍歷當前紅黑樹中的元素 p,對比 n 和 p 的雜湊值;
- 如果雜湊值相等並且鍵也相等,就判斷為已經有這個元素(這裡不清楚為什麼不對比值);
- 如果雜湊值就通過其他資訊,比如引用地址來給個大概比較結果,這裡可以看到紅黑樹的比較並不是很準確,註釋裡也說了,只是保證個相對平衡即可;
- 最後得到雜湊值比較結果後,如果當前節點 p 還沒有左孩子或者右孩子時才能插入,否則就進入下一輪迴圈;
- 插入元素後還需要進行紅黑樹例行的平衡調整,還有確保根節點的領先地位。
HashMap 在 JDK 1.8 中新增的操作: 紅黑樹中查詢元素 getTreeNode()
HashMap 的查詢方法是 get():
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
它通過計算指定 key 的雜湊值後,呼叫內部方法 getNode();
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
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) {
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;
}
這個 getNode() 方法就是根據雜湊表元素個數與雜湊值求模(使用的公式是 (n - 1) &hash
)得到 key 所在的桶的頭結點,如果頭節點恰好是紅黑樹節點,就呼叫紅黑樹節點的 getTreeNode() 方法,否則就遍歷連結串列節點。
final TreeNode<K,V> getTreeNode(int h, Object k) {
return ((parent != null) ? root() : this).find(h, k, null);
}
getTreeNode 方法使通過呼叫樹形節點的 find() 方法進行查詢:
//從根節點根據 雜湊值和 key 進行查詢
final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
TreeNode<K,V> p = this;
do {
int ph, dir; K pk;
TreeNode<K,V> pl = p.left, pr = p.right, q;
if ((ph = p.hash) > h)
p = pl;
else if (ph < h)
p = pr;
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;
else if (pl == null)
p = pr;
else if (pr == null)
p = pl;
else if ((kc != null ||
(kc = comparableClassFor(k)) != null) &&
(dir = compareComparables(kc, k, pk)) != 0)
p = (dir < 0) ? pl : pr;
else if ((q = pr.find(h, k, kc)) != null)
return q;
else
p = pl;
} while (p != null);
return null;
}
由於之前新增時已經保證這個樹是有序的,因此查詢時基本就是折半查詢,效率很高。
這裡和插入時一樣,如果對比節點的雜湊值和要查詢的雜湊值相等,就會判斷 key 是否相等,相等就直接返回(也沒有判斷值哎);不相等就從子樹中遞迴查詢。
HashMap 在 JDK 1.8 中新增的操作: 樹形結構修剪 split()
HashMap 中, resize() 方法的作用就是初始化或者擴容雜湊表。當擴容時,如果當前桶中元素結構是紅黑樹,並且元素個數小於連結串列還原閾值 UNTREEIFY_THRESHOLD (預設為 6),就會把桶中的樹形結構縮小或者直接還原(切分)為連結串列結構,呼叫的就是 split():
//引數介紹
//tab 表示儲存桶頭結點的雜湊表
//index 表示從哪個位置開始修剪
//bit 要修剪的位數(雜湊值)
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;
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next;
e.next = null;
//如果當前節點雜湊值的最後一位等於要修剪的 bit 值
if ((e.hash & bit) == 0) {
//就把當前節點放到 lXXX 樹中
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
//然後 loTail 記錄 e
loTail = e;
//記錄 lXXX 樹的節點數量
++lc;
}
else { //如果當前節點雜湊值最後一位不是要修剪的
//就把當前節點放到 hXXX 樹中
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
//記錄 hXXX 樹的節點數量
++hc;
}
}
if (loHead != null) {
//如果 lXXX 樹的數量小於 6,就把 lXXX 樹的枝枝葉葉都置為空,變成一個單節點
//然後讓這個桶中,要還原索引位置開始往後的結點都變成還原成連結串列的 lXXX 節點
//這一段元素以後就是一個連結串列結構
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
//否則讓索引位置的結點指向 lXXX 樹,這個樹被修剪過,元素少了
tab[index] = loHead;
if (hiHead != null) // (else is already treeified)
loHead.treeify(tab);
}
}
if (hiHead != null) {
//同理,讓 指定位置 index + bit 之後的元素
//指向 hXXX 還原成連結串列或者修剪過的樹
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
從上述程式碼可以看到,HashMap 擴容時對紅黑樹節點的修剪主要分兩部分,先分類、再根據元素個數決定是還原成連結串列還是精簡一下元素仍保留紅黑樹結構。
1.分類
指定位置、指定範圍,讓指定位置中的元素 (hash & bit) == 0
的,放到 lXXX 樹中,不相等的放到 hXXX 樹中。
2.根據元素個數決定處理情況
符合要求的元素(即 lXXX 樹),在元素個數小於 6 時還原成連結串列,最後讓雜湊表中修剪的痛 tab[index] 指向 lXXX 樹;在元素個數大於 6 時,還是用紅黑樹,只不過是修剪了下枝葉;
不符合要求的元素(即 hXXX 樹)也是一樣的操作,只不過最後它是放在了修剪範圍外 tab[index + bit]。
總結
JDK 1.8 以後雜湊表的 新增、刪除、查詢、擴容方法都增加了一種 節點為 TreeNode 的情況:
- 新增時,當桶中連結串列個數超過 8 時會轉換成紅黑樹;
- 刪除、擴容時,如果桶中結構為紅黑樹,並且樹中元素個數太少的話,會進行修剪或者直接還原成連結串列結構;
- 查詢時即使雜湊函式不優,大量元素集中在一個桶中,由於有紅黑樹結構,效能也不會差。
這篇文章根據原始碼分析了 HashMap 在 JDK 1.8 裡新增的 TreeNode 的一些關鍵方法,可以看到,1.8 以後的 HashMap 結合了雜湊表和紅黑樹的優點,不僅快速,而且在極端情況也能保證效能,設計者苦心孤詣可見一斑