HashMap實現原理

monkeysayhi發表於2017-10-18

HashMap的原理及實現

  • 個人對HashMap的總結,有錯誤請留言.
  • 本文是純文字介紹的,如果有朋友喜歡結合程式碼的話也可以直接點選文末連結。
  • 感謝閱讀.

概述

  • HashMap是在JDK1.2中引入的一種K/V對形式的集合類.
  • 在底層,HashMap通過陣列和單連結串列組合的結構形式來儲存資料,陣列在這作為一個外部結構,陣列中的每個節點被稱做Bucket(桶),而桶是由在單連結串列構成,JDK1.8之後為了解決長連結串列下,查詢和插入效率低下的情況,又引入了紅黑樹的作為桶的實現方式,
  • 桶中的各節點是由HashMap定義的Node內部類生成的,是普通的連結串列節點類.

HashMap的實現方式

  • 注意:HashMap是執行緒不安全的,在JDK1.8之前多執行緒情況下甚至可能會出現環路(後面會講),所以多執行緒狀態下還是要使用ConcurrentHashMap的.

重點引數

  • HashMap的引數不多,除去當做預設屬性的靜態常量和底層陣列物件,就只有以下五個
transient Node<K,V>[] table;
transient int size
transient int modCount; 
int threshold;
final float loadFactor;
複製程式碼
  • table就是整個HashMap的底層陣列,table的初始化並不在建構函式中完成,而是在resize()方法中完成.

    • table的初始化可能有點繞,建構函式中最多指定了閾值threshold和負載因子loadFactor並沒有容量相關,但是在resize()方法中會根據舊容量和舊閾值判斷新容量是等於預設容量,舊閾值或者兩倍舊容量,最後根據新容量建立新陣列
  • loadFactor就是所謂的負載因子,預設為0.75,是控制擴容時機的關鍵屬性,因為擴容發生在當前元素個數超過閾值時,而閾值等於當前容量乘以負載因子.

  • modCount為修改計數,是fast-fail機制的關鍵引數.在對Map中的元素做新增/刪除操作時會自增,但修改不會(putVal()方法中覆蓋原值)

新增邏輯

  • HashMap的新增過程重點主要還是定位,如何確定元素在陣列中的位置,HashMap採用的就是Hash演算法
    1. 首先HashMap會根據Key的hash值,按照表示式(n - 1) & hash計算出桶的下標
    2. 如果此時桶為空,會建立一個新的Node,作為連結串列的第一個元素,直接存放在陣列中.(以前還聽說過什麼連結串列首節點為空的情況,是假的.)
    3. 如果節點存在又會區分樹節點(TreeNode)和普通節點(Node)兩種情況.
      • 普通節點會直接從首節點往下遍歷找到尾節點,並將帶插入節點新增到末尾
      • 樹節點會呼叫,TreeNode的方法插入到樹中.
  • 另外新增前會判斷底層陣列table是否初始化,新增後會判斷該桶大小是否超過的8,超過則轉化為紅黑樹,再判斷整個陣列是否需要擴容.
  • Hash同時也叫雜湊,可以把任意長度的輸入通過演算法,換算成固定長度的輸出,不同元素通過Hash演算法獲得的下標一致可以被稱之為衝突或者碰撞,Hash演算法的要求就是使元素儘量少的發生碰撞,從而均勻的散佈在陣列中.而發生碰撞時,像HashMap這種以一個列表下掛的方式可以被稱為拉鍊法.

查詢邏輯

  • 此處的查詢邏輯是指呼叫get()方法,通過key值查詢的情況,如果自己遍歷的另說.
    1. 同樣是根據表示式(n - 1) & hash計算出桶的下標(可以說是相當重要了),若得到的桶為空,直接返回null
    2. 不為空時則會遍歷整個桶,並根據key.equals(k)判斷是否相等
    3. 遍歷的方法也會根據節點型別的不同而不同,但是區分節點前直接存放在陣列中的頭結點是要先進行判斷的.感覺上效能影響不大吧
  • 從查詢的過程可以看出,確定桶下標的計算不存在隨機性,時間複雜度就為O(1),具體的效能體現在遍歷這一塊,連結串列查詢的時間複雜度為O(n),所以連結串列越長遍歷時間也就越長,插入和查詢的效率也就越低.所以在JDK1.8之後引入的紅黑樹作為桶的另一種實現方法.當連結串列長度大於8時,桶的實現會轉化為紅黑樹.
  • HashMap的效能很大一部分取決於Hash演算法..

RESIZE邏輯

  • 通過插入和查詢我們可以知道,在陣列大小不變的情況下,連結串列越長或者說樹的高度越高都會導致操作效能降低,所以此時很有必要通過擴容陣列的方式,重新排列桶中元素,降低連結串列長度,減少樹的高度.

  • 首先,觸發擴容的情況是size > threshold即元素個數大於閾值.整個擴容過程可以簡單的拆分為以下幾步:

    1. 對陣列進行擴充,一般情況下是陣列容量和閾值都變為原來的兩倍,此間會有上限判斷,容量最大為1 << 30也就是2^30.
    2. 遍歷舊陣列,重新判斷元素的位置並散佈到新陣列.
  • resize()方法中重新散佈元素的方法還是很有意思的(除去單元素連結串列和紅黑樹(桶的容量在1~7之間)

    • 首先將新陣列分為兩部分lohi(原始碼是loHead和hiHead,我猜是low和high,怎麼縮寫這麼隨意),lo表示0到舊容量大小部分,hi表示餘下算是新加入的部分,並以此建立兩個連結串列的節點
    • 根據表示式e.hash & oldCap判斷元素是否分佈在lo部分,是就掛到lo連結串列下面,否就掛到hi連結串列下面.
    • lo連結串列掛到和舊陣列相同位置的桶,而hi則掛到下標為原下標 + 舊陣列容量的桶.
    • 此處的依據就是e.hash & (oldCap - 1) + oldCap == e.hash & (oldCap << 1) -1
  • 可以看出resize()方法會調整全部的元素雜湊情況,因此過於頻繁的resize會降低HashMap的效能,因此如果一開始可以大概知道所需要存放的元素個數時,儘量直接指定容量大小.

  • JDK1.7之前的resize()方法在併發條件下可能會發生閉環問題,但在JDK1.8之後不會在出現,但並不代表HashMap可以在併發條件下使用了,小部分情況還是會出現資料丟失等問題.

  • 介紹JDK1.8之前的閉環問題詳情的文章

  • HashMap的懶載入問題

    • 檢視HashMap的原始碼,你會發現底層陣列table的建立其實並不是在建構函式中完成的,而是resize()方法中,這就是所謂的懶載入,陣列物件並非是在一開始就建立的,而是在第一次插入操作之前完成的。

關於HashMap一些問題

擾動函式

   static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
複製程式碼

擾動函式的邏輯很簡單就是hashCode的高16位和低16位異或.

擾動函式的作用就是增加雜湊的隨機性,使元素能夠更均勻的分佈在陣列中,減少衝突從而捎帶提高效能.

至於為什麼,可以看hash(*)用到的地方,hash(*)被用來計算元素的下標.而下標的計算公式如下

tab[i = (n - 1) & hash]   // n表示陣列的長度
複製程式碼

因為HashMap的容量一定會是2的次冪,所以減1之後轉化為二進位制會變為一串0加一串1的,例如長度為4時,減去1,就會變為000…00011(前面30個0),再結合&可以發現他只使用了hashCode的末尾幾位,高位是全部沒用.

而經過擾動函式,將高16位和低16位異或之後相當於高低位都用到了,其雜湊的隨機性也就增加了.

HashMap的容量為什麼一定要是2的次冪

  • 容量為2次冪有兩個優點
    1. 在下標運算的時候使用(length - 1) & hash)代替hash % length,相對來說位運算效能更佳,速度更快。
    2. 而在採用(length - 1) & hash的方式計算下標之後,如果不是二次冪的容量,出現碰撞的機率將會大大增加,例如我們取17作為容量((17 -1) => 0001000),經過&與運算,可以想象會有一大批的元素直接掛在0號桶。
  • 可以說這是一整套的策略,如果使用hash & length的話,也不用要求容量一定是二次冪,但各方面的效能總是會差一點的。

HashMap和HashTable的區別

  • HashTable都沒用過了,但以前還稍微看過
  1. 最大的區別就是HashTable是執行緒安全的,暴力的加方法級synchronized.而HashMap是執行緒不安全的,併發情況下可能會出現資料丟失等情況.
  2. HashTable不允許null值,而HashMap允許null值.(包括key和value)
  3. HashCode的使用不同,HashTable是直接呼叫hashCode,而HashMap會經過擾動函式.而且HashMap中用&代替了%
  4. HashTable陣列預設是11,且增長為2n+1,而HashMap預設為16,增長為2n,且硬性要求長度為2的次冪.
  5. HashTable並不是和HashMap一樣繼承自AbstractMap的,它繼承自一個獨立的父類AbstractDictionary
  6. 還有就是遍歷方法的不同.瞭解不深先不說話.

  • 最後附上完整的原始碼閱讀,蠻久之前寫的,不過被朋友吐槽說大段的程式碼混著註釋實在看不下去,所以寫了這篇總結性的

相關文章