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)this

         AbstractMap經過遍歷Entry節點的方式實現了這個方法,顯然HashMap以爲效率過低並無複用而是重寫了這個方法。spa

         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     //第一步判斷就是是否擴容,須要擴容的條件須要知足如下兩個:一、Map中的key-value的個數大於等於Map的容量threshold(threshold=散列表容量(數組大小)*負載因子)。二、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的公衆號 

相關文章
相關標籤/搜索