源碼解析JDK1.8-HashMap鏈表成環的問題解決方案

前言

  上篇文章詳解介紹了HashMap在JDK1.7版本中鏈表成環的原因,今天介紹下JDK1.8針對HashMap執行緒安全問題的解決方案。

jdk1.8 擴容源碼解析

public class HashMap<K,V> extends AbstractMap<K,V>
   implements Map<K,V>, Cloneable, Serializable {
   
   // jdk1.8 HashMap擴容源碼
final Node<K,V>[] resize() {
       
       Node<K,V>[] oldTab = table;
       
       // 到@SuppressWarnings都是計算newTab的newCap和threshold容量
       int oldCap = (oldTab == null) ? 0 : oldTab.length;
       int oldThr = threshold;
       int newCap, newThr = 0;
       if (oldCap > 0) {
           if (oldCap >= MAXIMUM_CAPACITY) {
               threshold = Integer.MAX_VALUE;
               return oldTab;
          }
           else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                    oldCap >= DEFAULT_INITIAL_CAPACITY)
               newThr = oldThr << 1; // double threshold
      }
       else if (oldThr > 0) // initial capacity was placed in threshold
           newCap = oldThr;
       else {               // zero initial threshold signifies using defaults
           newCap = DEFAULT_INITIAL_CAPACITY;
           newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
      }
       if (newThr == 0) {
           float ft = (float)newCap * loadFactor;
           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) {
           // 遍歷oldTab中的數據,並遷移到新數組。
           for (int j = 0; j < oldCap; ++j) {
               Node<K,V> e;
               // 如果oldTab數組中j位置數據不為null,進行遍歷,並賦值給e,避免直接對oldTab進行操作
               if ((e = oldTab[j]) != null) {
                   oldTab[j] = null;
                   // 如果oldTab的j位置數據沒有形成鏈表,就直接賦值到newTab
                   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
                       // loHead 表示老值,老值的意思是擴容後,該鏈表中計算出索引位置不變的元素
                       Node<K,V> loHead = null, loTail = null;
                       // hiHead 表示新值,新值的意思是擴容後,計算出索引位置發生變化的元素
                       Node<K,V> hiHead = null, hiTail = null;
                       Node<K,V> next;
                       do {
                           next = e.next;
                           // 表示老值鏈表,即該鏈表中計算出索引位置不變的元素
                           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);
                       // 生成鏈表後整體賦值
                       // 老鏈表整體賦值
                       if (loTail != null) {
                           loTail.next = null;
                           newTab[j] = loHead;
                      }
                       // 新鏈表整體賦值
                       if (hiTail != null) {
                           hiTail.next = null;
                           newTab[j + oldCap] = hiHead;
                      }
                  }
              }
          }
      }
       return newTab;
  }
}

  看完上邊的程式碼後,可能也是一頭霧水,下面我們重點對其中的細節點進行解析,針對計算newTab的newCap和threshold容量部分我們就不詳細闡述,重點從數據遷移部分進行分析。我們按照程式碼順序分步進行分析。

1、利用for循環遍歷oldTab中的數據

for (int j = 0; j < oldCap; ++j) {
  Node<K,V> e;

2、對oldTab在j位置的數據進行判斷,並進行數據遷移操作

  如果在oldTab的j位置數據沒有形成鏈表

if (e.next == null)
  newTab[e.hash & (newCap - 1)] = e;

  如果e.next == null,也就是e沒有next數據節點,通過這種方法判斷是否形成了鏈表數據結構,如果沒有形成鏈表數據結構,直接將數據放到對應newTab的位置即可。

e.hash & (newCap – 1) : 代表newTab存放e數據的位置,假如,e.hash = 5,oldCap = 4(老數組oldTab的長度),newCap = 8(新數組newTab的長度,即老數組2倍擴容),那麼該e元素:在oldTab中的位置為:5 & (4 – 1) = 1

 101

& 11


 001

在newTab中的位置為:5 & (8 – 1) = 5

   101

& 111


   101

  這種計算方式既能保證數據存儲散列,又能避免計算出的位置超出最大容量(也就是數組角標越界,因為作&運算,不會超過oldTab-1和newTab-1)。

3、鏈錶轉換成了紅黑樹,針對紅黑樹的遷移方式

else if (e instanceof TreeNode)
  ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);

  具體的遍歷方式贊不做解析,如想了解更多,請關注公眾號「程式設計師清辭」。

4、針對鏈表的數據遷移方式

else { // preserve order
  // loHead 表示老值,老值的意思是擴容後,該鏈表中計算出索引位置不變的元素
  Node<K,V> loHead = null, loTail = null;
  // hiHead 表示新值,新值的意思是擴容後,計算出索引位置發生變化的元素
  Node<K,V> hiHead = null, hiTail = null;
  Node<K,V> next;
  do {
    next = e.next;
    // 表示老值鏈表,即該鏈表中計算出索引位置不變的元素
    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);
}

jdk1.8版本為了提高鏈表遷移的效率,引用兩個新的概念:

loHead:表示老值,老值的意思是擴容後,該鏈表中計算出索引位置不變的元素。

hiHead:表示新值,新值的意思是擴容後,計算出索引位置發生變化的元素。

  舉個例子,數組大小是 8 ,在數組索引位置是 1 的地方掛著一個鏈表,鏈表有兩個值,兩個值的 hashcode 分別是是9和33。當數組發生擴容時,新數組的大小是 16,此時 hashcode 是 33 的值計算出來的數組索引位置仍然是 1,我們稱為老值hashcode 是 9 的值計算出來的數組索引位置是 9,就發生了變化,我們稱為新值。

  針對鏈表做do-while遍歷,條件為(e = next) != null。利用(e.hash & oldCap) == 0來判斷元素e屬於新值鏈表還是老值鏈表。參考上面索引位置計算演算法 e.hash & (oldCap – 1),這次直接利用e.hash與oldCap作&運算,因為oldCap為4、8、16…為2的指數,其二進位為100,1000,10000….,所以e.hash與其作&運算,假如oldCap = 4,newCap = 8,那麼最終計算得到的值如果等於0,則該元素的位置0~3之間,除此之外在4~7之間。通過這種方式判斷元素e屬於老值還是新值,這樣生成兩條新的鏈表。

5、生成新老鏈表後整體賦值

// 老鏈表整體賦值
if (loTail != null) {
  loTail.next = null;
  newTab[j] = loHead;
}
// 新鏈表整體賦值
if (hiTail != null) {
  hiTail.next = null;
  newTab[j + oldCap] = hiHead;
}

  如果是老鏈表,直接將數據賦值給newTab[j]。如果是新鏈表,需要進行將j增加oldCap的長度,通過e.hash & (newTab – 1)計算後得到的也是這個值。計算原理是相通的。

總結:

  1. jdk1.8 是等鏈表整個 while 循環結束後,才給數組賦值,此時使用局部變數 loHead 和 hiHead 來保存鏈表的值,因為是局部變數,所以多執行緒的情況下,肯定是沒有問題的。

  2. 為什麼有 loHead 和 hiHead 兩個新老值來保存鏈表呢,主要是因為擴容後,鏈表中的元素的索引位置是可能發生變化的。