Java HashMap 詳解

HashMap

HashMap 繼承自 AbstractMap,實現了 Map 接口,基於哈希表實現,元素以鍵值對的方式存儲,允許鍵和值爲 null。因爲 key 不允許重複,因此只能有一個鍵爲 null。HashMap 不能保證放入元素的順序,它是無序的,和放入的順序並不相同。HashMap 是線程不安全的。

1. 哈希表

哈希表基於數組實現,當前元素的關鍵字通過某個哈希函數得到一個哈希值,這個哈希值映射到數組中的某個位置。哈希函數的好壞直接決定該哈希表的性能

當我們對某個元素進行哈希運算,得到一個存儲地址,然後要進行插入的時候,發現已經被其他元素佔用了,這就是所謂的哈希衝突,也叫哈希碰撞

解決方法如下:

  • 開放定址法:當衝突發生時,使用某種探查技術在散列表中形成一個探查序列,沿此序列逐個單元地查找,直到碰到一個開放的地址(即該地址單元爲空),將待插入的新結點存入該地址單元
  • 鏈地址法:可將散列表定義爲一個由 m 個頭指針組成的指針數組,將所有關鍵字爲同義詞的結點鏈接在同一個單鏈表中,初始時數組中各分量的初值應均爲 1
  • 再哈希法:同時構造多個不同的哈希函數,發生衝突時再換別的哈希函數

2. JDK1.7 實現原理

HashMap 由數組和鏈表實現對數據的存儲,HashMap 裏面實現一個靜態內部類 Entry,包含 Key、Value 和對 key 的 hashcode 值進行 hash 運算後得到的 Hash 值,它還具有 Next 指針,可以連接下一個 Entry 實體,以此來解決 Hash 衝突的問題

3. JDK1.7 存儲流程

  • 初始化哈希表:真正初始化哈希表(初始化存儲數組)是在第一次添加鍵值對時
    • 數組爲空:設置默認閾值與初始容量
    • 設置了傳入容量:將傳入的容量大小轉化爲大於自身的最小的二次冪。如果超出最大允許容量,則設置爲最大值
  • 判斷鍵是否爲空:對 null 作哈希運算,結果爲 0,所以以 null 爲鍵的鍵值對一般放在數組首位,該位置的新值總是會覆蓋舊值
  • 計算元素存放位置:首先根據 key 的 hashcode 計算 hash 值,然後根據 hash 值計算 index 下標值
    • 哈希衝突:當發生哈希衝突時,爲了保證鍵的唯一性,哈希表不會馬上在鏈表中插入新數據,而是先遍歷鏈表,查找該鍵是否已存在,若已存在,替換即可
  • 添加鍵值對:使用頭插法,新添加元素放在鏈表頭部,原始節點作爲新節點的後繼節點

4. JDK1.7 哈希函數

JDK 1.7 做了 9 次擾動處理 = 4 次位運算 + 5 次異或運算

5. JDK1.7 下標計算

計算元素位置採用的是 & 運算,該方法返回 h & (length - 1),其中 h 爲 key 的 hash 值,length 是數組長度

6. JDK1.7 擴容機制

先判斷是否需要擴容,再插入

7. JDK1.8 實現原理

1.8 以前 HashMap 採用 數組 + 鏈表 實現,即使用鏈表處理衝突,同一 hash 值的節點都存儲在一個鏈表裏。但是當同一 hash 值相等的元素較多時,通過 key 值依次查找的效率較低。JDK1.8 中,HashMap 採用 數組 + 鏈表 + 紅黑樹 實現,當鏈表長度超過閾值時,將鏈表轉換爲紅黑樹,大大減少了查找時間

8. JDK1.8 存儲流程

  • 初始化哈希表:真正初始化哈希表(初始化存儲數組)是在第一次添加鍵值對時
    • 數組爲空:設置默認閾值與初始容量
    • 設置了傳入容量:將傳入的容量大小轉化爲大於自身的最小的二次冪。如果超出最大允許容量,則設置爲最大值
  • 判斷鍵是否爲空:對 null 作哈希運算,結果爲 0,所以以 null 爲鍵的鍵值對一般放在數組首位,該位置的新值總是會覆蓋舊值
  • 計算元素存放位置:首先根據 key 的 hashcode 計算 hash 值,然後根據 hash 值計算 index 下標值
    • 哈希衝突:當發生哈希衝突時,爲了保證鍵的唯一性,哈希表不會馬上在鏈表中插入新數據,而是先遍歷鏈表,查找該鍵是否已存在,若已存在,替換即可;如果不存在,判斷當前節點類型是不是樹型節點,如果是樹型節點,創造樹型節點插入紅黑樹中;如果不是樹型節點,創建普通 Node 加入鏈表中;判斷鏈表長度是否大於 8 並且數組長度大於 64, 大於的話鏈表轉換爲紅黑樹
  • 添加鍵值對:鏈表的插入方式從頭插法改成了尾插法,簡單說就是插入時,如果數組位置上已經有元素,1.7 將新元素放到數組中,原始節點作爲新節點的後繼節點,1.8 遍歷鏈表,將元素放置到鏈表的最後

9. JDK1.8 哈希函數

JDK 1.8 簡化了擾動函數 = 只做了 2 次擾動 = 1 次位運算 + 1 次異或運算,本質是哈希碼的低 16 位異或高 16 位

10. JDK1.8 下標計算

計算元素位置採用的是 & 運算,該方法返回 h & (length - 1),其中 h 爲 key 的 hash 值,length 是數組長度

11. JDK1.8 擴容機制

先進行插入,插入完成再判斷是否需要擴容。擴容時,1.7 需要對原數組中的元素進行重新 hash 定位,以確定在新數組中的位置,1.8 採用更簡單的判斷邏輯,位置不變或索引 + 舊容量大小


相關問題

1. 擴容機制?

HashMap 使用懶擴容機制,只有在進行 PUT 操作時纔會判斷是否擴容,需要用到的屬性有兩個:

  • 閾值:threshold,初始容量爲 16,擴容時需要使用
  • 負載因子:loadFactor,默認是 0.75,用於減緩哈希衝突,如果等到數組滿了才擴容,那是某些桶可能就不止一個元素了

閾值 = 數組大小 * 負載因子,容器默認大小爲 16,此時 閾值 = 16 * 0.75 = 12,如果當前數組中元素的數量大於閾值,則將數組大小擴大爲原來的兩倍,並將原來數組中的元素進行重新放到新數組中。需要注意的是,每次擴容之後,都要重新計算元素在數組的位置,因爲元素所在位置和數組長度有關,既然擴容後數組長度發生了變化,那麼元素位置也會發生變化

2. 針對擴容機制的優化方案?

我們可以自定義數組容量及加載因子的大小。加載因子過大時,HashMap 內的數組使用率高,內部極有可能形成 Entry 鏈,影響查找速度。加載因子過小時,HashMap 內的數組使用率低,內部不會生成 Entry 鏈,或者生成的 Entry 鏈很短,提高了查找速度,不過會佔用更多的內存。所以要進行時間和空間的折中考慮

3. 爲什麼不直接使用 hashcode 作爲存儲數組的下標位置?

因爲 key.hashCode() 函數調用的是 key 鍵值類型自帶的哈希函數,返回 int 型散列值。int 值範圍爲非常大,前後加起來大概 40 億的映射空間,一個 40 億長度的數組,內存是放不下的。而且使用之前還需要對數組的長度取模運算,得到餘數才能用來訪問數組下標

4. 爲什麼要作擾動處理?

加大哈希碼低位的隨機性,使得分佈更均勻,從而提高對應數組存儲下標位置的隨機性 & 均勻性,最終減少哈希衝突

5. 爲什麼採用(哈希碼 & 數組長度減一)這種方式?

這也解釋了爲什麼 HashMap 的數組長度要取 2 的整數冪。因爲 數組長度 減一 正好相當於一個低位掩碼。與操作的結果就是散列值的高位全部歸零,只保留低位值,用來做數組下標訪問,其結果與取模運算相同,效率卻要高很多

6. 爲什麼在 1.8 使用尾插法插入新結點?

因爲 1.7 擴容時,元素會被重新移動到新的數組,而使用頭插法會使鏈表發生反轉,比如原本是 A-B-C 的鏈表,擴容之後就變成 C-B-A 了,在多線程環境下,會導致鏈表成環的問題。而尾插法,在擴容時會保持鏈表原本的順序不變,就不會出現鏈表成環的問題

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章