前言
前兩天在公司的內部博客看到一個同事分享的線上服務掛掉CPU100%的文章,讓我聯想到HashMap在不恰當使用情況下的死循環問題,這里做個整理和總結,也順便復習下HashMap。
直接上測試代碼
由於機器配置和性能不同,測試出效果的線程數和put數量也各不相同
public class HashMapInfiniteLoopTest { /** * 基於JDK1.7測試HashMap在多線程環境下假死鎖的情況 * JDK1.8的HashMap實現跟1.7比較已經有很大的變化,已不存在這樣的問題 * (其實這本來不是JDK的一個問題,HashMap本就不是線程安全的,多線程環境下共享一定要用線程安全的Map容器) */ public static void main(String[] args) { String jdkVer = System.getProperty("java.version"); //JDK版本 String jdkMod = System.getProperty("sun.arch.data.model"); //32位還是64位 System.out.println(jdkVer +"#"+ jdkMod); final Map<String, String> map = new HashMap<>(); // final Map<String, String> map = new ConcurrentHashMap<>(); for(int i=0; i<30; i++) { new Thread(new Runnable() { @Override public void run() { System.out.println(Thread.currentThread().getName()); for(int j=0; j<1000; j++) { map.put(""+j+"_"+System.currentTimeMillis(), ""+j+"_"+System.currentTimeMillis()); } } }, "myThread_"+i).start(); } } }
通過jconsole查看Java進程情況:
最后只能強制結束進程
分析
HashMap使用hash表來作為其底層存儲的數據結構(數組下標實現快速索引,鏈表實現元素碰撞處理),並且支持動態擴容,主要通過resize方法實現,也是從這個方法開始出問題的。(這里有兩個面試官喜歡問的點:1.table的默認長度以及擴容前后大小?2.為什么要求table的長度必須是2的N次方?)
因為整個HashMap都不是線程安全的,所以JDK也未對resize方法做同步,如果錯誤的在多線程環境下共享訪問了HashMap就有可能引起我前面提到的假死鎖問題。動態擴容的時候需要把舊的鏈表遷移到新的hash表中,如果是在多線程環境下,可能會形成循環鏈表,在再次put遍歷每個鏈表檢查是否存在相同key時,死循環就出現了(如果是get也會有同樣的情況)。
下面是我整理轉載自https://coolshell.cn/articles/9606.html的部分內容(寫得太好了):
1
2
3
4
5
6
7
8
9
10
11
12
|
void
resize(
int
newCapacity)
{
Entry[] oldTable = table;
int
oldCapacity = oldTable.length;
......
//創建一個新的Hash Table
Entry[] newTable =
new
Entry[newCapacity];
//將Old Hash Table上的數據遷移到New Hash Table上
transfer(newTable);
table = newTable;
threshold = (
int
)(newCapacity * loadFactor);
}
|
遷移的源代碼,注意高亮處:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
void
transfer(Entry[] newTable)
{
Entry[] src = table;
int
newCapacity = newTable.length;
//下面這段代碼的意思是:
// 從OldTable里摘一個元素出來,然后放到NewTable中
for
(
int
j =
0
; j < src.length; j++) {
Entry<K,V> e = src[j];
if
(e !=
null
) {
src[j] =
null
;
do
{
Entry<K,V> next = e.next;
int
i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
while
(e !=
null
);
}
}
}
|
- 假設我們的hash算法就是簡單的用key mod 一下表的大小(也就是數組的長度)。
- 最上面的是old hash 表,其中的Hash表的size=2, 所以key = 3, 7, 5,在mod 2以后都沖突在table[1]這里了。
- 接下來的三個步驟是Hash表 resize成4,然后所有的<key,value> 重新rehash的過程
並發下的Rehash
1)假設我們有兩個線程。我用紅色和淺藍色標注了一下。
我們再回頭看一下我們的 transfer代碼中的這個細節:
1
2
3
4
5
6
7
|
do
{
Entry<K,V> next = e.next;
// <--假設線程一執行到這里就被調度掛起了
int
i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
while
(e !=
null
);
|
而我們的線程二執行完成了。於是我們有下面的這個樣子。
注意,因為Thread1的 e 指向了key(3),而next指向了key(7),其在線程二rehash后,指向了線程二重組后的鏈表。我們可以看到鏈表的順序被反轉后。
2)線程一被調度回來執行。
- 先是執行 newTalbe[i] = e;
- 然后是e = next,導致了e指向了key(7),
- 而下一次循環的next = e.next導致了next指向了key(3)
3)一切安好。
線程一接着工作。把key(7)摘下來,放到newTable[i]的第一個,然后把e和next往下移。
4)環形鏈接出現。
e.next = newTable[i] 導致 key(3).next 指向了 key(7)
注意:此時的key(7).next 已經指向了key(3), 環形鏈表就這樣出現了。
於是,當我們的線程一調用到,HashTable.get(7)時,悲劇就出現了——Infinite Loop。
總結
多線程並發環境下訪問共享的map時一定要用線程安全的Map容器,如ConcurrentHashMap,HashTable等。