1. 程式人生 > 實用技巧 >原始碼解析JDK1.8-HashMap連結串列成環的問題解決方案

原始碼解析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 兩個新老值來儲存連結串列呢,主要是因為擴容後,連結串列中的元素的索引位置是可能發生變化的。