Java 程序員都該懂的 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 內部的一些主要特色:bash

  • 使用哈希表(散列表)來進行數據存儲,並使用鏈地址法來解決衝突
  • 當鏈表長度大於等於 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 進行一系列的移位和異或處理,這樣處理的目的是爲了有效地避免哈希碰撞

咱們能夠看到,經過這樣的計算方式,key 的 hash 值高 16 位不變,低 16 位與高 16 位異或做爲 key 的最終 hash 值;咱們後面會知道,HashMap 經過 (n - 1) & hash 來決定元素的位置(其中 n 是當前數組大小)

很顯然,這種計算方式決定了元素的位置只關係到低位的數值,這樣會使得哈希碰撞出現的可能性增長,所以咱們利用 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 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. 若是是鏈表結點,遍歷鏈表尋找目標結點
相關文章
相關標籤/搜索