走進Java Map家族 (1) - HashMap實現原理分析

在Java世界裏,有一個古老而神祕的家族——Map。從底層架構到上層應用,他們活躍於世界的每一個角落。但是,每次出現時,他們都戴着一張冷硬的面具(接口),深深隱藏着自己的內心。所有人都認識他們,卻並非每個人都理解他們。在這個熱鬧的世界中,Map們活得光榮卻孤獨……這個系列博文,就將嘗試透過接口的僞裝,走進每個家族成員的內心世界,聆聽家族內部的動人傳說……

注:各種Map在不同的JDK中有不同的實現。如無特別聲明,本文只針對當前(2019年3月)最新的OpenJDK(13-ea)的實現

 

一、從HashMap開始

好了,上面都是扯淡,目的是爲了讓氣氛更加尷尬……

第一個介紹的Map成員是HashMap,因爲它應用最廣,實現也最簡單——簡單到我一直在糾結要不要單獨爲它寫一篇文章。代碼在這裏

http://hg.openjdk.java.net/jdk/jdk/file/5529640c5f67/src/java.base/share/classes/java/util/HashMap.java

HashMap將鍵值對存儲於若干個bin中。所謂bin(或者叫bucket、桶),是一個可以保存多個鍵值對的數據結構。初始狀態下,一個bin就是一個鏈表。具體代碼如下

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;

Node類是鏈表的元素。變量table是一個鏈表的數組,或者說是bin的數組。HashMap所有的數據就保存在table中。(或許你注意到了transient關鍵字。HashMap並不直接序列化table變量,而是重寫了writeObject和readObject處理數據的序列化)

當向HashMap中put數據時,首先計算key的hashCode,根據hashCode找到它所屬的bin,然後將鍵值對放入bin中,也就是插入到鏈表的末尾。可見,有相同hashCode的兩個key,一定會放入同一個bin中,這種現象叫hash衝突(hash collision)。而get的過程也一樣,計算key的hashCode並找到對應的bin,然後在bin中搜索包含相同key的Node。

以上是HashMap的基本實現原理。

 

二、擴容(resize)與樹化(treeify)

可見,put和get消耗的時間與鏈表長度是o(n)的關係。如果數據量太大,每一個鏈表都會很長;或者運氣太差,大部分數據都集中於一個bin中,這時HashMap的性能就會迅速下降。怎麼辦呢?

有兩種思路,要麼是縮短鏈表長度,要麼是提高bin的搜索速度。HashMap的具體策略也是從這兩方面入手。每次put數據時,都記錄Map中整體的數據量,以及鏈表的長度,然後

(1)當整體數據量超過bin的數量的3/4時,增加bin的數量,這個過程叫擴容(resize)。擴容後,各條數據都要重新計算它屬於哪個bin,這叫做rehash。這樣,有些數據移動到新的bin中,各個bin的鏈表長度就會縮短。

(2)當某個鏈表很長(超過7),而bin的數量很少(小於等於64個),也會擴容,以縮短鏈表。

(3)當某個鏈表長度超過7,而bin的數量大於64個,就將這個bin由鏈表轉變爲紅黑樹,提高搜索速度。這個過程叫做樹化(treeify)。樹化是在JDK 1.8才實現的。

爲什麼不一開始就使用樹呢?個人理解,這是出於時間-空間的綜合考量。當數據量很小時,樹的搜索速度並不明顯優於鏈表,而佔用的空間卻比鏈表多,因此初始選擇是鏈表,遇到性能瓶頸也優先選擇擴容。

而當bin足夠多時,繼續擴容就會出現問題:

(1)繼續擴容也會增加空間佔用(而且佔用的是連續空間。還記得table是一個數組嗎?)。相比於樹化,擴容不再具有空間上的優勢。

(2)resize之後要對所有的數據做rehash,當數據量很大時,rehash的性能負擔遠高於對單個bin做樹化。

可以說,擴容改變所有數據的分佈方式,是一種針對整體的優化方案;樹化只改變單個bin的結構,是針對局部的優化方案。如果bin很多卻依然存在很長的鏈表,說明整體優化方案對於某個bin不起作用,這可能是hashCode分佈不均勻導致的。繼續擴容徒然增加空間,效果卻不見得理想。這時,就該採用局部優化方案,也就是樹化了。

以上純屬個人理解與猜測,僅供參考。

最後說一點,bin的數量並非到了64之後就不再增長了。根據策略(1),只要整體數據量足夠多,就會擴容。不過,擴容也不是無限的,畢竟數組太大了也會造成問題。bin的最大數量是2的30次方,或者寫成1<<<30。

 

三、hashCode與擴容策略

如何根據hashCode找到數據所屬的bin?每次擴容增大多少?如何rehash?這幾個問題互相關聯。

最簡單的方案當然是取餘。假設bin的數量爲N,key的hashCode爲H,那麼key所屬的bin就是第H%N個。而擴容可以任意增加bin的數量。比如擴容後的bin有N+M個,rehash時某個key所屬的bin是第H%(N+M)個。

這種方法可以實現功能,但性能不好,rehash階段會非常耗時。而且有可能兩個bin中的數據被rehash到同一個bin中,從而構建了一個比以前更長的鏈表。而OpenJDK採用的方法則頗具技巧性,充分利用了高效的位運算。

/****開始說正事的分割線****/

OpenJDK要求bin的數量必須是2的整數冪,即1<<<N個(乘以2和左移一位等價,乘以N個2和左移N位等價)。初始狀態N=4,即bin的初始數量是2的4次方,或者是1左移四位的結果,也就是16個。

有了這個要求,許多事情就變得簡單了。

如何resize呢?爲了保證上述條件成立,每次擴容,bin的數量都變爲2倍。如果當前bin的數量爲1<<<N,擴容一次後bin的數量是1<<<(N+1)。

一個key應該屬於哪個bin呢?如果key的hashCode是H,bin的數量是B=1<<<N,則key所屬的bin是第H^(B-1)個。也就是截取了hashCode最後的N位,如下圖所示

 

這種計算bin的方式與取餘的結果實際是相同的。但是它利用了位運算,效率高於取餘。而且這種方式對rehash很友好。

擴容之後,bin的數量是B'=1<<<(N+1)=B<<<1 。rehash前,key所屬的bin是b1=H^(B-1),它是hashCode截取後N位的結果;rehash之後,key所屬的bin是b2=H^(B'- 1),他是hashCode截取後N+1位的結果。可見,rehash前後的差異只在hashCode的第N+1位,也就是H^B'的結果。因此有

(1)如果H^B'==0,則rehash後這個key的位置不變

(2)如果H^B'==1,則rehash後這個key所屬的bin是b2=b1+B。也就是將b1的第N+1位由0變爲1

整個rehash過程,全部使用位運算以及一次簡單的加法運算,保證了最高效率。而且兩個bin的數據不會rehash到同一個bin中,也不會把數據rehash到一個擴容前就存在的bin中,保證了所有的bin在擴容後都不可能變得更長。

最後再說一個問題,hashCode如何計算?方法如下

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

將hashCode()方法的結果的高16位與低16位做and運算。爲什麼不直接用hashCode()方法的結果呢?OpenJDK的解釋是,開發者可能用Double做爲key,如果各個浮點數之間差別很小,那麼它們的低位將相同。而bin的位置是由hashCode的低N位決定的。這種情況下,大量的數據將進入同一個bin,發生大量hash衝突,嚴重影響性能。於是,OpenJDK最終選擇了高位低位混淆的方案。據說,這種方案得到的hashCode滿足泊松分佈(雖然我不知道爲什麼會滿足),分佈很均勻。

 

四、關於樹的二三趣事

比起鏈表,紅黑樹更復雜,也要處理更多的問題和細節。可能這就是Java拖到1.8才實現樹化的原因吧。許多關於樹的問題並不重要,不影響整體思路,但細細品味很有意思。所以在最後寫上一些。

(1)是一棵樹,也是鏈表

樹化後,新生成的樹其實保持着原有鏈表的結構和順序。它既是樹,也是鏈表。樹的節點用類TreeNode表示,貼一段TreeNode的聲明

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間接繼承自鏈表節點類Node,所以它也是鏈表節點。看到聲明中的prev了嗎?它不僅是鏈表,還是雙向鏈表。

這麼做意義何在呢?首先是方便遍歷,我們大概都寫過類似的代碼

Map<Integer, String> m = new HashMap<>();
...
Iterator<Entry<Integer, String>> it = m.entrySet().iterator();
while(it.hasNext()) {
    it.next();
}

Iterator的底層就是沿着鏈表的順序遍歷的。遍歷鏈表,比遍歷一棵樹要高效得多。

然後,是方便逆樹化(untreeify)。鏈表太長了會樹化成一棵樹。可樹中的數據量可能因爲resize或者remove而減少,數據太少了,樹就會逆樹化成一個鏈表。因爲鏈表結構沒有丟,逆樹化就非常簡單了。

(2)根節點在哪?

樹的節點類TreeNode是鏈表節點類Node的子類。因此,樹化不用改變table變量的類型

transient Node<K,V>[] table;

數組裏的Node,我們稱它爲首節點(first node)。它可能表示鏈表,也可能表示樹。如果是鏈表,首節點當然就是頭節點。可如果是樹,首節點是哪個節點呢?根節點(root)嗎?不一定。

大部分情況下首節點都是紅黑樹的根節點,因爲每次改變樹的結構時,都會調用下面的moveToFront方法將根移動到table數組裏

 1 static <K,V> void moveRootToFront(Node<K,V>[] tab, TreeNode<K,V> root) {
 2   int n;
 3   if (root != null && tab != null && (n = tab.length) > 0) {
 4     int index = (n - 1) & root.hash;
 5     TreeNode<K,V> first = (TreeNode<K,V>)tab[index];
 6     if (root != first) {
 7       Node<K,V> rn;
 8       tab[index] = root;
 9       TreeNode<K,V> rp = root.prev;
10       if ((rn = root.next) != null)
11         ((TreeNode<K,V>)rn).prev = rp;
12       if (rp != null)
13         rp.next = rn;
14       if (first != null)
15         first.prev = root;
16       root.next = first;
17       root.prev = null;
18     }
19        assert checkInvariants(root);
20   }
21 }

代碼涉及到很多細節,只想瞭解基本思路的話,無需都看懂。但請注意第8行,root被放到table中。這時,根節點就是首節點了。

但是,有一個例外情況,就是在Iterator中remove一個數據時

Map<Integer, String> m = new HashMap<>();
...
Iterator<Entry<Integer, String>> it = m.entrySet().iterator();
it.remove();

爲什麼呢?注意moveToFront方法的第10-17行,改變了一些節點的next和prev指針,也就是改變了鏈表的順序。因爲root節點必須同時是鏈表的頭節點。但是,(1)中說過,Iterator是靠鏈表遍歷的,因此它不能隨便改變鏈表的順序,也就不會移動root。

這裏需要多說一句,雖然單個bin中的數據構成鏈表,但不同bin的數據卻沒有聯繫,而且moveRootToFront還會改變鏈表順序。因此,HashMap不是一個有序的數據結構。

(3)樹中的數據如何比較

紅黑樹中的數據必須是可以比較的。那麼HashMap的樹如何比較呢。比較順序如下:

a. 首先,比較key的hashCode;

b. 如果hashCode相同,檢查key是否是Comparable的。是的話,直接比較key;

c. 如果key不是Comparable的,或者兩個key比較結果相同,則比較兩個key各自的類的字符創,即 key.getClass().getName()//看看你把JDK逼成什麼樣了 ;

d. 如果還是相同,就比較兩個key的System.identityHashCode。

可見,從第三步起,事情就變得莫名詭異起來了。這也說明了,使用HashMap時,key最好是Comparable類型的,對性能有益。

 

五、最後吐個槽

本文是一篇薄碼文,貼的代碼很少。因爲大多數代碼比較長,又涉及諸多細節,不好看也無益於理解整體思路。

但是,從少數的代碼中大概可以體會到,OpenJDK的代碼質量真的不高。隨處可見魔幻的變量聲明和鬼畜的代碼格式,就是照着寫業務代碼有可能會被打死的那種。學習JDK源碼的主要目的是瞭解細節,方便開發。如果抱着參考優秀代碼的目的,那你算來錯了地方。

當然,這種底層的輪子,也許開發者更多考慮的是性能和可靠性,至於可讀性或許並不那麼重要。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章