<article class="syl-page-article syl-device-pc tt-article-content font_m" style="box-sizing: border-box; display: block; font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "WenQuanYi Micro Hei", "Helvetica Neue", Arial, sans-serif; line-height: 1.75; margin-bottom: 24px; font-size: 16px; color: rgb(34, 34, 34); word-wrap: break-word; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial;">
HashMap 是日常開發中,用的最多的集合類之一,也是面試中經常被問到的 Java 類之一。同時,HashMap 在實現方式上面又有十分典型的範例。不管是從哪一方面來看,學習 HashMap 都可以說是有利無害的。
分析 HashMap 的源碼的文章在網上面已經數不勝數了,本文另闢蹊徑來分析 HashMap 的設計思想。
底層數據結構
說到 HashMap 的數據庫,我們需要從兩個 JDK 版本來分析:JDK7 和 JDK8。
JDK7 版本的 HashMap 的數據結構爲:數組 + 鏈表。而 JDK8 版本的 HashMap 的數據結構爲: 數組 + 鏈表 + 紅黑樹。可以看到 7 和 8 中 HashMap 的底層數據結構最主要的區別就是 Java8 多了紅黑樹。
爲何是數組加鏈表?
上文中說到了 不管是 7 或者8 ,底層數據結構都是 數組 + 鏈表,但這又是爲什麼呢?
數組是一個鏈式數據結構。put的時候,通過哈希函數將數據進行 哈希運算 之後,就得到數組的下標,這樣子就可以將數據保存在對應的槽中,這個槽在 HashMap 中被稱爲 Entry。在 get 時候,通過相同的哈希函數,將 key 進行哈希運算,可以得到對應的下標,就可以快速找到該 key 對應的 value。這時候, get 的時間複雜度還是 O(1)。
但,哈希運算就避免不了有哈希衝突,也就說,不同的值通過哈希運算之後可能得到同一個值。在散列表的相關概念中,我們說了幾種解決哈希衝突的方案,在 HashMap中,則是採用了鏈表法。
也就是說,發生了衝突之後,我們在Entry中形成一個單鏈表。但是這裏有存在了一個問題,如果鏈表過長,檢索起來的效率同樣也會很低。於是,在 Java8 中,通過鏈表轉紅黑樹來解決這個問題。
爲何要加上紅黑樹
爲什麼要鏈表轉紅黑樹,我們需要從數據結構來解析。
如果從一個無序單鏈表中檢索數據,我們只能從頭到尾一個一個檢索,一旦數據量很大的情況下,檢索的效率就很低。這時,我們想到了紅黑樹,從目前的情況來看,紅黑樹能很好地解決這個問題。
我們先來看看紅黑樹的定義:
紅黑樹是每個節點都帶有顏色屬性的二叉查找樹,顏色爲紅色或黑色。在二叉查找樹強制一般要求以外,對於任何有效的紅黑樹我們增加了如下的額外要求:
- 節點是紅色或黑色。
- 根是黑色。
- 所有葉子都是黑色(葉子是NIL節點)。
- 每個紅色節點必須有兩個黑色的子節點。(從每個葉子到根的所有路徑上不能有兩個連續的紅色節點。)
- 從任一節點到其每個葉子的所有簡單路徑都包含相同數目的黑色節點。
紅黑樹
要是紅黑樹,首先得是二叉查找樹:
二叉查找樹(英語:Binary Search Tree),也稱爲二叉搜索樹、有序二叉樹(ordered binary tree)或排序二叉樹(sorted binary tree),是指一棵空樹或者具有下列性質的二叉樹:
- 若任意節點的左子樹不空,則左子樹上所有節點的值均小於它的根節點的值;
- 若任意節點的右子樹不空,則右子樹上所有節點的值均大於或等於它的根節點的值;
- 任意節點的左、右子樹也分別爲二叉查找樹;
簡單做一個總結,紅黑樹的左節點要比父節點小,右節點要比父節點大。如果要檢索一個數字,可以將時間複雜度從 O(n) 降低到 O(logn)。
當然了,添加了紅黑樹的數據結構之後,代碼實現要比 只用數組 + 鏈表要複雜了好幾倍。看代碼的時候簡直是不能再痛苦了。
什麼時候轉成紅黑樹,有什麼轉成鏈表
在源碼中有這麼一個字段,static final int TREEIFY_THRESHOLD = 8;,見字知義,這個字段的意思鏈表轉紅黑樹的閾值,也就是 8。同樣的,還有這麼一個字段,static final int UNTREEIFY_THRESHOLD = 6;,它意思是紅黑樹轉鏈表的閾值。
爲什麼是 8 呢?在源碼的註釋中也有解釋,英文翻譯過來就是下面的意思。
鏈表查詢的時間複雜度是 O (n),紅黑樹的查詢複雜度是 O (log n)。在鏈表數據不多的時候,使用鏈表進行遍歷也比較快,只有當鏈表數據比較多的時候,纔會轉化成紅黑樹,但紅黑樹需要的佔用空間是鏈表的 2 倍,考慮到轉化時間和空間損耗,所以我們需要定義出轉化的邊界值。
在考慮設計 8 這個值的時候,我們參考了泊松分佈概率函數,由泊松分佈中得出結論,鏈表各個長度的命中概率爲:
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
複製代碼
意思是,當鏈表的長度是 8 的時候,出現的概率是 0.00000006,不到千萬分之一,所以說正常情況下,鏈表的長度不可能到達 8 ,而一旦到達 8 時,肯定是 hash 算法出了問題,所以在這種情況下,爲了讓 HashMap 仍然有較高的查詢性能,所以讓鏈表轉化成紅黑樹,我們正常寫代碼,使用 HashMap 時,幾乎不會碰到鏈表轉化成紅黑樹的情況,畢竟概念只有千萬分之一。
爲什麼兩個閾值不一樣的,大家想想,如果一樣的,在鏈表達到8 的時候,會轉成紅黑樹,但紅黑樹轉鏈表的閾值也是8,這時候就會出現循環轉換。
鏈表轉紅黑樹還有一個條件,就是當數組容量大於 64 時,鏈表纔會轉化成紅黑樹
擴容的條件
在說擴容之前,先來說說 HashMap 在 7 和 8 中初始化時的不同表現。
在 Java 7 中,HashMap 初始化的時候,會有個默認容量 (16)。但在 Java8 中,HashMap 初始化的時候,默認容量爲0,只有在第一次 put 的時候,纔會擴容到 16。(其實 ArrayList 在 Java8 也是這麼表現的)。
在 HashMap 源碼中,有一個字段定義 static final float DEFAULT_LOAD_FACTOR = 0.75f;。這個字段的意思是,當HashMap 的長度 = HashMap 當前容量 * 0.75的時候,就會發生擴容。
關於爲什麼負載因子是 0.75,我們可以在源碼註釋找到一定的答案。
[圖片上傳失敗...(image-304285-1607318505583)]
load factor
大致意思就是說負載因子是0.75的時候,空間利用率比較高,而且避免了相當多的Hash衝突,使得底層的鏈表或者是紅黑樹的高度比較低,提升了空間效率。
HashMap的擴容是變成原先容量的 2 倍。
Hash函數
我們先來看看 Java 8 的 hash 函數。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
複製代碼
這裏的大概意思就是,先計算出 key 的 hashCode h。然後計算計算 h ^ (h >>> 16)。無符號右移16位。這麼做的好處是使大多數場景下,算出來的 hash 值比較分散。
一般來說,hash 值算出來之後,要計算當前 key 在數組中的索引下標位置時,可以採用取模的方式,就是索引下標位置 = hash 值 % 數組大小,這樣做的好處,就是可以保證計算出來的索引下標值可以均勻的分佈在數組的各個索引位置上,但取模操作對於處理器的計算是比較慢的,數學上有個公式,當 b 是 2 的冪次方時,a % b = a &(b-1),所以此處索引位置的計算公式我們可以更換爲: (n-1) & hash。
此問題可以延伸出三個小問題:
1:爲什麼不用 key % 數組大小,而是需要用 key 的 hash 值 % 數組大小。
答:如果 key 是數字,直接用 key % 數組大小是完全沒有問題的,但我們的 key 還有可能是字符串,是複雜對象,這時候用 字符串或複雜對象 % 數組大小是不行的,所以需要先計算出 key 的 hash 值。
2:計算 hash 值時,爲什麼需要右移 16 位?
答:hash 算法是 h ^ (h >>> 16),爲了使計算出的 hash 值更分散,所以選擇先將 h 無符號右移 16 位,然後再於 h 異或時,就能達到 h 的高 16 位和低 16 位都能參與計算,減少了碰撞的可能性。
3:爲什麼把取模操作換成了 & 操作?
答:key.hashCode() 算出來的 hash 值還不是數組的索引下標,爲了隨機的計算出索引的下表位置,我們還會用 hash 值和數組大小進行取模,這樣子計算出來的索引下標比較均勻分佈。
取模操作處理器計算比較慢,處理器對 & 操作就比較擅長,換成了 & 操作,是有數學上證明的支撐,爲了提高了處理器處理的速度。
hash 衝突時怎麼辦?
hash 衝突指的是 key 值的 hashcode 計算相同,但 key 值不同的情況。
如果桶中元素原本只有一個或已經是鏈表了,新增元素直接追加到鏈表尾部;
如果桶中元素已經是鏈表,並且鏈表個數大於等於 8 時,此時有兩種情況:
- 如果此時數組大小小於 64,數組再次擴容,鏈表不會轉化成紅黑樹;
- 如果數組大小大於 64 時,鏈表就會轉化成紅黑樹。
這裏不僅僅判斷鏈表個數大於等於 8,還判斷了數組大小,數組容量小於 64 沒有立即轉化的原因,猜測主要是因爲紅黑樹佔用的空間比鏈表大很多,轉化也比較耗時,所以數組容量小的情況下衝突嚴重,我們可以先嚐試擴容,看看能否通過擴容來解決衝突的問題。
</article>