原文:https://blog.csdn.net/Leon_cx/article/details/81911223
下面我們來模擬一下多線程場景下擴容會出現的問題:
假設在擴容過程中舊hash桶中有一個單鏈表,單鏈表中只有一個節點A,也就是e引用的對象。新hash桶中有一個單鏈表,單鏈表中的節點是B->C,也就是newTable[i]引用的對象。
單線程擴容
如果只有一個線程在執行擴容:
- 執行到第 3 行next = e.next的時候next == null
- 從第 5 行到第 9 行會將A節點按照頭插法插入到newTable[i]所引用的單鏈表中,此時newTable[i]所引用的單鏈表中的節點是A->B->C
- 第 11 行e = next會將next賦值給e,所以e == null
- 這時候循環就結束了,整個擴容過程中毫無問題
多線程擴容
如果是多個線程同時在擴容,我們以T1線程的擴容過程為主視角,T2和T3線程只是會在T1線程擴容過程中搗亂的:
- T1線程執行到第 7 行e.next = newTable[i]的時候會使得 e.next == B
- 此時T2線程過來搗亂了,執行到第 3 行next = e.next,那么會使得next == B,此時T2線程的使命結束了,下面不去考慮T2線程了
- T1線程執行到第 9 行newTable[i] = e的時候,使用頭插法將A插入到newTable[i]所引用的單鏈表中,此時newTable[i]所引用的單鏈表中的節點是A->B->C
- T1線程繼續執行到 11 行e = next,將使得e == B,由於e != null,所以循環將繼續
- T1線程開啟新的一輪循環,執行到第 3 行next = e.next的時候因為B.next == C,所以next == C
- 由於e == B,newTable[i] == A,當T1線程執行到第 7 行e.next = newTable[i]的時候,將導致A.next == B, B.next == A
當執行到這一步的時候,大家會發現好像看見了一個環,離真相越來越近了,下面我們兩種情況來繼續執行下去:
沒有T3線程介入,導致get請求死循環
T1線程繼續向下執行到第 11 行e = next,將使得e == C,將繼續進行下一輪循環
T1在這一輪新的循環中沒有其他線程介入,這一輪執行完畢之后將跳出循環,而此時newTable[i]所引用的單鏈表會形成一個閉環
這時候如果用戶發送一個get(A)的請求,將導致get請求發生死循環
有T3線程介入,導致T1線程擴容過程發生死循環
當T1線程執行到第 7 行e.next = newTable[i]的時候會使得 e.next == A
此時T3線程過來搗亂了,執行到第 3 行next = e.next,那么會使得next == A,此時T3線程的使命結束了,下面不去考慮T2線程了
此時A.next == B, B.next == A, next == A,T1線程繼續往下執行next指針會在A和B之間無線循環,導致T1擴容過程中發生死循環
擴容死循環代碼示例
import java.util.HashMap; import java.util.Map; import java.util.UUID; public class HashMapTest { public static void main(String[] args) throws Exception { HashMap<String,String> map = new HashMap<String, String>(); TestDeadLock t1 = new TestDeadLock(map); t1.start(); TestDeadLock t2 = new TestDeadLock(map); t2.start(); TestDeadLock t3 = new TestDeadLock(map); t3.start(); } } class TestDeadLock extends Thread { private HashMap<String,String> map; public TestDeadLock(HashMap<String, String> map) { super(); this.map = map; } @Override public void run() { for (int i = 0; i<500000; i++) { map.put(UUID.randomUUID().toString(), UUID.randomUUID().toString()); System.out.println("Running ~~"); } } }
main方法執行到一半后不會再打印”Running ~~”,並且方法不會執行結束,所以判斷擴容過程造成死循環了。
JDK 1.7 HashMap擴容導致死循環的主要原因
HashMap擴容導致死循環的主要原因在於擴容后鏈表中的節點在新的hash桶使用頭插法插入。
新的hash桶會倒置原hash桶中的單鏈表,那么在多個線程同時擴容的情況下就可能導致產生一個存在閉環的單鏈表,從而導致死循環。
JDK 1.8 HashMap擴容不會造成死循環的原因
在JDK 1.8中執行上面的擴容死循環代碼示例就不會發生死循環,我們可以理解為在JDK 1.8 HashMap擴容不會造成死循環,但還是需要理論依據才有信服力。
首先通過上面的分析我們知道JDK 1.7中HashMap擴容發生死循環的主要原因在於擴容后鏈表倒置以及鏈表過長。
那么在JDK 1.8中HashMap擴容不會造成死循環的主要原因就從這兩個角度去分析一下。
由於擴容是按兩倍進行擴,即 N 擴為 N + N,因此就會存在低位部分 0 - (N-1),以及高位部分 N - (2N-1), 所以在擴容時分為 loHead (low Head) 和 hiHead (high head)。
然后將原hash桶中單鏈表上的節點按照尾插法插入到loHead和hiHead所引用的單鏈表中。
由於使用的是尾插法,不會導致單鏈表的倒置,所以擴容的時候不會導致死循環。
通過上面的分析,不難發現循環的產生是因為新鏈表的順序跟舊的鏈表是完全相反的,所以只要保證建新鏈時還是按照原來的順序的話就不會產生循環。
如果單鏈表的長度達到 8 ,就會自動轉成紅黑樹,而轉成紅黑樹之前產生的單鏈表的邏輯也是借助loHead (low Head) 和 hiHead (high head),采用尾插法。然后再根據單鏈表生成紅黑樹,也不會導致發生死循環。
這里雖然JDK 1.8 中HashMap擴容的時候不會造成死循環,但是如果多個線程同時執行put操作,可能會導致同時向一個單鏈表中插入數據,從而導致數據丟失的。
所以不論是JDK 1.7 還是 1.8,HashMap線程都是不安全的,要使用線程安全的Map可以考慮ConcurrentHashMap。