HashMap 是平常開發中,用的最多的集合類之一,也是面試中常常被問到的 Java 類之一。同時,HashMap 在實現方式上面又有十分典型的範例。不論是從哪一方面來看,學習 HashMap 均可以說是有利無害的。java
分析 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 中,經過鏈表轉紅黑樹來解決這個問題。性能
爲何要鏈表轉紅黑樹,咱們須要從數據結構來解析。學習
若是從一個無序單鏈表中檢索數據,咱們只能從頭至尾一個一個檢索,一旦數據量很大的狀況下,檢索的效率就很低。這時,咱們想到了紅黑樹,從目前的狀況來看,紅黑樹能很好地解決這個問題。spa
咱們先來看看紅黑樹的定義:
紅黑樹是每一個節點都帶有顏色屬性的二叉查找樹,顏色爲紅色或黑色。在二叉查找樹強制通常要求之外,對於任何有效的紅黑樹咱們增長了以下的額外要求:
要是紅黑樹,首先得是二叉查找樹:
二叉查找樹(英語: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,咱們能夠在源碼註釋找到必定的答案。
大體意思就是說負載因子是0.75的時候,空間利用率比較高,並且避免了至關多的Hash衝突,使得底層的鏈表或者是紅黑樹的高度比較低,提高了空間效率。
HashMap
的擴容是變成原先容量的 2 倍。
咱們先來看看 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 衝突指的是 key 值的 hashcode 計算相同,但 key 值不一樣的狀況。
若是桶中元素本來只有一個或已是鏈表了,新增元素直接追加到鏈表尾部;
若是桶中元素已是鏈表,而且鏈表個數大於等於 8 時,此時有兩種狀況:
這裏不只僅判斷鏈表個數大於等於 8,還判斷了數組大小,數組容量小於 64 沒有當即轉化的緣由,猜想主要是由於紅黑樹佔用的空間比鏈表大不少,轉化也比較耗時,因此數組容量小的狀況下衝突嚴重,咱們能夠先嚐試擴容,看看可否經過擴容來解決衝突的問題。
文章首發於:baozi.fun/archives/th…