Java集合中的HashMap類


jdk1.8.0_144

         HashMap作為最常用集合之一,繼承自AbstractMap。JDK8的HashMap實現與JDK7不同,新增了紅黑樹作為底層數據結構,結構變得復雜,效率變得更高。為滿足自身需要,也重新實現了很多AbstractMap中的方法。本文會圍繞HashMap,詳細探討HashMap的底層數據結構、擴容機制、並發環境下的死循環問題等。

         JDK8同JDK7一樣對Map.Entry進行了重新實現,改了個名字叫——Node,我想這是因為在紅黑樹中更方便理解,方法和JDK7大體相同只是取消了幾個方法。並且此時的Node節點(也就是Entry)結構更加完善:

1 static class Node<K,V> implements Map.Entry<K,V> {
2     final int hash;            //節點hash值
3     final K key;                 //key值
4     V value;                      //value值
5     Node<K,V> next;      //指向的下一個節點
6 
7     //省略,由於JDK8的Map接口新增了幾個compare比較的方法,Node直接就繼承了
8 
9 }

  Node作為HashMap維護key-value的內部數據結構比較簡單,下面是HashMap重新實現Map的方法。

public int size()

         HashMap並沒有繼承AbstractMap的size方法,而是重寫了此方法。HashMap在類中定義了一個size變量,再此處直接返回size變量而不用調用entrySet方法返回集合再計算。可以猜測這個size變量是當插入一個key-value鍵值對的時候自增。

public boolean isEmpty()

         判斷size變量是否0即可。

public boolean containsKey(Object key)

         AbstractMap通過遍歷Entry節點的方式實現了這個方法,顯然HashMap覺得效率太低並沒有復用而是重寫了這個方法。

         JDK8的HashMap底層數據結構引入了紅黑樹,它的實現要比JDK7略微復雜,我們先來看JDK7關於這個方法的實現。

1 //JDK7,HashMap#containsKey
2 public boolean containsKey(Object key) {
3     return getEntry(key) != null;        //調用getEntry方法
4 }

  getEntry實現的思路也比較簡單,由於JDK7的HashMap是數組+鏈表的數據結構,當key的hash值沖突的時候使用鏈地址法直接加到沖突地址Entry的next指針行程鏈表即可。所以getEntry方法的思路也是先計算key的hash值,計算后再找到它在散列表的下標,找到過再遍歷這個位置的鏈表返回結果即可。

  JDK8加入了紅黑樹,在鏈表的個數達到閾值8時會將鏈表轉換為紅黑樹,如果此時是紅黑樹,則不能通過遍歷鏈表的方式尋找key值,所以JDK8對該方法進行了改進主要是需要遍歷紅黑樹,有關紅黑樹的具體算法在此不多介紹。

1 //JDK8,HashMap#containsKey
2 public boolean containsKey(Object key) {
3     return getNode(hash(key), key) != null;    //JDK8中新增了一個getNode方法,且將key的hash值計算好后作為參數傳遞。
4 }
5 //HashMap#getNode
6 final Node<K,V> getNode(int hash, Object key) {
7     //此方法相比較於JDK7中的getEntry基本相同,唯一不同的是發現key值沖突過后會通過“first instanceof TreeNode”檢查此時是否是紅黑樹結構。如果是紅黑樹則會調用getTreeNode方法在紅黑樹上進行查詢。如果不是紅黑樹則是鏈表結構,遍歷鏈表即可。
8 }

 public boolean containsValue(Object value)

  遍歷散列表中的元素

public V get(Object key)

   在JDK8中get方法調用了containsKey的方法getNode,這點和JDk7的get方法中調用getEntry方法類似。

  1. 將參數key的hash值和key作為參數,調用getNode方法;
  2. 根據(n - 1) & hash(key)計算key值所在散列桶的下標;
  3. 取出散列桶中的key與參數key進行比較:

         3.1 如果相等則直接返回Node節點;

         3.2 如果不相等則判斷當前節點是否有后繼節點:

                   3.2.1 判斷是否是紅黑樹結構,是則調用getTreeNode查詢鍵值為key的Node   節點;

                   3.2.2 如果是鏈表結構,則遍歷整個鏈表。

public V put(K key, V value)

  這個方法最為關鍵,插入key-value到Map中,在這個方法中需要計算key的hash值,然后通過hash值計算所在散列桶的位置,判斷散列桶的位置是否有沖突,沖突過后需要使用鏈地址法解決沖突,使之形成一個鏈表,從JDK8開始如果鏈表的元素達到8個過后還會轉換為紅黑樹。在插入時還需要判斷是否需要擴容,擴容機制的設計,以及在並發環境下擴容所帶來的死循環問題。

  由於JDK7比較簡單,我們先來查看JDK7中的put方法源碼。

JDK7——HashMap#put

 1 //JDK7, HashMap#put
 2 public V put(K key, V value) {
 3     //1. 首先判斷是否是第一次插入,即散列表是否指向空的數組,如果是,則調用inflateTable方法對HashMap進行初始化。
 4     if (table == EMPTY_TABLE) {
 5         inflateTable(threshold);
 6     }
 7     //2. 判斷key是否等於null,等於空則調用putForNullKey方法存入key為null的key-value,HashMap支持key=null。
 8     if (key == null)
 9         return putForNullKey(value);
10     //3. 調用hash方法計算key的hash值,調用indexFor根據hash值和散列表的長度計算key值所在散列表的下標i。
11     int hash = hash(key);
12     int i = indexFor(hash, table.length);
13     //4. 這一步通過循環遍歷的方式判斷插入的key-value是否已經在HashMap中存在,判斷條件則是key的hash值相等,且value要么引用相等要么equals相等,如果滿足則直接返回value。
14     for (Entry<K,V> e = table[i]; e != null; e = e.next) {//如果插入位置沒有散列沖突,即這個位置沒有Entry元素,則不進入循環。有散列沖突則需要遍歷鏈表進行判斷。
15         Object k;
16         if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
17             V oldValue = e.value;
18             e.value = value;
19             e.recordAccess(this);
20             return oldValue;
21         }
22     }
23     //插入
24     modCount++;//記錄修改次數,在並發環境下通過迭代器遍歷時會拋出ConcurrentModificationException異常(Fail-Fast機制),就是通過這個變量來實現的。在迭代器初始化過程會將modCount賦給迭代器的ExpectedModCount,是否會拋出ConcurrentModificationException異常的實現就是在迭代過程中判斷modCount是否與ExpectedModCount相等。
25     //插入key-value鍵值對,傳入key的hash值、key、value、散列表的插入位置i
26     addEntry(hash, key, value, i);    
27 }                

 

 1 //JDK7,HashMap#addEntry,這個方法是put方法的實現核心,在其中會判斷是否沖突,是否擴容。
 2 void addEntry(int hash, K key, V value, int bucketIndex) {
 3     //第一步判斷就是是否擴容,需要擴容的條件需要滿足以下兩個:1、Map中的key-value的個數大於等於Map的容量threshold(threshold=散列表容量(數組大小)*負載因子)。2、key值所對應的散列表位置不為null。
 4     if ((size >= threshold) && (null != table[bucketIndex])) {
 5         resize(2 * table.length);        //關鍵的擴容機制,擴容后的大小是之前的兩倍
 6         hash = (null != key) ? hash(key) : 0;        //計算key的hash值
 7         bucketIndex = indexFor(hash, table.length);        //重新計算key所在散列表的下標
 8     }
 9     //創建Entry節點並插入,每次插入都會插在鏈表的第一個位置。
10     createEntry(hash, key, value, bucketIndex);    
11 }

  來看看HashMap是如何擴容的。JDK7HashMap擴容的大小是前一次散列表大小的兩倍2 * table.length

void resize(int newCapacity)

  在這個方法中最核心的是transfer(Entry[], boolean)方法,第一個參數表示擴容后新的散列表引用,第二參數表示是否初始化hash種子。

  結合源碼我們用圖例來說明HashMap在JDK7中是如何進行擴容的。

  假設現在有如下HashMap,初始容量initialCapacity=4,負載因子loadFactor=0.5。初始化時閾值threshold=4*0.5=2。也就是說在插入第三個元素時,HashMap中的size=3大於閾值threshold=2,此時就會進行擴容。我們從來兩種情況來對擴容機制進行分析,一種是兩個key-value未產生散列沖突,第二種是兩個key-value產生了散列沖突。

  1. 擴容時,當前HashMap的key-value未產生散列沖突

  此時當插入第三個key-value時,HashMap會進行擴容,容量大小為之前的兩倍,並且在擴容時會對之前的元素進行轉移,未產生沖突的HashMap轉移較為簡單,直接遍歷散列表對key重新計算出新散列表的數組下標即可。

  2. 擴容時,當前HashMap的key-value產生散列沖突

  在對散列沖突了的元素進行擴容轉移時,需要遍歷當前位置的鏈表,鏈表的轉移若新散列表還是沖突則采用頭插法的方式進行插入,此處需要了解鏈表的頭插法。同樣通過for (Entry<K,V> e : table)遍歷散列表中的元素,判斷當前元素e是否為null。由例可知,當遍歷到第2個位置的時候元素e不為null。此時創建臨時變量next=e.next。

  重新根據新的散列表計算e的新位置i,后面則開始通過頭插法把元素插入進入新的散列表。

  通過頭插法將A插入進了新散列表的i位置,此時指針通過e=next繼續移動,待插入元素變成了B,如下所示。

  此時會對B元素的key值進行hash運算,計算出它在新散列表中的位置,無論在哪個位置,均是頭插法,假設還是在位置A上產生了沖突,頭插法后則變成了如下所示。

  可知,在擴容過程中,鏈表的轉移是關鍵,鏈表的轉移通過頭插法進行插入,所以正是因為頭插法的原因,新散列表沖突的元素位置和舊散列表沖突的元素位置相反。

  關於HashMap的擴容機制還有一個需要注意的地方,在並發條件下,HashMap不僅僅是會造成數據錯誤,致命的是可能會造成CPU100%被占用,原因就是並發條件下,由於HashMap的擴容機制可能會導致死循環。下面將結合圖例說明,為什么HashMap在並發環境下會造成死循環。

  假設在並發環境下,有兩個線程現在都在對同一個HashMap進行擴容。

  此時線程T1對擴容前的HashMap元素已經完成了轉移,但由於Java內存模型的緣故線程T2此時看到的還是它自己線程中HashMap之前的變量副本。此時T2對數據進行轉移,如下圖所示。

  進一步地,在T2中的新散列表中newTable[i]指向了元素A,此時待插入節點變成了B,如下圖所示。

  原本在正常情況下,next會指向null,但由於T1已經對A->B鏈表進行了轉置B->A,即next又指回了A,並且B會插入到T2的newTable[i]中。

  由於此時next不為空,下一步又會將next賦值給e,即e = next,反反復復A、B造成閉環形成死循環。

  所以,千萬不要使用在並發環境下使用HashMap,一旦出現死循環CPU100%,這個問題不容易復現及排查。並發環境一定需要使用ConcurrentHashMap線程安全類。

  探討了JDK7中的put方法,接下來看看JDK8新增了紅黑樹HashMap是如何進行put,如何進行擴容,以及如何將鏈表轉換為紅黑樹的。

JDK8——HashMap#put

1 //JDK8, HashMap#put
2 public V put(K key, V value) {
3     //在JDK8中,put方法直接調用了putVal方法,該方法有5個參數:key哈希值,key,value,onlyIfAbsent(如果為ture則Map中已經存在該值的時候將不會把value值替換),evict在HashMap中無意義
4     return putVal(hash(key), key, value, false, true);
5 }

  所以關鍵的方法還是putVal。

 1 //JDK8中putVal方法和JDK7中put方法中的插入步驟大致相同,同樣需要判斷是否是第一次插入,插入的位置是否產生沖突,不同的是會判斷插入的節點是“鏈表節點”還是“紅黑色”節點。
 2 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
 3   //1. 是否是第一次插入,是第一次插入則復用resize算法,對散列表進行初始化
 4   if ((tab = table) == null || (n = tab.length) == 0)
 5     n = (tab = resize()).length;
 6   //2. 通過i = (n - 1) & hash計算key值所在散列表的下標,判斷tab[i]是否已經有元素存在,即有無沖突,沒有則直接插入即可,注意如果插入的key=null,此處和JDK7的策略略有不同,JDK7是遍歷散列表只要為null就直接插入,而JDK8則是始終會插入第一個位置,即使有元素也會形成鏈表
 7   if ((p = tab[i = (n - 1) & hash]) == null)
 8     tab[i] = newNode(hash, key, value, null);
 9   //3. tab[i]已經有了元素即產生了沖突,如果是JDK7則直接使用頭插法即可,但在JDK8中HashMap增加了紅黑樹數據結構,此時有可能已經是紅黑樹結構,或者處在鏈表轉紅黑樹的臨界點,所以此時需要有幾個判斷條件
10   else {
11      //3.1 這是一個特殊判斷,如果tab[i]的元素hash和key都和帶插入的元素相等,則直接覆蓋value值即可
12    if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
13      e = p;
14     //3.2 待插入節點是一個紅黑樹節點
15     else if (p instanceof TreeNode)
16      e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
17    //3.3 插入后可能繼續是一個鏈表,也有可能轉換為紅黑樹。在元素個數超過8個時則會將鏈表轉換為紅黑樹,所以第一個則需要一個計數器來遍歷計算此時tab[i]上的元素個數
18    else {
19      for (int binCount = 0; ; ++binCount) {
20        if ((e = p.next) == null) {
21          p.next = newNode(hash, key, value, null);        //遍歷到當前元素的next指向null,則通過尾插法插入,這也是和JDK7采用頭插法略微不同的地方
22          if (binCount >= TREEIFY_THRESHOLD - 1) // tab[i]的數量超過了臨界值8,此時將會進行鏈表轉紅黑樹的操作,並跳出循環
23            treeifyBin(tab, hash);
24            break;
25              }
26              if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))        //這種情況同3.1,出現了和插入key相同的元素,直接跳出循環,覆蓋value值即可,無需插入操作
27                 break;
28              p = e;
29      }
30    }
31    if (e != null) {        //這種情況表示帶插入元素的key在Map中已經存在,此時沒有插入操作,直接覆蓋value值即可
32      V oldValue = e.value;
33      if (!onlyIfAbsent || oldValue == null)
34        e.value = value;
35      afterNodeAccess(e);
36      return oldValue;
37    }
38  }
39  ++modCount;        //修改計數,在使用Iterator迭代器時會和這個變量比較,如果不相等,則會拋出ConcurrentModificationException異常
40  if (++size > threshold)    //判斷是否需要擴容
41    resize();
42  afterNodeInsertion(evict);        //並無意義
43  return null;
44 }

 

  從上面的JDK7和JDK8的put插入方法源碼分析來看,JDK8確實復雜了不少,在沒有耐心的情況下,這個“干貨”確實顯得比較干,我試着用下列圖解的方式回顧JDK7和JDK8的插入過程,在對比過后接着對JDK8中的紅黑樹插入、鏈表轉紅黑樹以及擴容作分析。

  綜上JDK7和JDK8的put插入方法大體上相同,其核心均是計算key的hash並通過hash計算散列表的下標,再判斷是否產生沖突。只是在實現細節上略有區別,例如JDK7會對key=null做特殊處理,而JDK8則始終會放置在第0個位置;而JDK7在產生沖突時會使用頭插法進行插入,而JDK8在鏈表結構時會采用尾插法進行插入;當然最大的不同還是JDK8對節點的判斷分為了:鏈表節點、紅黑樹節點、鏈表轉換紅黑樹臨界節點。

  對於紅黑樹的插入暫時不做分析,接下來是對JDK8擴容方法的分析。

 1 // JDK8,HashMap#resize擴容,HashMap擴容的大小仍然是前一次散列表大小的兩倍 
 2 final Node<K,V>[] resize() {
 3  //1. 由於JDK8初始化散列表時復用了resize方法,所以前面是對oldTab的判斷,是否為0(表示是初始化),是否已經大於等於了最大容量。判斷結束后newTab會擴大為oldTab的兩倍,同樣newThr(閾值)也是以前的兩倍。源碼略。
 4  //2. 確定好newTab的大小后接下來就是初始化newTab散列表數組
 5  Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
 6  table = newTab;
 7  //3. 如果是初始化(即oldTab==null),則直接返回新的散列表數組,不是則進行轉移
 8  //4. 首先還是遍歷散列表
 9  for (int j = 0; j < oldCap; ++j) {
10  //5. e = oldCap[i] != null,則繼續判斷
11    //5.1 當前位置i,是否有沖突,沒有則直接轉移
12    if (e.next == null)
13      newTab[e.hash & (newCap - 1)] = e;    //這里並沒有對要轉移的元素重新計算hash,對於JDK7來會通過hash(e.getKey()) ^ newCap重新計算e在newTab中的位置,此處則是e.hash & (newCap - 1),減少了重新計算hash的過程。擴容后的位置要么在原來的位置上,要么在原索引 + oldCap位置 
14    //5.2 判斷是否是紅黑樹節點
15    else if (e instanceof TreeNode)
16      ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
17    //5.3 判斷是否是鏈表節點
18    else {
19     20    }
21   }
22 }

 

  JDK8的擴容機制相比較於JDK7除了增加對節點是否為紅黑樹的判斷,其余大致相同,只是做了一些微小的優化。特別在於在JDK8中並不會重新計算key的hash值。

public V remove(Object key)

  如果已經非常清楚put過程,我相信對於HashMap中的其他方法也基本能知道套路。remove刪除也不例外,計算hash(key)以及所在散列表的位置i,判斷i是否有元素,元素是否是紅黑樹還是鏈表。

  這個方法容易陷入的陷阱是key值是一個自定義的pojo類,且並沒有重寫equals和hashCode方法,此時用pojo作為key值進行刪除,很有可能出現“刪不掉”的情況。這需要重寫equals和hashCode才能使得兩個pojo對象“相等”。

  剩下的方法思路大同小異,基本均是計算hash、計算散列表下標i、遍歷、判斷節點類型等等。本文在弄清put和resize方法后,一切方法基本上都能舉一反三。所以在看完本文后,你應該試着問自己以下幾個問題:

  1. HashMap的底層數據結構是什么?
  2. HashMap的put過程?
  3. HashMap的擴容機制?
  4. 並發環境下HashMap會帶來什么致命問題?

 

 

這是一個能給程序員加buff的公眾號 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM