搞懂 Java HashMap 原始碼

像一隻狗發表於2018-04-07

HashMap 原始碼分析

前幾篇分析了 ArrayListLinkedListVectorStack List 集合的原始碼,Java 容器除了包含 List 集合外還包含著 Set 和 Map 兩個重要的集合型別。而 HashMap 則是最具有代表性的,也是我們最常使用到的 Map 集合。我們這篇文章就來試著分析下 HashMap 的原始碼,由於 HashMap 底層涉及到太多方面,一篇文章總是不能面面俱到,所以我們可以帶著面試官常問的幾個問題去看原始碼:

  1. 瞭解底層如何儲存資料的
  2. HashMap 的幾個主要方法
  3. HashMap 是如何確定元素儲存位置的以及如何處理雜湊衝突的
  4. HashMap 擴容機制是怎樣的
  5. JDK 1.8 在擴容和解決雜湊衝突上對 HashMap 原始碼做了哪些改動?有什麼好處?

本文也將從以上幾個方面來展開敘述:

由於掘金後臺稽核可能會由於某些原因造成文章釋出延遲或者遺漏,如果感覺我寫的原始碼分析文章還不錯,可以關注我,以後我每次更新文章就可以收到推送了。另外博主也是在努力進步中,所有文章如果有問題請儘管留言給我。我會及時改正。大家一起進步。

概述

為了方便下邊的敘述這裡需要先對幾個常見的關於 HashMap 的知識點進行下概述:

  1. HashMap 儲存資料是根據鍵值對儲存資料的,並且儲存多個資料時,資料的鍵不能相同,如果相同該鍵之前對應的值將被覆蓋。注意如果想要保證 HashMap 能夠正確的儲存資料,請確保作為鍵的類,已經正確覆寫了 equals() 方法。

  2. HashMap 儲存資料的位置與新增資料的鍵的 hashCode() 返回值有關。所以在將元素使用 HashMap 儲存的時候請確保你已經按照要求重寫了 hashCode()方法。這裡說有關係代表最終的儲存位置不一定就是 hashCode 的返回值。

  3. HashMap 最多隻允許一條儲存資料的鍵為 null,可允許多條資料的值為 null。

  4. HashMap 儲存資料的順序是不確定的,並且可能會因為擴容導致元素儲存位置改變。因此遍歷順序是不確定的。

  5. HashMap 是執行緒不安全的,如果需要再多執行緒的情況下使用可以用 Collections.synchronizedMap(Map map) 方法使 HashMap 具有執行緒安全的能力,或者使用 ConcurrentHashMap

瞭解 HashMap 底層如何儲存資料的

要想分析 HashMap 原始碼,就必須在 JDK1.8 和 JDK1.7之間劃分一條線,因為在 JDK 1.8 後對於 HashMap 做了底層實現的改動。

JDK 1.7 之前的儲存結構

通過上篇文章搞懂 Java equals 和 hashCode 方法 我們以及對 hash 表有所瞭解,我們瞭解到及時 hashCode() 方法已經寫得很完美了,終究還是有可能導致 「hash碰撞」的,HashMap 作為使用 hash 值來決定元素儲存位置的集合也是需要處理 hash 衝突的。在1.7之前JDK採用「拉鍊法」來儲存資料,即陣列和連結串列結合的方式:

搞懂  Java HashMap 原始碼

「拉鍊法」用專業點的名詞來說叫做鏈地址法。簡單來說,就是陣列加連結串列的結合。在每個陣列元素上儲存的都是一個連結串列。

我們之前說到不同的 key 可能經過 hash 運算可能會得到相同的地址,但是一個陣列單位上只能存放一個元素,採用鏈地址法以後,如果遇到相同的 hash 值的 key 的時候,我們可以將它放到作為陣列元素的連結串列上。待我們去取元素的時候通過 hash 運算的結果找到這個連結串列,再在連結串列中找到與 key 相同的節點,就能找到 key 相應的值了。

JDK1.7中新新增進來的元素總是放在陣列相應的角標位置,而原來處於該角標的位置的節點作為 next 節點放到新節點的後邊。稍後通過原始碼分析我們也能看到這一點。

JDK1.8中的儲存結構。

對於 JDK1.8 之後的HashMap底層在解決雜湊衝突的時候,就不單單是使用陣列加上單連結串列的組合了,因為當處理如果 hash 值衝突較多的情況下,連結串列的長度就會越來越長,此時通過單連結串列來尋找對應 Key 對應的 Value 的時候就會使得時間複雜度達到 O(n),因此在 JDK1.8 之後,在連結串列新增節點導致連結串列長度超過 TREEIFY_THRESHOLD = 8 的時候,就會在新增元素的同時將原來的單鏈錶轉化為紅黑樹。

對資料結構很在行的讀者應該,知道紅黑樹是一種易於增刪改查的二叉樹,他對與資料的查詢的時間複雜度是 O(logn) 級別,所以利用紅黑樹的特點就可以更高效的對 HashMap 中的元素進行操作。

搞懂  Java HashMap 原始碼

JDK1.8 對於HashMap 底層儲存結構優化在於:當連結串列新增節點導致連結串列長度超過8的時候,就會將原有的連結串列轉為紅黑樹來儲存資料。

關於 HashMap 原始碼中提到的幾個重要概念

關於 HashMap 原始碼中分析的文章一般都會提及幾個重要的概念:

重要引數

  1. 雜湊桶(buckets):在 HashMap 的註釋裡使用雜湊桶來形象的表示陣列中每個地址位置。注意這裡並不是陣列本身,陣列是裝雜湊桶的,他可以被稱為雜湊表

  2. 初始容量(initial capacity) : 這個很容易理解,就是雜湊表中雜湊桶初始的數量。如果我們沒有通過構造方法修改這個容量值預設為DEFAULT_INITIAL_CAPACITY = 1<<4 即16。值得注意的是為了保證 HashMap 新增和查詢的高效性,HashMap 的容量總是 2^n 的形式。

  3. 載入因子(load factor):載入因子是雜湊表(雜湊表)在其容量自動增加之前被允許獲得的最大數量的度量。當雜湊表中的條目數量超過負載因子和當前容量的乘積時,雜湊表就會被重新對映(即重建內部資料結構),重新建立的雜湊表容量大約是之前雜湊表哈系統桶數量的兩倍。預設載入因子(0.75)在時間和空間成本之間提供了良好的折衷。載入因子過大會導致很容易連結串列過長,載入因子很小又容易導致頻繁的擴容。所以不要輕易試著去改變這個預設值

  4. 擴容閾值(threshold):其實在說載入因子的時候已經提到了擴容閾值了,擴容閾值 = 雜湊表容量 * 載入因子。雜湊表的鍵值對總數 = 所有雜湊桶中所有連結串列節點數的加和,擴容閾值比較的是是鍵值對的個數而不是雜湊表的陣列中有多少個位置被佔了。

  5. 樹化閥值(TREEIFY_THRESHOLD) :這個引數概念是在 JDK1.8後加入的,它的含義代表一個雜湊桶中的節點個數大於該值(預設為8)的時候將會被轉為紅黑樹行儲存結構。

  6. 非樹化閥值(UNTREEIFY_THRESHOLD): 與樹化閾值相對應,表示當一個已經轉化為數形儲存結構的雜湊桶中節點數量小於該值(預設為 6)的時候將再次改為單連結串列的格式儲存。導致這種操作的原因可能有刪除節點或者擴容。

  7. 最小樹化容量(MIN_TREEIFY_CAPACITY): 經過上邊的介紹我們只知道,當連結串列的節點數超過8的時候就會轉化為樹化儲存,其實對於轉化還有一個要求就是雜湊表的數量超過最小樹化容量的要求(預設要求是 64),且為了避免進行擴容、樹形化選擇的衝突,這個值不能小於 4 * TREEIFY_THRESHOLD);在達到該有求之前優先選擇擴容。擴容因為因為容量的變化可能會使單連結串列的長度改變。

與這個幾個概念對應的在 HashMap 中幾個常亮量,由於上邊的介紹比較詳細了,下邊僅列出幾個變數的宣告:

/*預設初始容量*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

/*最大儲存容量*/
static final int MAXIMUM_CAPACITY = 1 << 30;

/*預設載入因子*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;

/*預設樹化閾值*/
static final int TREEIFY_THRESHOLD = 8;

/*預設非樹化閾值*/
static final int UNTREEIFY_THRESHOLD = 6;

/*預設最小樹化容量*/
static final int MIN_TREEIFY_CAPACITY = 64;
複製程式碼

對應的還有幾個全域性變數:

// 擴容閾值 = 容量 x 載入因子
int threshold;

//儲存雜湊桶的陣列,雜湊桶中裝的是一個單連結串列或一顆紅黑樹,長度一定是 2^n
transient Node<K,V>[] table;  
  
// HashMap中儲存的鍵值對的數量注意這裡是鍵值對的個數而不是陣列的長度
transient int size;
  
//所有鍵值對的Set集合 區分於 table 可以呼叫 entrySet()得到該集合
transient Set<Map.Entry<K,V>> entrySet;
  
//運算元記錄 為了多執行緒操作時 Fast-fail 機制
transient int modCount;

複製程式碼

基本儲存單元

HashMap 在 JDK 1.7 中只有 Entry 一種儲存單元,而在 JDK1.8 中由於有了紅黑樹的存在,就多了一種儲存單元,而 Entry 也隨之應景的改為名為 Node。我們先來看下單連結串列節點的表示方法 :

/**
 * 內部類 Node 實現基類的內部介面 Map.Entry<K,V>
 * 
 */
static class Node<K,V> implements Map.Entry<K,V> {
   //此值是在陣列索引位置
   final int hash;
   //節點的鍵
   final K key;
   //節點的值
   V value;
   //單連結串列中下一個節點
   Node<K,V> next;
    
   Node(int hash, K key, V value, Node<K,V> next) {
       this.hash = hash;
       this.key = key;
       this.value = value;
       this.next = next;
   }

   public final K getKey()        { return key; }
   public final V getValue()      { return value; }
   public final String toString() { return key + "=" + value; }
    //節點的 hashCode 值通過 key 的雜湊值和 value 的雜湊值異或得到,沒發現在原始碼中中有用到。
   public final int hashCode() {
       return Objects.hashCode(key) ^ Objects.hashCode(value);
   }

   //更新相同 key 對應的 Value 值
   public final V setValue(V newValue) {
       V oldValue = value;
       value = newValue;
       return oldValue;
   }
 //equals 方法,鍵值同時相同才節點才相同
   public final boolean equals(Object o) {
       if (o == this)
           return true;
       if (o instanceof Map.Entry) {
           Map.Entry<?,?> e = (Map.Entry<?,?>)o;
           if (Objects.equals(key, e.getKey()) &&
               Objects.equals(value, e.getValue()))
               return true;
       }
       return false;
   }
}
複製程式碼

對於JDK1.8 新增的紅黑樹節點,這裡不做展開敘述,有興趣的朋友可以檢視 HashMap 在 JDK 1.8 後新增的紅黑樹結構這篇文章來了解一下 JDK1.8對於紅黑樹的操作。

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
   TreeNode<K,V> parent;  // red-black tree links
   TreeNode<K,V> left;
   TreeNode<K,V> right;
   TreeNode<K,V> prev;    // needed to unlink next upon deletion
   boolean red;
   TreeNode(int hash, K key, V val, Node<K,V> next) {
       super(hash, key, val, next);
   }
   ·········
}
複製程式碼

HashMap 構造方法

HashMap 構造方法一共有三個:

  • 可以指定期望初始容量和載入因子的建構函式,有了這兩個值,我們就可以算出上邊說到的 threshold 載入因子。其中載入因子不可以小於0,並沒有規定不可以大於 1,但是不能等於無窮.

大家可能疑惑 Float.isNaN() 其實 NaN 就是 not a number 的縮寫,我們知道在運算 1/0 的時候回丟擲異常,但是如果我們的除數指定為浮點數 1/0.0f 的時候就不會丟擲異常了。計算器運算出的結果可以當做一個極值也就是無窮大,無窮大不是個數所以 1/0.0f 返回結果是 Infinity 無窮,使用 Float.isNaN()判斷將會返回 true。

 public HashMap(int initialCapacity, float loadFactor) {
    // 指定期望初始容量小於0將會丟擲非法引數異常
   if (initialCapacity < 0)
       throw new IllegalArgumentException("Illegal initial capacity: " +
                                          initialCapacity);
   // 期望初始容量不可以大於最大值 2^30  實際上我們也不會用到這麼大的容量                                      
   if (initialCapacity > MAXIMUM_CAPACITY)
       initialCapacity = MAXIMUM_CAPACITY;
  // 載入因子必須大於0 不能為無窮大   
   if (loadFactor <= 0 || Float.isNaN(loadFactor))
       throw new IllegalArgumentException("Illegal load factor: " +
                                          loadFactor);
   this.loadFactor = loadFactor;//初始化全域性載入因子變數
   this.threshold = tableSizeFor(initialCapacity);//根據初始容量計算計算擴容閾值
}
複製程式碼

咦?不是說好擴容閾值 = 雜湊表容量 * 載入因子麼?為什麼還要用到下邊這個方法呢?我們之前說了引數 initialCapacity 只是期望容量,不知道大家發現沒我們這個建構函式並沒有初始化 Node<K,V>[] table ,事實上真正指定雜湊表容量總是在第一次新增元素的時候,這點和 ArrayList 的機制有所不同。等我們說到擴容機制的時候我們就可以看到相關程式碼了。

//根據期望容量返回一個 >= cap 的擴容閾值,並且這個閾值一定是 2^n 
static final int tableSizeFor(int cap) {
   int n = cap - 1;
   n |= n >>> 1;
   n |= n >>> 2;
   n |= n >>> 4;
   n |= n >>> 8;
   n |= n >>> 16;
   //經過上述面的 或和位移 運算, n 最終各位都是1 
   //最終結果 +1 也就保證了返回的肯定是 2^n 
   return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
複製程式碼
  • 只指定初始容量的建構函式

這個就比較簡單了,將指定的期望初容量和預設載入因子傳遞給兩個引數構造方法。這裡就不在贅述。

public HashMap(int initialCapacity) {
   this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
複製程式碼
  • 無引數建構函式

這也是我們最常用的一個建構函式,該方法初始化了載入因子為預設值,並沒有調動其他的構造方法,跟我們之前說的一樣,雜湊表的大小以及其他引數都會在第一呼叫擴容函式的初始化為預設值。

public HashMap() {
   this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
複製程式碼
  • 傳入一個 Map 集合的構造引數

該方法解釋起來就比較麻煩了,因為他在初始化的時候就涉及了新增元素,擴容這兩大重要的方法。這裡先把它掛起來,緊接著我們講完了擴容機制再回來看就好了。

public HashMap(Map<? extends K, ? extends V> m) {
   this.loadFactor = DEFAULT_LOAD_FACTOR;
   putMapEntries(m, false);
}
複製程式碼

HashMap 如何確定新增元素的位置

在分析 HashMap 新增元素的方法之前,我們需要先來了解下,如何確定元素在 HashMap 中的位置的。我們知道 HashMap 底層是雜湊表,雜湊表依靠 hash 值去確定元素儲存位置。HashMap 在 JDK 1.7 和 JDK1.8中採用的 hash 方法並不是完全相同。我們現在看下

JDK 1.7 中 hash 方法的實現:

這裡提出一個概念擾動函式,我們知道Map 文中存放鍵值對的位置有鍵的 hash 值決定,但是鍵的 hashCode 函式返回值不一定滿足,雜湊表長度的要求,所以在儲存元素之前需要對 key 的 hash 值進行一步擾動處理。下面我們JDK1.7 中的擾動函式:

//4次位運算 + 5次異或運算 
//這種演算法可以防止低位不變,高位變化時,造成的 hash 衝突
static final int hash(Object k) {
   int h = 0;
   h ^= k.hashCode(); 
   h ^= (h >>> 20) ^ (h >>> 12);
   return h ^ (h >>> 7) ^ (h >>> 4);
}
複製程式碼

JDK1.8 中 hash 函式的實現

JDK1.8中再次優化了這個雜湊函式,把 key 的 hashCode 方法返回值右移16位,即丟棄低16位,高16位全為0 ,然後在於 hashCode 返回值做異或運算,即高 16 位與低 16 位進行異或運算,這麼做可以在陣列 table 的 length 比較小的時候,也能保證考慮到高低Bit都參與到 hash 的計算中,同時不會有太大的開銷,擾動處理次數也從 4次位運算 + 5次異或運算 降低到 1次位運算 + 1次異或運算

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

進過上述的擾動函式只是得到了合適的 hash 值,但是還沒有確定在 Node[] 陣列中的角標,在 JDK1.7中存在一個函式,JDK1.8中雖然沒有但是隻是把這步運算放到了 put 函式中。我們就看下這個函式實現:

static int indexFor(int h, int length) {
     return h & (length-1);  // 取模運算
}
複製程式碼

為了讓 hash 值能夠對應到現有陣列中的位置,我們上篇文章講到一個方法為 取模運算,即 hash % length,得到結果作為角標位置。但是 HashMap 就厲害了,連這一步取模運算的都優化了。我們需要知道一個計算機對於2進位制的運算是要快於10進位制的,取模算是10進位制的運算了,而位與運算就要更高效一些了。

我們知道 HashMap 底層陣列的長度總是 2^n ,轉為二進位制總是 1000 即1後邊多個0的情況。此時一個數與 2^n 取模,等價於 一個數與 2^n - 1做位與運算。而 JDK 中就使用h & (length-1) 運算替代了對 length取模。我們根據圖片來看一個具體的例子:

搞懂  Java HashMap 原始碼

圖片來自:https://tech.meituan.com/java-hashmap.html 侵刪。

小結

通過上邊的分析我們可以到如下結論:

  • 在儲存元素之前,HashMap 會對 key 的 hashCode 返回值做進一步擾動函式處理,1.7 中擾動函式使用了 4次位運算 + 5次異或運算,1.8 中降低到 1次位運算 + 1次異或運算
  • 擾動處理後的 hash 與 雜湊表陣列length -1 做位與運算得到最終元素儲存的雜湊桶角標位置。

HashMap 的新增元素

敲黑板了,重點來了。對於理解 HashMap 原始碼一方面要了解儲存的資料結構,另一方面也要了解具體是如何新增元素的。下面我們就來看下 put(K key, V value) 函式。

// 可以看到具體的新增行為在 putVal 方法中進行
public V put(K key, V value) {
   return putVal(hash(key), key, value, false, true);
}
複製程式碼

對於 putVal 前三個引數很好理解,第4個引數 onlyIfAbsent 表示只有當對應 key 的位置為空的時候替換元素,一般傳 false,在 JDK1.8中新增方法 public V putIfAbsent(K key, V value) 傳 true,第 5 個引數 evict 如果是 false。那麼表示是在初始化時呼叫的:

 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
              boolean evict) {
              
   Node<K,V>[] tab; Node<K,V> p; int n, i;
   //如果是第一新增元素 table = null 則需要擴容
   if ((tab = table) == null || (n = tab.length) == 0)
       n = (tab = resize()).length;// n 表示擴容後陣列的長度
   //  i = (n - 1) & hash 即上邊講得元素儲存在 map 中的陣列角標計算
   // 如果對應陣列沒有元素沒發生 hash 碰撞 則直接賦值給陣列中 index 位置   
   if ((p = tab[i = (n - 1) & hash]) == null)
       tab[i] = newNode(hash, key, value, null);
   else {// 發生 hash 碰撞了
       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 TreeNode)//如果新增當前節點已經為紅黑樹,則需要轉為紅黑樹中的節點
           e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
       else {// hash 值計算出的陣列索引相同,但 key 並不同的時候,        // 迴圈整個單連結串列
           for (int binCount = 0; ; ++binCount) {
               if ((e = p.next) == null) {//遍歷到尾部
                    // 建立新的節點,拼接到連結串列尾部
                   p.next = newNode(hash, key, value, null);             // 如果新增後 bitCount 大於等於樹化閾值後進行雜湊桶樹化操作
                   if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                       treeifyBin(tab, hash);
                   break;
               }
               //如果遍歷過程中找到連結串列中有個節點的 key 與 當前要插入元素的 key 相同,此時 e 所指的節點為需要替換 Value 的節點,並結束迴圈
               if (e.hash == hash &&
                   ((k = e.key) == key || (key != null && key.equals(k))))
                   break;
               //移動指標    
               p = e;
           }
       }
       //如果迴圈完後 e!=null 代表需要替換e所指節點 Value
       if (e != null) { // existing mapping for key
           V oldValue = e.value//儲存原來的 Value 作為返回值
           // onlyIfAbsent 一般為 false 所以替換原來的 Value
           if (!onlyIfAbsent || oldValue == null)
               e.value = value;
            //這個方法在 HashMap 中是空實現,在 LinkedHashMap 中有關係   
           afterNodeAccess(e);
           return oldValue;
       }
   }
   //運算元增加
   ++modCount;
   //如果 size 大於擴容閾值則表示需要擴容
   if (++size > threshold)
       resize();
   afterNodeInsertion(evict);
   return null;
}
複製程式碼

由於新增元素中設計邏輯有點複雜,這裡引用一張圖來說明,理解

圖片來來自:https://tech.meituan.com/java-hashmap.html

搞懂  Java HashMap 原始碼

新增元素過程:

  1. 如果 Node[] table 表為 null ,則表示是第一次新增元素,講建構函式也提到了,及時建構函式指定了期望初始容量,在第一次新增元素的時候也為空。這時候需要進行首次擴容過程。
  2. 計算對應的鍵值對在 table 表中的索引位置,通過i = (n - 1) & hash 獲得。
  3. 判斷索引位置是否有元素如果沒有元素則直接插入到陣列中。如果有元素且key 相同,則覆蓋 value 值,這裡判斷是用的 equals 這就表示要正確的儲存元素,就必須按照業務要求覆寫 key 的 equals 方法,上篇文章我們也提及到了該方法重要性。
  4. 如果索引位置的 key 不相同,則需要遍歷單連結串列,如果遍歷過如果有與 key 相同的節點,則儲存索引,替換 Value;如果沒有相同節點,則在但單連結串列尾部插入新節點。這裡操作與1.7不同,1.7新來的節點總是在陣列索引位置,而之前的元素作為下個節點拼接到新節點尾部。
  5. 如果插入節點後連結串列的長度大於樹化閾值,則需要將單鏈錶轉為紅黑樹。
  6. 成功插入節點後,判斷鍵值對個數是否大於擴容閾值,如果大於了則需要再次擴容。至此整個插入元素過程結束。

HashMap 的擴容過程

在上邊說明 HashMap 的 putVal 方法時候,多次提到了擴容函式,擴容函式也是我們理解 HashMap 原始碼的重中之重。所以再次敲黑板~

final Node<K,V>[] resize() {
   // oldTab 指向舊的 table 表
   Node<K,V>[] oldTab = table;
   // oldCap 代表擴容前 table 表的陣列長度,oldTab 第一次新增元素的時候為 null 
   int oldCap = (oldTab == null) ? 0 : oldTab.length;
   // 舊的擴容閾值
   int oldThr = threshold;
   // 初始化新的閾值和容量
   int newCap, newThr = 0;
   // 如果 oldCap > 0 則會將新容量擴大到原來的2倍,擴容閾值也將擴大到原來閾值的兩倍
   if (oldCap > 0) {
       // 如果舊的容量已經達到最大容量 2^30 那麼就不在繼續擴容直接返回,將擴容閾值設定到 Integer.MAX_VALUE,並不代表不能裝新元素,只是陣列長度將不會變化
       if (oldCap >= MAXIMUM_CAPACITY) {
           threshold = Integer.MAX_VALUE;
           return oldTab;
       }//新容量擴大到原來的2倍,擴容閾值也將擴大到原來閾值的兩倍
       else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                oldCap >= DEFAULT_INITIAL_CAPACITY)
           newThr = oldThr << 1; // double threshold
   }
   //oldThr 不為空,代表我們使用帶引數的構造方法指定了載入因子並計算了
   //初始初始閾值 會將擴容閾值 賦值給初始容量這裡不再是期望容量,
   //但是 >= 指定的期望容量
   else if (oldThr > 0) // initial capacity was placed in threshold
       newCap = oldThr;
   else {
        // 空引數構造會走這裡初始化容量,和擴容閾值 分別是 16 和 12
       newCap = DEFAULT_INITIAL_CAPACITY;
       newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
   }
   //如果新的擴容閾值是0,對應的是當前 table 為空,但是有閾值的情況
   if (newThr == 0) {
        //計算新的擴容閾值
       float ft = (float)newCap * loadFactor;
       // 如果新的容量不大於 2^30 且 ft 不大於 2^30 的時候賦值給 newThr 
       //否則 使用 Integer.MAX_VALUE
       newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                 (int)ft : Integer.MAX_VALUE);
   }
   //更新全域性擴容閾值
   threshold = newThr;
   @SuppressWarnings({"rawtypes","unchecked"})
    //使用新的容量建立新的雜湊表的陣列
   Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
   table = newTab;
   //如果老的陣列不為空將進行重新插入操作否則直接返回
   if (oldTab != null) {
        //遍歷老陣列中每個位置的連結串列或者紅黑樹重新計算節點位置,插入新陣列
       for (int j = 0; j < oldCap; ++j) {
           Node<K,V> e;//用來儲存對應陣列位置連結串列頭節點
           //如果當前陣列位置存在元素
           if ((e = oldTab[j]) != null) {
                // 釋放原來陣列中的對應的空間
               oldTab[j] = null;
               // 如果連結串列只有一個節點,
               //則使用新的陣列長度計算節點位於新陣列中的角標並插入
               if (e.next == null)
                   newTab[e.hash & (newCap - 1)] = e;
               else if (e instanceof TreeNode)//如果當前節點為紅黑樹則需要進一步確定樹中節點位於新陣列中的位置。
                   ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
               else { // preserve order
                   //因為擴容是容量翻倍,
                   //原連結串列上的每個節點 現在可能存放在原來的下標,即low位,
                   //或者擴容後的下標,即high位
              //低位連結串列的頭結點、尾節點
              Node<K,V> loHead = null, loTail = null;
              //高位連結串列的頭節點、尾節點
              Node<K,V> hiHead = null, hiTail = null;
              Node<K,V> next;//用來存放原連結串列中的節點
              do {
                  next = e.next;
                  // 利用雜湊值 & 舊的容量,可以得到雜湊值去模後,
                  //是大於等於 oldCap 還是小於 oldCap,
                  //等於 0 代表小於 oldCap,應該存放在低位,
                  //否則存放在高位(稍後有圖片說明)
                  if ((e.hash & oldCap) == 0) {
                      //給頭尾節點指標賦值
                      if (loTail == null)
                          loHead = e;
                      else
                          loTail.next = e;
                      loTail = e;
                  }//高位也是相同的邏輯
                  else {
                      if (hiTail == null)
                          hiHead = e;
                      else
                          hiTail.next = e;
                      hiTail = e;
                  }//迴圈直到連結串列結束
              } while ((e = next) != null);
              //將低位連結串列存放在原index處,
              if (loTail != null) {
                  loTail.next = null;
                  newTab[j] = loHead;
              }
              //將高位連結串列存放在新index處
              if (hiTail != null) {
                  hiTail.next = null;
                  newTab[j + oldCap] = hiHead;
              }
           }
       }
   }
   return newTab;
}
複製程式碼

相信大家看到擴容的整個函式後對擴容機制應該有所瞭解了,整體分為兩部分:1. 尋找擴容後陣列的大小以及新的擴容閾值,2. 將原有雜湊表拷貝到新的雜湊表中

第一部分沒的說,但是第二部分我看的有點懵逼了,但是踩在巨人的肩膀上總是比較容易的,美團的大佬們早就寫過一些有關 HashMap 的原始碼分析文章,給了我很大的幫助。在文章的最後我會放出參考連結。下面說下我的理解:

JDK 1.8 不像 JDK1.7中會重新計算每個節點在新雜湊表中的位置,而是通過 (e.hash & oldCap) == 0是否等於0 就可以得出原來連結串列中的節點在新雜湊表的位置。為什麼可以這樣高效的得出新位置呢?

因為擴容是容量翻倍,所以原連結串列上的每個節點,可能存放新雜湊表中在原來的下標位置, 或者擴容後的原位置偏移量為 oldCap 的位置上,下邊舉個例子 圖片和敘述來自 https://tech.meituan.com/java-hashmap.html:

圖(a)表示擴容前的key1和key2兩種key確定索引位置的示例,圖(b)表示擴容後key1和key2兩種key確定索引位置的示例,其中hash1是key1對應的雜湊與高位運算結果。

搞懂  Java HashMap 原始碼

元素在重新計算hash之後,因為n變為2倍,那麼n-1的mask範圍在高位多1bit(紅色),因此新的index就會發生這樣的變化:

搞懂  Java HashMap 原始碼

所以在 JDK1.8 中擴容後,只需要看看原來的hash值新增的那個bit是1還是0就好了,是0的話索引沒變,是1的話索引變成“原索引+oldCap

另外還需要注意的一點是 HashMap 在 1.7的時候擴容後,連結串列的節點順序會倒置,1.8則不會出現這種情況。

HashMap 其他新增元素的方法

上邊將建構函式的時候埋了個坑即使用:

public HashMap(Map<? extends K, ? extends V> m) {
   this.loadFactor = DEFAULT_LOAD_FACTOR;
   putMapEntries(m, false);
}
複製程式碼

建構函式構建 HashMap 的時候,在這個方法裡,除了賦值了預設的載入因子,並沒有呼叫其他構造方法,而是通過批量新增元素的方法 putMapEntries 來構造了 HashMap。該方法為私有方法,真正批量新增的方法為putAll

public void putAll(Map<? extends K, ? extends V> m) {
   putMapEntries(m, true);
}
複製程式碼
//同樣第二引數代表是否初次建立 table 
 final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
   int s = m.size();
   if (s > 0) {
        //如果雜湊表為空則初始化引數擴容閾值
       if (table == null) { // pre-size
           float ft = ((float)s / loadFactor) + 1.0F;
           int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                    (int)ft : MAXIMUM_CAPACITY);
           if (t > threshold)
               threshold = tableSizeFor(t);
       }
       else if (s > threshold)//構造方法沒有計算 threshold 預設為0 所以會走擴容函式
           resize();
        //將引數中的 map 鍵值對一次新增到 HashMap 中
       for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
           K key = e.getKey();
           V value = e.getValue();
           putVal(hash(key), key, value, false, evict);
       }
   }
}
複製程式碼

JDK1.8 中還新增了一個新增方法,該方法呼叫 putVal 且第4個引數傳了 true,代表只有雜湊表中對應的key 的位置上元素為空的時候新增成功,否則返回原來 key 對應的 Value 值。

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

HashMap 查詢元素

分析了完了 put 函式後,接下來讓我們看下 get 函式,當然有 put 函式計算鍵值對在雜湊表中位置的索引方法分析的鋪墊後,get 方法就顯得很容容易了。

  1. 根據鍵值對的 key 去獲取對應的 Value
public V get(Object key) {
   Node<K,V> e;
   //通過 getNode尋找 key 對應的 Value 如果沒找到,或者找到的結果為 null 就會返回null 否則會返回對應的 Value
   return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
   Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
   //現根據 key 的 hash 值去找到對應的連結串列或者紅黑樹
   if ((tab = table) != null && (n = tab.length) > 0 &&
       (first = tab[(n - 1) & hash]) != null) {
       // 如果第一個節點就是那麼直接返回
       if (first.hash == hash && // always check first node
           ((k = first.key) == key || (key != null && key.equals(k))))
           return first;
        //如果 對應的位置為紅黑樹呼叫紅黑樹的方法去尋找節點   
       if ((e = first.next) != null) {
           if (first instanceof TreeNode)
               return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            //遍歷單連結串列找到對應的 key 和 Value   
           do {
               if (e.hash == hash &&
                   ((k = e.key) == key || (key != null && key.equals(k))))
                   return e;
           } while ((e = e.next) != null);
       }
   }
   return null;
}
複製程式碼
  1. JDK 1.8新增 get 方法,在尋找 key 對應 Value 的時候如果沒找大則返回指定預設值
@Override
public V getOrDefault(Object key, V defaultValue) {
   Node<K,V> e;
   return (e = getNode(hash(key), key)) == null ? defaultValue : e.value;
}
複製程式碼

HashMap 的刪操作

HashMap 沒有 set 方法,如果想要修改對應 key 對映的 Value ,只需要再次呼叫 put 方法就可以了。我們來看下如何移除 HashMap 中對應的節點的方法:

 public V remove(Object key) {
   Node<K,V> e;
   return (e = removeNode(hash(key), key, null, false, true)) == null ?
       null : e.value;
}
複製程式碼
@Override
public boolean remove(Object key, Object value) {
   //這裡傳入了value 同時matchValue為true
   return removeNode(hash(key), key, value, true, true) != null;
}
複製程式碼

這裡有兩個引數需要我們提起注意:

  • matchValue 如果這個值為 true 則表示只有當 Value 與第三個引數 Value 相同的時候才刪除對一個的節點
  • movable 這個引數在紅黑樹中先刪除節點時候使用 true 表示刪除並其他數中的節點。
 final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
   Node<K,V>[] tab; Node<K,V> p; int n, index;
   //判斷雜湊表是否為空,長度是否大於0 對應的位置上是否有元素
   if ((tab = table) != null && (n = tab.length) > 0 &&
       (p = tab[index = (n - 1) & hash]) != null) {
       
       // node 用來存放要移除的節點, e 表示下個節點 k ,v 每個節點的鍵值
       Node<K,V> node = null, e; K k; V v;
       //如果第一個節點就是我們要找的直接賦值給 node
       if (p.hash == hash &&
           ((k = p.key) == key || (key != null && key.equals(k))))
           node = p;
       else if ((e = p.next) != null) {
            // 遍歷紅黑樹找到對應的節點
           if (p instanceof TreeNode)
               node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
           else {
                //遍歷對應的連結串列找到對應的節點
               do {
                   if (e.hash == hash &&
                       ((k = e.key) == key ||
                        (key != null && key.equals(k)))) {
                       node = e;
                       break;
                   }
                   p = e;
               } while ((e = e.next) != null);
           }
       }
       // 如果找到了節點
       // !matchValue 是否不刪除節點
       // (v = node.value) == value ||
                            (value != null && value.equals(v))) 節點值是否相同,
       if (node != null && (!matchValue || (v = node.value) == value ||
                            (value != null && value.equals(v)))) {
           //刪除節點                 
           if (node instanceof TreeNode)
               ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
           else if (node == p)
               tab[index] = node.next;
           else
               p.next = node.next;
           ++modCount;
           --size;
           afterNodeRemoval(node);
           return node;
       }
   }
   return null;
}
複製程式碼

HashMap 的迭代器

我們都只我們知道 Map 和 Set 有多重迭代方式,對於 Map 遍歷方式這裡不展開說了,因為我們要分析迭代器的原始碼所以這裡就給出一個使用迭代器遍歷的方法:

public void test(){

    Map<String, Integer> map = new HashMap<>();
    
    ...
    
    Set<Map.Entry<String, Integer>> entrySet = map.entrySet();
    
    //通過迭代器:先獲得 key-value 對(Entry)的Iterator,再迴圈遍歷   
    Iterator iter1 = entrySet.iterator();
    while (iter1.hasNext()) {
    // 遍歷時,需先獲取entry,再分別獲取key、value
    Map.Entry entry = (Map.Entry) iter1.next();
    System.out.print((String) entry.getKey());
    System.out.println((Integer) entry.getValue());
    }
}
複製程式碼

通過上述遍歷過程我們可以使用 map.entrySet() 獲取之前我們最初提及的 entrySet

public Set<Map.Entry<K,V>> entrySet() {
   Set<Map.Entry<K,V>> es;
   return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}
複製程式碼
// 我們來看下 EntrySet 是一個 set 儲存的元素是 Map 的鍵值對
final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
   // size 放回 Map 中鍵值對個數
   public final int size()                 { return size; }
   //清除鍵值對
   public final void clear()               { HashMap.this.clear(); }
   // 獲取迭代器
   public final Iterator<Map.Entry<K,V>> iterator() {
       return new EntryIterator();
   }
   
   //通過 getNode 方法獲取對一個及對應 key 對應的節點 這裡必須傳入
   // Map.Entry 鍵值對型別的物件 否則直接返回 false
   public final boolean contains(Object o) {
       if (!(o instanceof Map.Entry))
           return false;
       Map.Entry<?,?> e = (Map.Entry<?,?>) o;
       Object key = e.getKey();
       Node<K,V> candidate = getNode(hash(key), key);
       return candidate != null && candidate.equals(e);
   }
   // 滴啊用之前講得 removeNode 方法 刪除節點
   public final boolean remove(Object o) {
       if (o instanceof Map.Entry) {
           Map.Entry<?,?> e = (Map.Entry<?,?>) o;
           Object key = e.getKey();
           Object value = e.getValue();
           return removeNode(hash(key), key, value, true, true) != null;
       }
       return false;
   }
   ...
}
複製程式碼
//EntryIterator 繼承自 HashIterator
final class EntryIterator extends HashIterator
   implements Iterator<Map.Entry<K,V>> {
   // 這裡可能是因為大家使用介面卡的習慣新增了這個 next 方法
   public final Map.Entry<K,V> next() { return nextNode(); }
}

   
abstract class HashIterator {
        Node<K,V> next;        // next entry to return
        Node<K,V> current;     // current entry
        int expectedModCount;  // for fast-fail
        int index;             // current slot

        HashIterator() {
            //初始化運算元 Fast-fail 
            expectedModCount = modCount;
            // 將 Map 中的雜湊表賦值給 t
            Node<K,V>[] t = table;
            current = next = null;
            index = 0;
            //從table 第一個不為空的 index 開始獲取 entry
            if (t != null && size > 0) { // advance to first entry
                do {} while (index < t.length && (next = t[index++]) == null);
            }
        }
        
        public final boolean hasNext() {
            return next != null;
        }

        final Node<K,V> nextNode() {
            Node<K,V>[] t;
            Node<K,V> e = next;
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            if (e == null)
                throw new NoSuchElementException();
             //如果當前連結串列節點遍歷完了,則取雜湊桶下一個不為null的連結串列頭   
            if ((next = (current = e).next) == null && (t = table) != null) {
                do {} while (index < t.length && (next = t[index++]) == null);
            }
            return e;
        }
        //這裡還是呼叫 removeNode 函式不在贅述
        public final void remove() {
            Node<K,V> p = current;
            if (p == null)
                throw new IllegalStateException();
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            current = null;
            K key = p.key;
            removeNode(hash(key), key, null, false, false);
            expectedModCount = modCount;
        }
    }
複製程式碼

除了 EntryIterator 以外還有 KeyIteratorValueIterator 也都繼承了HashIterator 也代表了 HashMap 的三種不同的迭代器遍歷方式。


final class KeyIterator extends HashIterator
   implements Iterator<K> {
   public final K next() { return nextNode().key; }
}

final class ValueIterator extends HashIterator
   implements Iterator<V> {
   public final V next() { return nextNode().value; }
}
複製程式碼

可以看出無論哪種迭代器都是通過,遍歷 table 表來獲取下個節點,來遍歷的,遍歷過程可以理解為一種深度優先遍歷,即優先遍歷連結串列節點(或者紅黑樹),然後在遍歷其他陣列位置。

HashTable 的區別

面試的時候面試官總是問完 HashMap 後會問 HashTable 其實 HashTable 也算是比較古老的類了。翻看 HashTable 的原始碼可以發現有如下區別:

  1. HashMap 是執行緒不安全的,HashTable是執行緒安全的。

  2. HashMap 允許 key 和 Vale 是 null,但是隻允許一個 key 為 null,且這個元素存放在雜湊表 0 角標位置。 HashTable 不允許key、value 是 null

  3. HashMap 內部使用hash(Object key)擾動函式對 key 的 hashCode 進行擾動後作為 hash 值。HashTable 是直接使用 key 的 hashCode() 返回值作為 hash 值。

  4. HashMap預設容量為 2^4 且容量一定是 2^n ; HashTable 預設容量是11,不一定是 2^n

  5. HashTable 取雜湊桶下標是直接用模運算,擴容時新容量是原來的2倍+1。HashMap 在擴容的時候是原來的兩倍,且雜湊桶的下標使用 &運算代替了取模。

參考

最後

寫 HashMap 原始碼分析的過程,可以說比 ArrayList 或者LinkedList原始碼簡直不是一個級別的。個人能力有限,所以在學習的過程中,參考了很多前輩們的分析,也學到了很多東西。這很有用,經過這一波分析我覺得我對面試中的的 HashMap 面試題回答要比以前強很多。對於 HashMap的相關面試題集合番@HashMap一文通(1.7版) 這篇文章末尾較全面的總結。另外 HashMap 的多執行緒會導致迴圈連結串列的情況,大家可以參考 Java 8系列之重新認識HashMap 寫的非常好。大家可以原部落格去檢視。

相關文章