HashMap1.7 vs 1.8


  jdk 由 1.7 升級到 1.8 底層改動很大,今天我們先來看一下其中一個基本結構 hashmap 的優化改動。那么具體hashmap1.7 和 hashmap1.8 有哪些區別呢?

  1. JDK1.7用的是頭插法,而 JDK1.8及之后使用的都是尾插法

    那么他們為什么要這樣做呢?

    因為 JDK1.7 是用單鏈表進行的縱向延伸,當采用頭插法就是能夠提高插入的效率,效率高的原因:

      1. 頭插法不需要遍歷到鏈表尾部插入,節省了一定的遍歷時間

      2. 我們一般認為后插入的數據比較熱,所以當遇到查詢節點的時候可能會節省遍歷查詢對比的時間

      3. resize后鏈表可能倒序; 並發resize可能產生循環鏈。為什么會出現環形鏈表死循環問題呢?

假設初始 map 大小為 8,threadA 和 threadB 兩個線程想 hashMap 中添加數據,假設 threadA 獲取了執行權,向 hashmap 插入數據的時候開始擴容,此時創建了一個新的數組,還沒來得及轉移舊的數據,此時的狀態為:

此時 threadB 開始執行,假設 threadB 開始執行之后,添加數據的時候又開始擴容,此時 threadB 創建新數組通過完成了所有的操作,此時狀態:

假設此時 threadA 獲取到執行權,那么 threadA 開始執行,此時 threadA 還是如下圖所示,狀態沒有發生變化:

那么,此時我們可以看到數據 A 指向 B,剛才 threadB 執行完成之后數據 B 執行 A,此時就形成了循環列表為:

    在 JDK1.8 之后使用尾插法方式插入數據,能夠避免出現逆序且鏈表死循環的問題。

  2. 擴容后數據遷移流程(transfer)不一樣

   首先我們需要知道,hashmap 底層在計算下標位置的時候算法都是一樣的,都是直接用 hash值 和 需要擴容的二進制數進行 & 運算,在計算 indexOf 的即應該存放的哪個下標位置的時候使用計算公式為:hash值 & (length-1)

  這里有必要解釋一下 jdk 源碼為什么使用 & 的方式來計算,按照我們正常的思維方式不應該是 hash % length 嗎?

  其實 hash%length == hash&(length-1),而 & 操作是 % 操作運算速度提升了一個量級,故為什么 jdk 源碼使用了 & 操作來計算 indexOf。

  為了有同學還不理解兩者為什么相等,這里用實際例子來解釋:

  我們hashmap 默認初始值大小為 16,那么 length=16 轉為二進制是:10000, 假設 hash 值為 25 轉為 二進制是:11001 即:

  初始數據:11001

  數組長度:  1111

  按位 & :   1001

  取模運算: 1001

  結論:11001 & 1111 = 1001   (25 & (16 - 1))

       11001 % 1000 = 1001   (25 % 16)

  這就是為什么擴容的時候為啥一定必須是2的多少次冪的原因所在,因為如果只有2的n次冪的情況時,length -1 最后一位二進制數才一定是1,這樣能最大程度減少hash碰撞。

  但是 jdk1.7 擴容后遷移的流程:

    1. 首先新創建 2*length 大小的新數組

    2. 將原來老數組上的數據挨個拍的遷移到新的數組上

  jdk1.8 擴容后的遷移的流程有個技巧性的優化:

    1. 首先新創建 2 * length 大小的新數組

    2. 擴容前的原始位置 + 擴容的大小值,這種方式就相當於只需要判斷Hash值的新增參與運算的位是0還是1就直接迅速計算出了擴容后的儲存方式,非常的高效。這里有必要解釋一下

  重新回顧一下我們👆剛解釋的 hash%length == hash&(length-1) 公式,那么我們試想一下,當 length 擴大為 2*length,從二進制上觀察可以發現擴容后后幾位不會發生變化,只是高位又增加了以為,例如:剛開始 length=16 擴容后為 32,(length-1) 二進制變化也就是高位多加了即:15-->31  |  1111-->11111

  以上面的例子來看,那么我們的思路就來了,既然擴容后二進制后四位沒有發生變化,只是在第 5 位多增加了一位,那么我們是否可以這樣認為:

  凡是小於 length 的值都可以直接指向(因為 hash&(length-1) 不會發生變化,即當前數據的下標位置不會發生變化),大於 length 的值只需要看新增參與運算的位是 0 還是 1,是 0 就不變,是 1 就相應的增加對應擴容的值,具體如下圖所示:

總結詳情如下圖所示:

  

 

 

   由上圖可知:在計算hash值的時候,JDK1.7用了9次擾動處理=4次位運算+5次異或,而JDK1.8只用了2次擾動處理=1次位運算+1次異或。

  3. JDK1.7 和 JDK1.8 存儲結構不同

  JDK1.7 的時候使用的是:數組+ 單鏈表的數據結構。

  JDK1.8及之后時,使用的是:數組+鏈表+紅黑樹的數據結構(當鏈表的深度達到8的時候,也就是默認閾值,就會自動擴容把鏈表轉成紅黑樹的數據結構來把時間復雜度從O(n) 變成O(logN) 提高了效率)

  4.  JDK1.7 和 JDK1.8 擴容與插入數據順序不同

  JDK1.7 中先進行擴容后進行插入,而在 JDK1.8 中是先進行插入后進行擴容。

  JDK1.7 中:先擴容后插入

    當你發現你插入的桶是不是為空,如果不為空說明存在值就發生了hash沖突,那么就必須得擴容,但是如果不發生Hash沖突的話,說明當前桶是空的(后面並沒有掛有鏈表),那就等到下一次發生Hash沖突的時候在進行擴容,但是當如果以后都沒有發生hash沖突產生,那么就不會進行擴容了,減少了一次無用擴容,也減少了內存的使用

  JDK1.8 中:先插入后擴容

    主要是因為對鏈表轉為紅黑樹進行的優化,因為你插入這個節點的時候有可能是普通鏈表節點,也有可能是紅黑樹節點

    如果是鏈表節點,是否達到了 鏈表轉化為紅黑樹的閾值是8,如果沒有那么就還可以繼續插入。

    如果是紅黑樹節點,需要看插入紅黑樹節點是否還能滿足當前是紅黑樹的特性,如果還能繼續滿足即還沒有達到擴容的臨界條件。、

  這里提到了 “鏈表轉化為紅黑樹的閾值是8”,為什么會是 8,而不是其它數值呢?

  我們可以從jdk 源碼中的注釋部分分析得出:容器中節點分布在hash桶中的頻率遵循泊松分布,桶的長度超過8的概率非常非常小。所以作者應該是根據概率統計而選擇了8作為閥值

  

 

 


 

這里再總結一些面試長問的問題:

1、哈希表如何解決Hash沖突?

 

 

2、為什么HashMap具備下述特點

  1. 鍵-值(key-value)都允許為空

  2. 線程不安全

  3. 不保證有序

  4. 存儲位置隨時間變化

3、為什么 HashMap 中 String、Integer 這樣的包裝類適合作為 key 鍵

4、HashMap 中的 key若 Object類型, 則需實現哪些方法?

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM