Java集合中的HashMap類,Java集合HashMap類

來源:互聯網
上載者:User

Java集合中的HashMap類,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#containsKey2 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#containsKey2 public boolean containsKey(Object key) {3     return getNode(hash(key), key) != null;    //JDK8中新增了一個getNode方法,且將key的hash值計算好後作為參數傳遞。4 }5 //HashMap#getNode6 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方法類似。

         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、散列表的插入位置i26     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未產生散列衝突

  重新根據新的散列表計算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#put2 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方法後,一切方法基本上都能舉一反三。所以在看完本文後,你應該試著問自己以下幾個問題:

 

 

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

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.