搞懂 Java HashMap 源碼

HashMap 源碼分析

前幾篇分析了 ArrayListLinkedListVectorStack List 集合的源碼,Java 容器除了包含 List 集合外還包含着 Set 和 Map 兩個重要的集合類型。而 HashMap 則是最具備表明性的,也是咱們最常使用到的 Map 集合。咱們這篇文章就來試着分析下 HashMap 的源碼,因爲 HashMap 底層涉及到太多方面,一篇文章老是不能面面俱到,因此咱們能夠帶着面試官常問的幾個問題去看源碼:html

  1. 瞭解底層如何存儲數據的
  2. HashMap 的幾個主要方法
  3. HashMap 是如何肯定元素存儲位置的以及如何處理哈希衝突的
  4. HashMap 擴容機制是怎樣的
  5. JDK 1.8 在擴容和解決哈希衝突上對 HashMap 源碼作了哪些改動?有什麼好處?

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

因爲掘金後臺審覈可能會因爲某些緣由形成文章發佈延遲或者遺漏,若是感受我寫的源碼分析文章還不錯,能夠關注我,之後我每次更新文章就能夠收到推送了。另外博主也是在努力進步中,全部文章若是有問題請儘管留言給我。我會及時改正。你們一塊兒進步。node

概述

爲了方便下邊的敘述這裏須要先對幾個常見的關於 HashMap 的知識點進行下概述:面試

  1. HashMap 存儲數據是根據鍵值對存儲數據的,而且存儲多個數據時,數據的鍵不能相同,若是相同該鍵以前對應的值將被覆蓋。注意若是想要保證 HashMap 可以正確的存儲數據,請確保做爲鍵的類,已經正確覆寫了 equals() 方法。算法

  2. HashMap 存儲數據的位置與添加數據的鍵的 hashCode() 返回值有關。因此在將元素使用 HashMap 存儲的時候請確保你已經按照要求重寫了 hashCode()方法。這裏說有關係表明最終的存儲位置不必定就是 hashCode 的返回值。segmentfault

  3. HashMap 最多隻容許一條存儲數據的鍵爲 null,可容許多條數據的值爲 null。數組

  4. HashMap 存儲數據的順序是不肯定的,而且可能會由於擴容致使元素存儲位置改變。所以遍歷順序是不肯定的。安全

  5. HashMap 是線程不安全的,若是須要再多線程的狀況下使用能夠用 Collections.synchronizedMap(Map map) 方法使 HashMap 具備線程安全的能力,或者使用 ConcurrentHashMapbash

瞭解 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採用「拉鍊法」來存儲數據,即數組和鏈表結合的方式:

「拉鍊法」用專業點的名詞來講叫作鏈地址法。簡單來講,就是數組加鏈表的結合。在每一個數組元素上存儲的都是一個鏈表。

咱們以前說到不一樣的 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 中的元素進行操做。

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取模。咱們根據圖片來看一個具體的例子:

圖片來自: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

添加元素過程:

  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對應的哈希與高位運算結果。

元素在從新計算hash以後,由於n變爲2倍,那麼n-1的mask範圍在高位多1bit(紅色),所以新的index就會發生這樣的變化:

因此在 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 寫的很是好。你們能夠原博客去查看。

相關文章
相關標籤/搜索