Java:HashMap原理與設計緣由
前言
Java中使用最多的數據結構基本就是ArrayList和HashMap,HashMap的原理也常常出現在各種面試題中,本文就HashMap的設計與設計緣由作出一一講解,並點明面試常見的一些問題。
一 HashMap數據結構
HashMap是一張哈希表(即數組),表中的每個元素都是鍵值對(Map.Entry類)。並且每個元素都是一個鏈表(紅黑樹)的節點。並且HashMap的數組長度一定是2的次冪。
1.1 為何數組長度一定是2的次冪
正常情況下,新增節點時,會對節點進行取模運算,確定節點在哈希表中的位置。但是當哈希表(數組)長度為2的次冪時,取模運算可以修改為位與運算。
源碼如下:
static final int hash(Object key) {
if (key == null){
return 0;
}
int h;
h = key.hashCode();返回散列值也就是hashcode
// ^ :按位異或
// >>>:無符號右移,忽略符號位,空位都以0補齊
//其中n是數組的長度,即Map的數組部分初始化長度
return (n-1)&(h ^ (h >>> 16));
}
具體原理可以參考專門講解該算法的文章:
由HashMap哈希算法引出的求余%和與運算&轉換問題
二 HashMap的鍵值存儲
我們給 put() 方法傳遞鍵和值時,我們先對鍵調用 hashCode() 方法,計算並返回 hashCode,然后使用HashMap內部的hash算法,將hashCode計算為表中的具體位置,找到 Map 數組的 bucket 位置來儲存 Node 對象。
三 解決Hash碰撞
使用拉鏈法
如果hash到的數組位置已存在對象,即為Hash碰撞。JDK使用拉鏈法解決Hash碰撞問題。
即以原有的Node節點為基礎,構造鏈表。將新的Node節點設為鏈表表頭。
3.1 JDK7中新節點為表頭
如果已原有節點為表頭,則需要遍歷鏈表,徒增不必要的性能消耗
3.2 JDK8中新節點為表尾
因為JDK8中鏈表在長度大於等於8時會轉變為紅黑樹,所以每次在鏈表中添加節點,都必須遍歷鏈表計算一次鏈表長度,所以新節點直接在遍歷完鏈表后添加到表尾。
3.3 鏈表過長導致的復雜度問題
HashMap的查詢操作最佳時間復雜度是O(1)
,但是當表中的某個鏈表過長時,查詢該鏈表上的元素時間復雜度為O(n)
。JDK1.8
中解決了該問題,當HashMap中某鏈表長度大於8時,鏈表會重構為紅黑樹,這樣,HashMap的最壞時間復雜度為O(n)
。同理,為了不必要的消耗,當鏈表長度小於6時,紅黑樹會重新變回鏈表
3.4 還有什么方法解決Hash碰撞
開放尋址法,再哈希法
感興趣可以參看此文:
Hash碰撞和解決策略
四 HashMap的擴容
4.1 擴容時機
當size超過閾值(數組長度*負載因子)時,即開始擴容,HashMap的負載因子為0.75。
4.1.1 為何要數組未滿就擴容
避免頻繁出現Hash碰撞,造成拉鏈過長(紅黑樹過長)。這樣會導致查詢復雜度頻繁出現最壞情況
4.2 擴容過程
創建原本數組容量*2的新數組,將節點從原本的數組中遷移過去。
4.2.1 為何擴容的倍數是2倍
原因一上文已說明,方便進行哈希運算。
原因二是不需要重新計算Hash值(JDK1.8
優化)。經過觀測可以發現,我們使用的是2次冪的擴展(指長度擴為原來2倍),所以,經過rehash之后,元素的位置要么是在原位置,要么是在原位置再移動2次冪的位置。對應的就是下方的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.
*
* @return the table
*/
final Node<K,V>[] resize() { }
看下圖可以明白這句話的意思,n為table的長度,圖(a)表示擴容前的key1和key2兩種key確定索引位置的示例,圖(b)表示擴容后key1和key2兩種key確定索引位置的示例,其中hash1是key1對應的哈希值(也就是根據key1算出來的hashcode值)與高位與運算的結果。
元素在重新計算hash之后,因為n變為2倍,那么n-1的mask范圍在高位多1bit(紅色),因此新的index就會發生這樣的變化:
因此,我們在擴充HashMap的時候,不需要像JDK1.7的實現那樣重新計算hash,只需要看看原來的hash值新增的那個bit是1還是0就好了,是0的話索引沒變,是1的話索引變成“原索引+oldCap”。
五 重寫equals方法需同時重寫hashCode方法
這個是老生常談的問題了,如果順利理解了HashMap的底層結構那么這個問題就很好理解了。equals相同的key理論上必定有相同hashCode,所以必須也重寫hashCode方法。可以思考下如果沒重寫,在put,get過程中會導致什么問題。