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

JAVA全棧發表於2017-12-04

大熱的《阿里巴巴Java開發規約》中有提到:
【推薦】集合初始化時,指定集合初始值大小。
說明:HashMap使用如下構造方法進行初始化,如果暫時無法確定集合大小,那麼指定預設值(16)即可:

看到程式碼規約這一條的時候,我覺得是不是有點太 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的元素達到容量乘負載因子的時候,預設160.75,有1/4同學中槍)
HashMap 是寫程式碼時最常用的集合類之一,看來大家也不是全都很瞭解。孤盡乘勝追擊又丟擲問題:JDK8中 HashMap 和之前 HashMap 有什麼不同?
我知道 JDK8 中 HashMap 引入了紅黑樹來處理雜湊碰撞,具體細節和原始碼並沒有仔細翻過,看來是時候對比翻看下 JDK8 和 JDK7 的 HashMap 原始碼了。
通過對比翻看原始碼,先說下結論:
HashMap 在 new 後並不會立即分配bucket陣列,而是第一次 put 時初始化,類似 ArrayList 在第一次 add 時分配空間。
HashMap 的 bucket 陣列大小一定是2的冪,如果 new 的時候指定了容量且不是2的冪,實際容量會是最接近(大於)指定容量的2的冪,比如 new HashMap<>(19),比19大且最接近的2的冪是32,實際容量就是32。
HashMap 在 put 的元素數量大於 Capacity
LoadFactor(預設16 0.75) 之後會進行擴容。
JDK8在雜湊碰撞的連結串列長度達到TREEIFY_THRESHOLD(預設8)後,會把該連結串列轉變成樹結構,提高了效能。
JDK8在 resize 的時候,通過巧妙的設計,減少了 rehash 的效能消耗。
儲存結構
JDK7 中的 HashMap 還是採用大家所熟悉的陣列+連結串列的結構來儲存資料。
JDK8 中的 HashMap 採用了陣列+連結串列或樹的結構來儲存資料。
重要引數
HashMap中有兩個重要的引數,容量(Capacity) 和 負載因子(Load factor)
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
HashMap 的bucket陣列並不會在new 的時候分配,而是在第一次 put 的時候通過 resize() 函式進行分配。
JDK8中 HashMap 的bucket陣列大小肯定是2的冪,對於2的冪大小的 bucket,計算下標只需要 hash 後按位與 n-1,比%模運算取餘要快。如果你通過 HashMap(int initialCapacity) 構造器傳入initialCapacity,會先計算出比initialCapacity大的 2的冪存入 threshold,在第一次 put 的 resize() 初始化中會按照這個2的冪初始化陣列大小,此後 resize 擴容也都是每次乘2,這麼設計的原因後面會詳細講。

hash
JKD8 中put 和 get 時,對 key 的 hashCode 先用 hash 函式雜湊下,再計算下標:

具體 hash 程式碼如下:

由於 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函式的思路大致分以下幾步:
對key的hashCode()進行hash後計算陣列下標index;
如果當前陣列table為null,進行resize()初始化;
如果沒碰撞直接放到對應下標的bucket裡;
如果碰撞了,且節點已經存在,就替換掉 value;
如果碰撞後發現為樹結構,掛載到樹上。
如果碰撞後為連結串列,新增到連結串列尾,並判斷連結串列如果過長(大於等於TREEIFY_THRESHOLD,預設8),就把連結串列轉換成樹結構;
資料 put 後,如果資料量超過threshold,就要resize。
具體程式碼如下:

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,resize 後 index 不變,如圖所示:

如果增加的高位為1,resize 後 index 增加 oldCap,如圖所示:

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

JDK7 中的 HashMap 實現
new
JDK7 裡 HashMap的bucket陣列也不會在new 的時候分配,也是在第一次 put 的時候通過 inflateTable() 函式進行分配。
JDK7中 HashMap 的bucket陣列大小也一定是2的冪,同樣有計算下標簡便的優點。如果你通過 HashMap(int initialCapacity) 構造器傳入initialCapacity,會先存入 threshold,在第一次 put 時呼叫 inflateTable() 初始化,會計算出比initialCapacity大的2的冪作為初始化陣列的大小,此後 resize 擴容也都是每次乘2。

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.stringHash32的 hash 演算法;對非 String 型別的 key,多一次和hashSeed的異或,也可以一定程度上減少碰撞的概率。
JDK 7u40以後,hashSeed 被移除,在 JDK8中也沒有再採用,因為stringHash32()的演算法基於MurMur雜湊,其中hashSeed的產生使用了Romdum.nextInt()實現。Rondom.nextInt()使用AtomicLong,它的操作是CAS的(Compare And Swap)。這個CAS操作當有多個CPU核心時,會存在許多效能問題。因此,這個替代函式在多核處理器中表現出了糟糕的效能。
具體hash 程式碼如下所示:

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

put
JKD7 的put相比於 JDK8就要簡單一些,碰撞以後只有連結串列結構。具體程式碼如下:

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

總結

HashMap 在 new 後並不會立即分配bucket陣列,而是第一次 put 時初始化,類似 ArrayList 在第一次 add 時分配空間。
HashMap 的 bucket 陣列大小一定是2的冪,如果 new 的時候指定了容量且不是2的冪,實際容量會是最接近(大於)指定容量的2的冪,比如 new HashMap<>(19),比19大且最接近的2的冪是32,實際容量就是32。
HashMap 在 put 的元素數量大於 Capacity LoadFactor(預設16 0.75) 之後會進行擴容。
JDK8處於提升效能的考慮,在雜湊碰撞的連結串列長度達到TREEIFYTHRESHOLD(預設8)後,會把該連結串列轉變成樹結構。
JDK8在 resize 的時候,通過巧妙的設計,減少了 rehash 的效能消耗。
相對於 JDK7的1000餘行程式碼,JDK8程式碼量達到了2000餘行,對於這個大家最常用的資料結構增加了不少的效能優化。
仔細看完上面的分析和原始碼,對 HashMap 內部的細節又多了些瞭解,有空的時候還是多翻翻原始碼,^
^
《阿里巴巴Java開發規約》自誕生以來,一直處於挑戰漩渦的最中心,從這一個規約的小條目,看出來規約也是冰凍三尺,非一日之寒,研讀規約,其實能夠發現很多看似簡單的知識點背後,其實隱藏著非常深的邏輯知識點。
注:加群要求 學習交流群:450936584

1、想學習JAVA這一門技術, 對JAVA感興趣,想從事JAVA工作的。
2、工作0-5年,感覺自己技術不行,想提升的
3、如果沒有工作經驗,但基礎非常紮實,想提升自己技術的。
4、還有就是想一起交流學習的。
5、小號加群一律不給過,謝謝。
群內每天會分享最新的視訊和資料,可以免費領取學習視訊和資料
轉發此文章請帶上原文連結,否則將追究法律責任!

相關文章