前言
大熱的《阿里巴巴 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
源碼了。
通過對比翻看源碼,先說下結論:
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
中有兩個重要的參數,初始容量和負載因子:
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
代碼如下:
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
函數的思路大致分以下幾步:
- 對
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 處於提升性能的考慮,在哈希碰撞的鏈表長度達到TREEIFY_THRESHOLD
(默認8
)後,會把該鏈表轉變成樹結構。
JDK8 在resize
的時候,通過巧妙的設計,減少了rehash
的性能消耗。
相對於 JDK7 的 1000 餘行代碼,JDK8 代碼量達到了 2000 餘行,對於這個大家最常用的數據結構增加了不少的性能優化。
仔細看完上面的分析和源碼,對HashMap
內部的細節又多了些瞭解,有空的時候還是多翻翻源碼吧!
《阿里巴巴 Java 開發規約》自誕生以來,一直處於挑戰漩渦的最中心,從這一個規約的小條目,看出來規約也是冰凍三尺,非一日之寒,研讀規約,其實能夠發現很多看似簡單的知識點背後,其實隱藏着非常深的邏輯知識點。
參考資料: