Java 程序員都該懂的 Java8 HashMap

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

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

先了解一下 Map

常見的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 方法之間的關係與約定,這裏就不多說了)

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

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

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

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 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 方法計算出來的哈希值

圖中我們可以看到,擴容前 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,即在原先的二進制的最高位發生了變化

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

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

因此源碼中使用了一個非常簡單的方法(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. 如果是鏈表結點,遍歷鏈表尋找目標結點
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章