HashMap在JDK8前後的區別

 

背景

       目前,部門的很多JAVA項目使用的還是JDK7,其實JDK8的升級進行了很多優化,而且目前最新的JDK版本已經已經到了JDK12,版本帝真的很可怕。其實也不用很慌,因爲從JDK9開始就是每半年發佈一個版本,2019年JDK就會到JDK13,更多的關注重大功能變更就好了。但是對於HashMap來說,JDK8的優化還是有可以看一下源碼的意義的,本文的目的就是針對於這個優化畫一下重點。

JDK7的HashMap原理

       在JDK7中,HashMap的實現方式是數組+鏈表。put過程,首先通過對key取hash值,然後根據hash值定位該key在數組中的索引查找數據。爲了解決哈希碰撞問題,數組的每一個元素又是一個鏈表,這樣hash值相同但是實際不相同的key會在同一個鏈表上。而get過程大致相同,先根據key取hash值,根據hash值拿到數組的索引,通過索引拿到鏈表,遍歷鏈表找到key對應的節點獲取數據。

JDK8針對HashMap的優化

        JDK7對於HashMap的設計也會有相應的問題。首先無論多麼優秀的哈希算法也無法避免大量key集中到一個鏈表上,我們也知道對於一個鏈表來說,一次查詢的時間複雜度爲O(n),如果將一個節點新加到鏈表的Head的話時間複雜度爲O(1)。因此JDK8就是通過使用紅黑樹在某些情況下代替鏈表來提高HashMap的查詢性能。總的來說,就是數組+鏈表 to 數組+鏈表+紅黑樹的優化方案。

 紅黑樹原理

       使用紅黑樹(Red Black Tree)在某些情況下替代鏈表是HashMap優化的中心思想,接下來我們就簡單介紹一些紅黑樹的原理,分析一下紅黑樹代替鏈表的好處。

       紅黑樹是一棵只有紅黑節點且近似平衡的二叉搜索樹,紅黑樹任意一個節點的左右子樹的高度差不會超過一倍。我個人覺得這是對紅黑樹一個比較標準的定義。首先紅黑樹是一棵二叉搜索樹,其次它並不是一棵完美平衡的二叉樹,而是近似平衡。紅黑樹在各個語言的基礎數據結構中都得到了廣泛的應用,例如:1.Java的1.8版本的HashMap和TreeMap。2. python的集合(set)數據類型等。下圖就是一顆標準的紅黑樹:

紅黑樹

        紅黑樹既然是一棵二叉搜索樹(BST),那我們就先來回顧一下二叉搜索樹的性質:

       1.假如左子樹不空,則左子樹上所有結點的值均小於它的根節點的值;

       2.假如右子樹不空,則右子樹上所有結點的值均大於它的根結點的值;

       3. 左、右子樹也分別爲二叉排序樹;

       因此紅黑樹也滿足二叉搜索樹的一些性質,例如:1.查詢的平均時間複雜度爲O(logn)。2. 插入的平均時間複雜度爲O(logn)。AKA,二叉搜索樹並不是平衡二叉樹,所以在某些情況下會有較壞的查詢效率,這要是紅黑樹較二叉搜索樹更有優勢的地方。紅黑樹也有一些自己的性質,這些性質能夠保證紅黑樹在修改或者刪除節點的時候做到自平衡,從而從頭到尾保證自己是一棵近似平衡的二叉查找樹並擁有較好的最壞查詢性能。這些性質可以總結爲5條:

1. 每個節點要麼是黑色,要麼是紅色。

2. 根節點是黑色。

3. 每個葉子節點(Nil)是黑色。

4. 每個紅色結點的兩個子結點一定都是黑色。

5. 任意一結點到每個葉子結點的路徑都包含數量相同的黑結點。

      紅黑樹的這5個性質保證了它的自平衡性。紅黑樹在修改以及刪除節點的時候往往會破壞性質4和性質5,爲了繼續滿足紅黑樹的性質會通過改變節點顏色、左旋(Rotate Left)和右旋(Rotate Right)進行調整,從而達到自平衡。左旋和右旋是二叉搜索樹的兩種調整方式,其作用就是在不改變二叉搜索樹的性質的前提下,將一個節點的左右子樹高度進行平衡,詳細原理這裏不闡述。性質4以及性質5能保證紅黑樹是一棵近似平衡的二叉搜索樹,也就是任意一個節點的左右子樹高度相差不會超過一倍。

        紅黑樹是一種比較複雜的數據結構,如果想要徹底搞懂會非常的費力,滿足樹自調整的條件就有8個之多,每種條件使用何種方式調整也比較難理解。同時,也勸導大家不用鑽牛角尖,畢竟如果不涉及到項目中設計一棵紅黑樹以及調優,很多細節搞懂了也很容易遺忘。這裏,只討論紅黑樹的優勢以及在何處使用。紅黑樹的自平衡性使其查找、修改和刪除的平均以及最壞的時間複雜度都爲O(logn)。對於鏈表來說,查找以及修改的時間複雜度爲O(n), 對於雙向鏈表來說,增加一個Node到鏈表頭或者尾的時間複雜度都是O(1)。因此,對於HashMap來說,使用紅黑樹替代鏈表綜合來看,確實有部分性能提升。

HashMap源碼解讀

        通過以上分析,HashMap的結構大致如下圖:

       接下來,我們要對JDK1.8裏的源碼進行一下解讀,剖析一下其實現原理。首先,來看一下HashMap的幾個初始參數的含義:

HashMap初始化參數

      上文展示的幾個參數都是HashMap底層非常重要的參數。其中加載因子和數組初始容量是可以通過HashMap的構造函數修改的,其他的參數對於使用JDK的人來說都是默認的。對於樹化閾值和反樹化閾值的定義也別有一番深意。對於長度爲6鏈表來說,get時需要遍歷的最多節點數爲6,而put一個節點卻非常容易,只需要找到鏈表頭,然後增加節點。對於節點數量爲8的紅黑樹來說,查找一個節點最多需要遍歷的節點數爲3,插入或者刪除一個節點需要遍歷的節點數也爲3,如果綜合一次get和一次put操作來說,需要遍歷的節點數也爲6。因此選擇6和8作爲兩個轉換過程的臨界值。另外,6和8中間還有一個整數7,對於一個頻繁put節點和刪除節點的HashMap來說,7能夠在一定程度上避免鏈表和紅黑樹中間頻繁轉換而帶來的性能消耗。但是,這也告訴我們HashMap的主要優勢還是根據key查找value的效率非常高,對於頻繁的put和delete操作來說紅黑樹的性能其實存在很大瓶頸,使用時如果發現HashMap出行性能瓶頸,可以往這方面考慮優化。

         然後,我們來看一下HashMap的幾個構造函數:

 

HashMap提供的構造函數

       從構造函數可以看出,在創建HashMap時可以指定哈希表容量和加載因子。其中加載因子loadFactor應該是一個0-1的浮點數。initialCapacity是一個HashMap設計者絞盡腦汁提高HashMap性能的參數。首先,上文提到,initialCapacity參數有默認值:

DEFAULT_INITIAL_CAPACITY = 1>>4

這個參數如果直接定義爲16,計算機會將10進制數16最終轉換爲二進制10000存儲,中間的轉換過程肯定要比使用二進制右移的方式要消耗性能。對於initialCapacity這個參數,其實有一個要求:該參數必須是2的n次冪。對於爲什麼有這樣的要求,要從HashMap的原理說起。之前已經說過,哈希表的原理就是根據key的哈希值定位該key所指定的value在數據中索引的位置,這其中有很多方法,針對於數組長度取餘就是常用的一種。但是HashMap裏使用如下所示:index = hashCode&(length-1)。其中length=initialCapacity。首先肯定的是與操作要比取餘操作的效率更高。什麼原理?舉例來說明。如果length=8,那麼對於lenth取餘的結果只能是0-7,轉換爲二進制爲0000-0111,意味着任何數num對8取餘,都等於num&0111(也就是7)的結果。如果hashCode爲12,二進制爲1100,對8取餘結果爲1100&0111=0100。從這個例子就可以看出,如果HashMap的底層數組長度length的長度爲2的n次冪,那麼計算hashCode對應在數組的索引index的值就可以通過hashCode&(length-1)的方式代替取餘操作,從而提高效率。

但是呢,在使用HashMap構造函數的時候,我們不需要考慮initialCapacity參數是否爲2的n次冪的問題,只管隨意指定一個正整數a即可,只不過HashMap本身會做一些轉化,結果爲2的ceil(log2(a)次冪。關鍵在於方法tableSizeFor,輸入是一個整數n,輸出是2的n次冪,例如:輸入8,輸出8。輸入9,輸出16。如果我來實現這函數,我的思路就是計算a = ceil(log2(n)),然後計算2的a次冪值。根據前文的描述,HashMap的創作者仍然是使用二進制位移的防範解決這個問題。基本思路就是:如果n爲2的整數次冪,那麼n == (n-1)|n>>>m結果爲true。這裏我不用更多的篇幅來解釋,有興趣的同學可以自己查看源碼。

      最後,我們來看一下另一個可能會嚴重影響HashMap性能的方法resize:

 

resize方法圖1

 

resize方法圖2

       resize方法做了兩件事兒:1.對舊的底層數組進行擴容。2.把舊的數組內的所有Node取出,重新分配到新的數組內。整個過程所耗費的空間複雜度爲O(n), 最優情況下的時間複雜度爲O(n),其中n爲哈希表內的key-value對個數。嗯,沒錯resize方法整體對於性能的消耗還是非常大的。那我們再看一下都哪些地方使用到了resize方法:

 1. putMapEntries方法。該方法是使用HashMap的構造函數,並使用了一個實現了Map接口的對象m作爲參數時候調用的。當m.size>threshold時候,HashMap要進行擴容操作。

 2. 數組內的鏈表達到樹化閾值,卻發現tab.length< MIN_TREEIFY_CAPACITY時。此時可能出現了大量的哈希碰撞,需要resize方法重新將哈希表內的Node散列。

 3. 餘下的情況基本上都是,向HashMap內加入新的key-value時,如果size>threshold時需要調用resize方法。

        綜上所述,如果想要避免HashMap內頻繁的調用resize方法,應該避免在有頻繁的put操作時,卻定義了較小的initCapacity參數(感覺大家都懂)。

總結

        以上就是本人覺得HashMap內需要詳細瞭解的一些知識。其實還有很多細節沒有講,比如如何創建Hash的迭代視圖、紅黑樹的實現細節。我發現如果把這些東西全都弄明白並寫下來,可以寫成一本書的一章了。鑽牛角尖需要大量的時間和精力,而且也容易遺忘,我這裏就不在深究了。以後如果用到項目中想要實現一棵紅黑樹,強烈推薦直接到HashMap裏參考紅黑樹的實現原理。反正寫了這些東西之後,感覺自己對紅黑樹還是比之前瞭解的更多了,收工。

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