Java 程式設計師都該懂的 HashMap

喝水會長肉發表於2021-12-12

HashMap 一直是非常常用的資料結構,也是面試中十分常問到的集合型別,今天就來說說 HashMap。

但是為什麼要專門說明是 Java8 的 HashMap 呢?我們都知道,Java8 有很多大的變化和改動,如函數語言程式設計等,而 HashMap 也有了一個比較大的變化。

先了解一下 Map

Java 程式設計師都該懂的 HashMap

常見的Map型別有以下幾種:

HashMap:
  • 無序
  • 訪問速度快
  • key不允許重複(只允許存在一個null key)
LinkedHashMap:
  • 有序
  • HashMap 子類
TreeMap:
  • TreeMap 中儲存的記錄會根據 Key 排序(預設為升序排序),因此使用 Iterator 遍歷時得到的記錄是排過序的
  • 因為需要排序,所以TreeMap 中的 key 必須實現 Comparable 介面,否則會報 ClassCastException 異常
  • TreeMap 會按照其 key 的 compareTo 方法來判斷 key 是否重複

除了上面幾種以外,我們還可能看到過一個叫 Hashtable 的類:

Hashtable:
  • 一個遺留類,執行緒安全,與 HashMap 類似
  • 當不需要執行緒安全時,選擇 HashMap 代替
  • 當需要執行緒安全時,使用 ConcurrentHashMap 代替

HashMap

我們現在來正式看一下 HashMap

首先先了解一下 HashMap 內部的一些主要特點:

  • 使用雜湊表(雜湊表)來進行資料儲存,並使用鏈地址法來解決衝突
  • 當連結串列長度大於等於 8 時,將連結串列轉換為紅黑樹來儲存
  • 每次進行二次冪的擴容,即擴容為原容量的兩倍

欄位

HashMap 有以下幾個欄位:

  • Node[] table:儲存資料的雜湊表;初始長度 length = 16(DEFAULT_INITIAL_CAPACITY),擴容時容量為原先的兩倍(n * 2)
  • final float loadFactor:負載因子,確定陣列長度與當前所能儲存的鍵值對最大值的關係;不建議輕易修改,除非情況特殊
  • int threshold:所能容納的 key-value 對極限 ;threshold = length * Load factor,當存在的鍵值對大於該值,則進行擴容
  • int modCount:HashMap 結構修改次數(例如每次 put 新值使則自增 1)
  • int size:當前 key-value 個數

值得一提的是,HashMap 中陣列的初始大小為 16,這是為什麼呢?這個我會在後面講 put 方法的時候說到。

方法

hash(Object key)

我們都知道,Object 類的 hashCode 方法與 HashMap 息息相關,因為 HashMap 便是通過 hashCode 來確定一個 key 在陣列中的儲存位置。(這裡大家都應該瞭解一下 hashCode 與 equals 方法之間的關係與約定,這裡就不多說了)

Java 8 之前的做法和現在的有所不同,Java 8 對此進行了改進,優化了該演算法

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

值得注意的是,HashMap 並非直接使用 hashCode 作為雜湊值,而是通過這裡的 hash 方法對 hashCode 進行一系列的移位和異或處理,這樣處理的目的是為了有效地避免雜湊碰撞

Java 程式設計師都該懂的 HashMap

我們可以看到,通過這樣的計算方式,key 的 hash 值高 16 位不變,低 16 位與高 16 位異或作為 key 的最終 hash 值;我們後面會知道,HashMap 通過 (n - 1) & hash 來決定元素的位置(其中 n 是當前陣列大小)

Java 程式設計師都該懂的 HashMap

很顯然,這種計算方式決定了元素的位置只關係到低位的數值,這樣會使得雜湊碰撞出現的可能性增加,因此我們利用 hash 值高位與低位的異或處理來降低衝突的可能性,使得元素的位置不單單取決於低位

put(K key, V value)

put 方法是 HashMap 裡面一個十分核心的方法,關係到了 HashMap 對資料的儲存問題。

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
複製程式碼

put 方法直接呼叫了 putVal 方法,這裡我為大家加上了註釋,可以配合下面的流程圖一步步感受:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    HashMap.Node<K, V>[] tab;
    HashMap.Node<K, V> p;
    int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        //初始化雜湊表
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)
        //通過雜湊值找到對應的位置,如果該位置還沒有元素存在,直接插入
        tab[i] = newNode(hash, key, value, null);
    else {
        HashMap.Node<K, V> e;
        K k;
        //如果該位置的元素的 key 與之相等,則直接到後面重新賦值
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof HashMap.TreeNode)
            //如果當前節點為樹節點,則將元素插入紅黑樹中
            e = ((HashMap.TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
        else {
            //否則一步步遍歷連結串列
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    //插入元素到鏈尾
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1)
                        //元素個數大於等於 8,改造為紅黑樹
                        treeifyBin(tab, hash);
                    break;
                }
                //如果該位置的元素的 key 與之相等,則重新賦值
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        //前面當雜湊表中存在當前key時對e進行了賦值,這裡統一對該key重新賦值更新
        if (e != null) { 
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    //檢查是否超出 threshold 限制,是則進行擴容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}
複製程式碼

主要的邏輯步驟在此:

Java 程式設計師都該懂的 HashMap

有個值得注意的有趣的地方:在 Java 8 之前,HashMap 插入資料時一直是插入到連結串列表頭;而到了 Java 8 之後,則改為了尾部插入。至於頭插入有什麼缺點,其中一個就是在併發的情況下因為插入而進行擴容時可能會出現連結串列環而發生死迴圈;當然,HashMap 設計出來本身就不是用於併發的情況的。

(1)HashMap 初始大小為何是 16

每當插入一個元素時,我們都需要計算該值在陣列中的位置,即p = tab[i = (n - 1) & hash]

當 n = 16 時,n - 1 = 15,二進位制為 1111,這時和 hash 作與運算時,元素的位置完全取決與 hash 的大小

倘若不是 16,如 n = 10,n - 1 = 9,二進位制為 1001,這時作與運算,很容易出現重複值,如 1101 & 1001,1011 & 1001,1111 & 1001,結果都是一樣的,所以選擇 16 以及 每次擴容都乘以二的原因也可想而知了

(2)懶載入

我們在 HashMap 的建構函式中可以發現,雜湊表 Node[] table 並沒有在一開始就完成初始化;觀察 put 方法可以發現:

if ((tab = table) == null || (n = tab.length) == 0)
      n = (tab = resize()).length;
複製程式碼

當發現雜湊表為空或者長度為 0 時,會使用 resize 方法進行初始化,這裡很顯然運用了 lazy-load 原則,當雜湊表被首次使用時,才進行初始化

(3)樹化

Java8 中,HashMap 最大的變動就是增加了樹化處理,當連結串列中元素大於等於 8,這時有可能將連結串列改造為紅黑樹的資料結構,為什麼我這裡說可能呢?

final void treeifyBin(HashMap.Node<K,V>[] tab, int hash) {
    int n, index; HashMap.Node<K,V> e;
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        //......
}
複製程式碼

我們可以觀察樹化處理的方法 treeifyBin,發現當tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY為 true 時,只會進行擴容處理,而沒有進行樹化;MIN_TREEIFY_CAPACITY 規定了 HashMap 可以樹化的最小表容量為 64,這是因為當一開始雜湊表容量較小是,雜湊碰撞的機率會比較大,而這個時候出現長連結串列的可能性會稍微大一些,這種原因下產生的長連結串列,我們應該優先選擇擴容而避免這類不必要的樹化。

那麼,HashMap 為什麼要進行樹化呢?我們都知道,連結串列的查詢效率大大低於陣列,而當過多的元素連成連結串列,會大大降低查詢存取的效能;同時,這也涉及到了一個安全問題,一些程式碼可以利用能夠造成雜湊衝突的資料對系統進行攻擊,這會導致服務端 CPU 被大量佔用。

resize()

擴容方法同樣是 HashMap 中十分核心的方法,同時也是比較耗效能的操作。

我們都知道陣列是無法自動擴容的,所以我們需要重新計算新的容量,建立新的陣列,並將所有元素拷貝到新陣列中,並釋放舊陣列的資料。

與以往不同的是,Java8 規定了 HashMap 每次擴容都為之前的兩倍(n*2),也正是因為如此,每個元素在陣列中的新的索引位置只可能是兩種情況,一種為不變,一種為原位置 + 擴容長度(即偏移值為擴容長度大小);反觀 Java8 之前,每次擴容需要重新計算每個值在陣列中的索引位置,增加了效能消耗

接下來簡單給大家說明一下,上一段話是什麼意思: 前面講 put 的時候我們知道每個元素在雜湊表陣列中的位置等於 (n - 1) & hash,其中 n 是當前陣列的大小,hash 則是前面講到的 hash 方法計算出來的雜湊值

Java 程式設計師都該懂的 HashMap

圖中我們可以看到,擴容前 0001 0101 和 0000 0101 兩個 hash 值最終的計算出來的陣列中的位置都是 0000 0101,即為 5,此時陣列大小為 0000 1111 + 1 即 16

擴容後,陣列從 16 擴容為兩倍即 32(0001 1111),此時原先兩個 hash 值計算出來的結果分別為 0001 0101 和 0000 0101 即 21 和 5,兩個數之間剛好相差 16,即陣列的擴容大小

這個其實很容易理解,陣列擴容為原來的兩倍後,n - 1 改變為 2n - 1,即在原先的二進位制的最高位發生了變化

Java 程式設計師都該懂的 HashMap

因此進行 & 運算後,出來的結果只可能是兩種情況,一種是毫無影響,一種為原位置 + 擴容長度

那麼原始碼中是如何判斷是這兩種情況的哪一種呢?我們前面說到,HashMap 中陣列的大小始終為 16 的倍數,因此 hash & n 和 hash & (2n - 1) 分別計算出來的值中高位是相等的

Java 程式設計師都該懂的 HashMap

因此原始碼中使用了一個非常簡單的方法(oldCap 是原陣列的大小,即 n)

if ((e.hash & oldCap) == 0) {
    ...
} else {
    ...
}
複製程式碼

當 e.hash & oldCap 等於 0 時,元素位置不變,當非 0 時,位置為原位置 + 擴容長度

get(Object key)

瞭解了 HashMap 的儲存機制後,get 方法也很好理解了

final HashMap.Node<K,V> getNode(int hash, Object key) {
    HashMap.Node<K,V>[] tab; HashMap.Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
        //檢查當前位置的第一個元素,如果正好是該元素,則直接返回
        if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            //否則檢查是否為樹節點,則呼叫 getTreeNode 方法獲取樹節點
            if (first instanceof HashMap.TreeNode)
                return ((HashMap.TreeNode<K,V>)first).getTreeNode(hash, key);
            //遍歷整個連結串列,尋找目標元素
            do {
                if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}
複製程式碼

主要就四步:

  1. 雜湊表是否為空或者目標位置是否存在元素
  2. 是否為第一個元素
  3. 如果是樹節點,尋找目標樹節點
  4. 如果是連結串列結點,遍歷連結串列尋找目標結點

相關文章