由阿里巴巴 Java 開發規約 HashMap 條目引發的故事

前言

大熱的《阿里巴巴 Java 開發規約》中有提到:

  • 【推薦】集合初始化時,指定集合初始值大小。

說明:HashMap使用如下構造方法進行初始化,如果暫時無法確定集合大小,那麼指定默認值(16)即可:

    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

看到代碼規約這一條的時候,我覺得是不是有點太 low 了,身爲開發,大家都知道HashMap的原理。什麼?這個要通過插件監測?沒必要吧,哪個開發不知道默認大小,何時resize啊,然後我和孤盡打賭隨機諮詢幾位同學以下幾個問題:

  • HashMap默認bucket數組多大?
  • 如果new HashMap<>(19)bucket數組多大?
  • HashMap什麼時候開闢bucket數組佔用內存?
  • HashMap何時擴容?

抽樣調查的結果出乎我的意料:

  • HashMap默認bucket數組多大?(答案是16,大概一半的同學答錯)
  • 如果new HashMap<>(19)bucket數組多大?(答案是32,大多被諮詢同學都不太瞭解這個點)
  • HashMap什麼時候開闢bucket數組佔用內存?(答案是第一次put時,一半同學認爲是new的時候)
  • HashMap何時擴容?(答案是put的元素達到容量乘負載因子的時候,默認16*0.75,有 1/4 同學中槍)

HashMap是寫代碼時最常用的集合類之一,看來大家也不是全都很瞭解。孤盡乘勝追擊又拋出問題:JDK8 中HashMap和之前HashMap有什麼不同?

我知道 JDK8 中HashMap引入了紅黑樹來處理哈希碰撞,具體細節和源代碼並沒有仔細翻過,看來是時候對比翻看下 JDK8 和 JDK7 的HashMap源碼了。

通過對比翻看源碼,先說下結論:

  • HashMapnew後並不會立即分配bucket數組,而是第一次put時初始化,類似ArrayList在第一次add時分配空間。
  • HashMapbucket數組大小一定是 2 的冪,如果new的時候指定了容量且不是 2 的冪,實際容量會是最接近(大於)指定容量的 2 的冪,比如new HashMap<>(19),比 19 大且最接近的 2 的冪是 32,實際容量就是 32。
  • HashMapput的元素數量大於Capacity * LoadFactor(默認16 * 0.75) 之後會進行擴容。
  • JDK8 在哈希碰撞的鏈表長度達到TREEIFY_THRESHOLD(默認8)後,會把該鏈表轉變成樹結構,提高了性能。
  • JDK8 在resize的時候,通過巧妙的設計,減少了rehash的性能消耗。

存儲結構

  • JDK7 中的HashMap還是採用大家所熟悉的數組+鏈表的結構來存儲數據。

  • JDK8 中的HashMap採用了數組+鏈表或樹的結構來存儲數據。

重要參數

HashMap中有兩個重要的參數,初始容量和負載因子:

  • Initial capacity,The capacity is the number of buckets in the hash table, The initial capacity is simply the capacity at the time the hash table is created.
  • Load factor,The load factor is a measure of how full the hash table is allowed to get before its capacity is automatically increased.

Initial capacity決定bucket的大小,Load factor決定bucket內數據填充比例,基於這兩個參數的乘積,HashMap內部由threshold這個變量來表示HashMap能放入的元素個數。

  • capacity就是HashMap中數組的length
  • loadFactor一般都是使用默認的0.75
  • threshold 決定能放入的數據量,一般情況下等於capacity * LoadFactor

以上參數在 JDK7 和 JDK8 中是一致的,接下來會根據實際代碼分析。

JDK8 中的 HashMap 實現

new

HashMapbucket數組並不會在new的時候分配,而是在第一次put的時候通過resize()函數進行分配。

JDK8 中HashMapbucket數組大小肯定是 2 的冪,對於 2 的冪大小的bucket,計算下標只需要hash後按位與n-1,比%模運算取餘要快。如果你通過HashMap(int initialCapacity)構造器傳入initialCapacity,會先計算出比initialCapacity大的 2 的冪存入threshold,在第一次putresize()初始化中會按照這個 2 的冪初始化數組大小,此後resize擴容也都是每次乘 2,這麼設計的原因後面會詳細講。

hashmap-tableSizeFor

hash

JKD8 中putget時,對keyhashCode先用hash函數散列下,再計算下標:

hash-method
具體hash代碼如下:

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

由於h>>>16,高 16bit 補 0,一個數和 0 異或不變,所以hash函數大概的作用就是:高 16bit 不變,低 16bit 和高 16bit 做了一個異或,目的是減少碰撞。

按照函數註釋,因爲bucket數組大小是 2 的冪,計算下標index = (table.length - 1) & hash,如果不做hash處理,相當於散列生效的只有幾個低 bit 位,爲了減少散列的碰撞,設計者綜合考慮了速度、作用、質量之後,使用高 16bit 和低 16bit 異或來簡單處理減少碰撞,而且 JDK8 中用了複雜度O(logn)的樹結構來提升碰撞下的性能。具體性能提升可以參考「Java 8:HashMap的性能提升」這篇文章。

put

put函數的思路大致分以下幾步:

  • keyhashCode()進行hash後計算數組下標index
  • 如果當前數組tablenull,進行resize()初始化;
  • 如果沒碰撞直接放到對應下標的bucket裏;
  • 如果碰撞了,且節點已經存在,就替換掉value
  • 如果碰撞後發現爲樹結構,掛載到樹上;
  • 如果碰撞後爲鏈表,添加到鏈表尾,並判斷鏈表如果過長(大於等於TREEIFY_THRESHOLD,默認8),就把鏈表轉換成樹結構;
  • 數據put後,如果數據量超過threshold,就要resize

具體代碼如下:

hashmap-put
hashmap-put2

resize

resize()用來第一次初始化,或者put之後數據超過了threshold後擴容,resize的註釋如下:

Initializes or doubles table size. If null, allocates in accord with initial capacity target held in field threshold. Otherwise, because we are using power-of-two expansion, the elements from each bin must either stay at same index, or move with a power of two offset in the new table.

數組下標計算:index = (table.length - 1) & hash,由於table.length也就是capacity肯定是 2 的 N 次方,使用&位運算意味着只是多了最高位,這樣就不用重新計算index,元素要麼在原位置,要麼在原位置+oldCapacity

如果增加的高位爲 0,resizeindex不變,如圖所示:

jdk8-resize-1

如果增加的高位爲 1,resizeindex增加oldCap,如圖所示:

jdk8-resize-2

這個設計的巧妙之處在於,節省了一部分重新計算hash的時間,同時新增的一位爲 0 或 1 的概率可以認爲是均等的,所以在resize的過程中就將原來碰撞的節點又均勻分佈到了兩個bucket裏。

jdk8-resize-code-1
jdk8-resize-code-2
jdk8-resize-code-3

JDK7 中的 HashMap 實現

new

JDK7 裏HashMapbucket數組也不會在new的時候分配,也是在第一次put的時候通過inflateTable()函數進行分配。

JDK7 中HashMapbucket數組大小也一定是 2 的冪,同樣有計算下標簡便的優點。如果你通過HashMap(int initialCapacity)構造器傳入initialCapacity,會先存入threshold,在第一次put時調用inflateTable()初始化,會計算出比initialCapacity大的 2 的冪作爲初始化數組的大小,此後resize擴容也都是每次乘 2。

jdk7-hashmap

hash

JKD7 中,bucket數組下標也是按位與計算,但是hash函數與 JDK8 稍有不同,代碼註釋如下:

Retrieve object hash code and applies a supplemental hash function to the result hash, which defends against poor quality hash functions. This is critical because HashMap uses power-of-two length hash tables, that otherwise encounter collisions for hashCodes that do not differ in lower bits. Note: Null keys always map to hash 0, thus index 0.

hash爲了防止只有hashCode()的低 bit 位參與散列容易碰撞,也採用了位移異或,只不過不是高低 16bit,而是如下代碼中多次位移異或。

JKD7 的hash中存在一個開關:hashSeed。開關打開(hashSeed不爲0)的時候,對String類型的key採用sun.misc.Hashing.stringHash32hash算法;對非String類型的key,多一次和hashSeed的異或,也可以一定程度上減少碰撞的概率。

JDK 7u40 以後,hashSeed被移除,在 JDK8 中也沒有再採用,因爲stringHash32()的算法基於MurMur哈希,其中hashSeed的產生使用了Romdum.nextInt()實現。Rondom.nextInt()使用AtomicLong,它的操作是CAS的(Compare And Swap)。這個 CAS 操作當有多個 CPU 核心時,會存在許多性能問題。因此,這個替代函數在多核處理器中表現出了糟糕的性能。

具體hash代碼如下所示:

jdk7-hash

hashSeed默認值是 0,也就是默認關閉,任何數字與 0 異或不變。hashSeed會在capacity發生變化的時候,通過initHashSeedAsNeeded()函數進行計算。當capacity大於設置值Holder.ALTERNATIVE_HASHING_THRESHOLD後,會通過sun.misc.Hashing.randomHashSeed產生hashSeed值,這個設定值是通過 JVM 的jdk.map.althashing.threshold參數來設置的,具體代碼如下:

jdk7-initHashSeedAsNeeded-1
jdk7-initHashSeedAsNeeded-2

put

JKD7 的put相比於 JDK8 就要簡單一些,碰撞以後只有鏈表結構。具體代碼如下:

jdk7-put-1
jdk7-put-2

resize

JDK7 的resize()也是擴容兩倍,不過擴容過程相對 JDK8 就要簡單許多,由於默認initHashSeedAsNeeded內開關都是關閉狀態,所以一般情況下transfer不需要進行rehash,能減少一部分開銷。代碼如下所示:

jdk7-resize

總結

HashMapnew後並不會立即分配bucket數組,而是第一次put時初始化,類似ArrayList在第一次add時分配空間。

HashMapbucket數組大小一定是 2 的冪,如果new的時候指定了容量且不是 2 的冪,實際容量會是最接近(大於)指定容量的 2 的冪,比如new HashMap<>(19),比 19 大且最接近的 2 的冪是 32,實際容量就是 32。

HashMapput的元素數量大於Capacity * LoadFactor(默認16 * 0.75) 之後會進行擴容。

JDK8 處於提升性能的考慮,在哈希碰撞的鏈表長度達到TREEIFY_THRESHOLD(默認8)後,會把該鏈表轉變成樹結構。

JDK8 在resize的時候,通過巧妙的設計,減少了rehash的性能消耗。

相對於 JDK7 的 1000 餘行代碼,JDK8 代碼量達到了 2000 餘行,對於這個大家最常用的數據結構增加了不少的性能優化。

仔細看完上面的分析和源碼,對HashMap內部的細節又多了些瞭解,有空的時候還是多翻翻源碼吧!

阿里巴巴 Java 開發規約》自誕生以來,一直處於挑戰漩渦的最中心,從這一個規約的小條目,看出來規約也是冰凍三尺,非一日之寒,研讀規約,其實能夠發現很多看似簡單的知識點背後,其實隱藏着非常深的邏輯知識點。


參考資料

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