深入淺出HashMap的設計與優化

可以在我的個人網站中查看該文章?深入淺出HashMap的設計與優化
該篇文章是 極客時間 《Java性能調優實戰》中的內容,以下是自己整理的算是筆記吧。

深入淺出HashMap的設計與優化

我們先了解一下常用的數據結構。

常用的數據結構

  • 數組:採用一段連續的存儲單元來存儲數據。對於指定下表的查找,時間複雜度爲O(1),但在數組中間以及頭部插入數據時,需要複製移動後面的元素。
  • 鏈表:一種在物理存儲單元上非連續、非順序的存儲結構,數據元素的邏輯順序是通過鏈表中的指針鏈接次序實現的。
    • 鏈表由一系列結點(鏈表中每一個元素)組成,結點可以在運行時動態生成。每個結點都包含“存儲數據單元的數據域”和“存儲下一個結點的指針域”這兩部分。
    • 由於鏈表不用必須按順序存儲,所以鏈表在插入的時候可以達到O(1)的複雜度,但查找一個結點或者訪問特定編號的結點需要O(n)的時間。
  • 哈希表:根據關鍵碼值(key value)直接進行訪問的數據結構。通過把關鍵碼映射到表中一個位置來訪問記錄,以加快查找速度。這個映射函數叫做哈希函數,存放記錄的數組就叫做哈希表。
  • :由n(n>1)個有限結點組成的一個具有層次關係的集合,就像是一棵倒掛的樹。

HashMap的實現結構

作爲最常用的Map類,它是基於哈希表實現的,繼承了AbstractMap並實現了Map接口。

哈希表將鍵Hash值映射到內存地址,即根據鍵獲取對應的值,並將其存儲到內存地址。也就是說HashMap是根據鍵的Hash值來決定對應值的存儲位置。通過這種索引方式,HashMap獲取數據的速度會非常快。

例如:存儲鍵值對(x,"aa")時,哈希表會通過哈希函數f(x)得到"aa"的實現存儲位置。

但也會有新的問題。如果再來一個(y,"bb"),哈希函數f(y)的哈希值跟之前f(x)是一樣的,這樣兩個對象的存儲地址就衝突了,這種現象就被稱爲哈希衝突解決哈希衝突的方式很多,比如:開放定址法、再哈希函數法和鏈地址法

  • 開放定址法:開放定址法很簡單,當發生哈希衝突時,如果哈希表未被裝滿,說明在哈希表中必然還有空位置,那麼可以把key存放到衝突位置後面的空位置上去,這種方式存在很多缺點,例如,查找、擴容等。
  • 再哈希法:再哈希法就是在同義詞產生地址衝突時再計算另一個哈希函數地址,直到衝突不再發生,這種方法不易產生“聚集”,但卻增加了計算時間。如果不考慮添加元素的時間成本,且對查詢元素的要求極高,可以考慮這種算法設計。
  • 鏈地址法:HashMap則是綜合考慮了所有元素,採用鏈地址法解決哈希衝突問題。這種方式採用了數組(哈希表) + 鏈表的數據結構,當發生哈希衝突時,就用一個鏈表結構存儲相同Hash值的數據。

HashMap的重要性

HashMap的源碼中,可以發現,HashMap是由一個Node數組構成,每個Node包含了一個key-value鍵值對。

Node類作爲HashMap中的一個內部類,除了key、value兩個屬性外,還定義了一個next指針。當有哈希衝突 時,HashMap會用之前數組當中相同哈希值對應存儲的Node對象,通過指針指向新增的相同哈希值的Node對象的引用。

HashMap還有兩個重要的屬性:加載因子(loadFactor)和邊界值(threshold)。在初始化HashMap時,就會涉及到這兩個關鍵初始化參數。

loadFactor屬性是用來間接設置Entry數組(哈希表)的內存空間大小,在初始化HashMap不設置參數的情況下,默認loadFactor值爲0.75

  • 爲什麼是0.75?
    • 因爲對於使用鏈表法的哈希表來說,查找一個元素的平均時間是O(1+n),這裏的 n 指的是遍歷鏈表的長度,因此加載因子越大,對空間的利用就越充分,這就意味着鏈表的長度越長,查找效率也就越低。如果設置的加載因子太小,那麼哈希表的數據將過於稀疏,對空間造成嚴重浪費。

Entry數組的Threshold是通過初始容量(16)和loadFactor計算得到的。在初始HashMap不設置參數的情況下,默認邊界值爲12。(16 * 0.75)。如果在初始化時,設置初始容量較小,HashMap中Node的數量超過邊界值,HashMap就會調用resize()方法重新分配table數組。這將會導致HashMap數組複製,遷移到另一塊內存中去,從而影響HashMap的效率。

HashMap添加元素優化

初始化完成後,HashMap就可以使用put()方法添加鍵值對了。從下面源碼可以看出,當程序將一個key-value對添加到HashMap中,程序首先會根據該key的hashCode()返回值,再通過hash()方法計算出hash值,再通過putVal方法中的(n-1) & hash決定該Node的存儲位置。

如果不太清楚hash()以及(n-1)&hash的算法,就請看下面詳述:

如果我們沒有使用hash()方法計算hashCode,而是直接使用對象的hashCode值,會出現什麼問題?

假設要添加兩個對象 a 和 b,如果數組長度是16,這時對象 a 和 b 通過公式(n-1)&hash運算,也就是(16-1) & a.hashCode(16-1) & b.hashCode,15的二進制爲0000000000000000000000000001111,假設對象 a 的hashCode爲1000010001110001000001111000000,對象 b 的hashCode爲0111011100111000101000010100000,你會發現上述與運算結果都是0,這樣的hash結果讓人失望,不是一個好的哈希算法。

但如果將hashCode值右移16位(h >>> 16代表無符號右移16位),也就是取int類型的一半,剛好可以將該二進制數對半切開,並且使用位異或運算(如果兩個數對應的位置相反,則結果爲1,反之爲0),這樣的話,就能避免上面的情況發生。這就是hash()方法的具體實現方式。就是儘量打亂hashCode真正參與運算的低16位

(n-1) & hash的設計,這裏的n代表哈希表的長度,哈希表習慣將長度設置爲2的n次方,這樣恰好可以保證(n-1) & hash的計算得到的索引值總是位於table數組的索引之內。例如:hash=15,n=16時,結果爲15;hash=17,n=16時,結果爲1。

在獲得Node的存儲位置後,如果判斷Node不在哈希表中,就新增一個Node,並添加到哈希表中。

從圖中可以看出

  • 如果創建HashMap時沒有指定大小,是不會在構造函數中指定默認初始大小的,而是在第一次put添加元素時初始化。

  • 在JDK1.8中,HashMap引入了紅黑樹結構來提升鏈表的查詢效率。

    • 這是因爲鏈表的長度超過8後,紅黑樹的查詢效率要比鏈表高,所以當鏈表超過8時,HashMap就會將鏈表轉換爲紅黑樹,這裏值得注意的一點是,這時新增由於存在左旋、右旋效率會降低。這也就解決了上面提到過的,鏈表長度過長導致查詢時間複雜度高的問題。

以下是put的實現源碼:

 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
//1、判斷當 table 爲 null 或者 tab 的長度爲 0 時,即 table 尚未初始化,此時通過 resize() 方法得到初始化的 table
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
//1.1、此處通過(n - 1) & hash 計算出的值作爲 tab 的下標 i,並另 p 表示 tab[i],也就是該鏈表第一個節點的位置。並判斷 p 是否爲 null
            tab[i] = newNode(hash, key, value, null);
//1.1.1、當 p 爲 null 時,表明 tab[i] 上沒有任何元素,那麼接下來就 new 第一個 Node 節點,調用 newNode 方法返回新節點賦值給 tab[i]
        else {
//2.1 下面進入 p 不爲 null 的情況,有三種情況:p 爲鏈表節點;p 爲紅黑樹節點;p 是鏈表節點但長度爲臨界長度 TREEIFY_THRESHOLD,再插入任何元素就要變成紅黑樹了。
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
//2.1.1HashMap 中判斷 key 相同的條件是 key 的 hash 相同,並且符合 equals 方法。這裏判斷了 p.key 是否和插入的 key 相等,如果相等,則將 p 的引用賦給 e

                e = p;
            else if (p instanceof TreeNode)
//2.1.2 現在開始了第一種情況,p 是紅黑樹節點,那麼肯定插入後仍然是紅黑樹節點,所以我們直接強制轉型 p 後調用 TreeNode.putTreeVal 方法,返回的引用賦給 e
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
//2.1.3 接下里就是 p 爲鏈表節點的情形,也就是上述說的另外兩類情況:插入後還是鏈表 / 插入後轉紅黑樹。另外,上行轉型代碼也說明了 TreeNode 是 Node 的一個子類
                for (int binCount = 0; ; ++binCount) {
// 我們需要一個計數器來計算當前鏈表的元素個數,並遍歷鏈表,binCount 就是這個計數器

                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) 
// 插入成功後,要判斷是否需要轉換爲紅黑樹,因爲插入後鏈表長度加 1,而 binCount 並不包含新節點,所以判斷時要將臨界閾值減 1
                            treeifyBin(tab, hash);
// 當新長度滿足轉換條件時,調用 treeifyBin 方法,將該鏈表轉換爲紅黑樹
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }


HashMap獲取元素優化

當HashMap中只存在數組,而數組中沒有Node鏈表時,是HashMap查詢數據性能最好的時候。一旦發生大量的哈希衝突,就會產生Node鏈表,這個時候每次查詢元素都可能遍歷Node鏈表,從而降低查詢數據的性能。

特別是在鏈表長度過長的情況下,性能將明顯降低,紅黑樹的使用很好解決了這個問題,使得查詢的平均複雜度降低到了O(log(n)),鏈表越長,使用紅黑樹替換後的查詢效率提升就越明顯。

我們在編碼中也可以優化HashMap的性能,例如,重寫key值的hashCode()方法,降低哈希衝突,從而減少鏈表的產生,高效利用哈希表,達到提高性能的效果。

HashMap擴容優化

HashMap也是數組類型的數據結構,所以一樣存在擴容的情況。

在JDK1.7中,HashMap整個擴容過程就是分別取出數組元素,一般該元素是最後一個放入鏈表中的元素,然後遍歷以該元素爲頭的單向鏈表元素,依據每個被遍歷元素的hash值計算其在新數組中的下標,然後進行交換。這樣的擴容方式會將原來哈希衝突的單向鏈表尾部變成擴容後單向鏈表的頭部。

而在JDK1.8中,HashMap對擴容操作做了優化。由於擴容數組的長度是2倍關係,所以對於假設初始tableSize = 4要擴容到8來說就是0100到1000的變化(左移一位就是2倍),在擴容中只用判斷原來的hash值和左移動的一位(newtable的值)按位與操作是0或1就行,0的話索引不變,1的話索引變成原索引加上擴容前數組。

之所以能通過這種“與運算”來重新分配索引,是因爲hash值本來就是隨機的,而hash按位與上newTable得到的0(擴容前的索引位置)和1(擴容前索引位置加上擴容前數組長度的數值索引處)就是隨機的,所以擴容的過程就能把之前哈希衝突的元素再隨機分佈到不同的索引中去。

問:實際應用中,設置初始容量,一般得是2的整數次冪,爲什麼?

2的冪次方減 1 ,讓數組每一個位置都能添加到元素。例如十進制8,對應二進制1000,減1是0111,這樣在&hash值使數組每個位置都是可以添加到元素的,如果有一個位置爲0,也就是說數組下標爲2的位置總是空的。

如果初始化大小設置的不是2的冪次方,HashMap也會調整到比初始化值大且最近的一個冪作爲capacity。

就是爲了減少哈希衝突,均勻分佈元素。

總結

  • HashMap通過哈希表數據結構的形式來存儲鍵值對,這種設計的好處就是查詢鍵值對的效率高。
  • 在使用HashMap時,可以結合場景來設置初始容量和加載因子兩個參數,當查詢操作較爲頻繁時,可以適當減少加載因子;如果對內存利用率要求比較高,可以適當增加加載因子。
  • 在預知存儲數據量的情況下,提前設置初始容量。初始容量 = 預知數據量 / 加載因子。這樣做的好處可以減少resize()操作,提高HashMap效率。
  • HashMap使用了數組+鏈表這兩種數據結構相結合的方式實現了鏈地址法,當有哈希衝突時,就可以將衝突的鍵值對鏈成一個鏈表。
    • 這種方式又存在鏈表過長,查詢數據時間複雜度增加的問題。HashMap在Java8中使用了紅黑樹來解決鏈表過長導致的查詢性能下降問題。

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