在淘寶內網裏看到同事發了貼說了一個CPU被100%的線上故障,而且這個事發生了不少次,緣由是在Java語言在併發狀況下使用HashMap造 成Race Condition,從而致使死循環。這個事情我四、5年前也經歷過,原本以爲沒什麼好寫的,由於Java的HashMap是非線程安全的,因此在併發下 必然出現問題。可是,我發現近幾年,不少人都經歷過這個事(在網上查「HashMap Infinite Loop」能夠看到不少人都在說這個事)因此,以爲這個是個廣泛問題,須要寫篇疫苗文章說一下這個事,而且給你們看看一個完美的「Race Condition」是怎麼造成的。html
從前咱們的Java代碼由於一些緣由使用了HashMap這個東西,可是當時的程序是單線程的,一切都沒有問題。後來,咱們的程序性能有問題,因此 須要變成多線程的,因而,變成多線程後到了線上,發現程序常常佔了100%的CPU,查看堆棧,你會發現程序都Hang在了HashMap.get()這 個方法上了,重啓程序後問題消失。可是過段時間又會來。並且,這個問題在測試環境裏可能很難重現。算法
咱們簡單的看一下咱們本身的代碼,咱們就知道HashMap被多個線程操做。而Java的文檔說HashMap是非線程安全的,應該用ConcurrentHashMap。shell
可是在這裏咱們能夠來研究一下緣由。數組
我須要簡單地說一下HashMap這個經典的數據結構。安全
HashMap一般會用一個指針數組(假設爲table[])來作分散全部的key,當一個key被加入時,會經過Hash算法經過key算出這個 數組的下標i,而後就把這個<key, value>插到table[i]中,若是有兩個不一樣的key被算在了同一個i,那麼就叫衝突,又叫碰撞,這樣會在table[i]上造成一個鏈 表。數據結構
咱們知道,若是table[]的尺寸很小,好比只有2個,若是要放進10個keys的話,那麼碰撞很是頻繁,因而一個O(1)的查找算法,就變成了鏈表遍歷,性能變成了O(n),這是Hash表的缺陷(可參看《Hash Collision DoS 問題》)。多線程
因此,Hash表的尺寸和容量很是的重要。通常來講,Hash表這個容器當有數據要插入時,都會檢查容量有沒有超過設定的thredhold,若是 超過,須要增大Hash表的尺寸,可是這樣一來,整個Hash表裏的無素都須要被重算一遍。這叫rehash,這個成本至關的大。併發
相信你們對這個基礎知識已經很熟悉了。app
下面,咱們來看一下Java的HashMap的源代碼。oop
Put一個Key,Value對到Hash表中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
public
V put(K key, V value)
{
......
//算Hash值
int
hash = hash(key.hashCode());
int
i = indexFor(hash, table.length);
//若是該key已被插入,則替換掉舊的value (連接操做)
for
(Entry<K,V> e = table[i]; e !=
null
; e = e.next) {
Object k;
if
(e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(
this
);
return
oldValue;
}
}
modCount++;
//該key不存在,須要增長一個結點
addEntry(hash, key, value, i);
return
null
;
}
|
檢查容量是否超標
1
2
3
4
5
6
7
8
|
void
addEntry(
int
hash, K key, V value,
int
bucketIndex)
{
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] =
new
Entry<K,V>(hash, key, value, e);
//查看當前的size是否超過了咱們設定的閾值threshold,若是超過,須要resize
if
(size++ >= threshold)
resize(
2
* table.length);
}
|
新建一個更大尺寸的hash表,而後把數據從老的Hash表中遷移到新的Hash表中。
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的過程
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(11)時,悲劇就出現了——Infinite Loop。
有人把這個問題報給了Sun,不過Sun不認爲這個是一個問題。由於HashMap原本就不支持併發。要併發就用ConcurrentHashmap
http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=6423457
我在這裏把這個事情記錄下來,只是爲了讓你們瞭解並體會一下併發環境下的危險。
參考:http://mailinator.blogspot.com/2009/06/beautiful-race-condition.html
(全文完)
writes: "This is a classic symptom of an incorrectly synchronized use of HashMap. Clearly, the submitters need to use a thread-safe HashMap. If they upgraded to Java 5, they could just use ConcurrentHashMap. If they can't do this yet, they can use either the pre-JSR166 version, or better, the unofficial backport as mentioned by Martin. If they can't do any of these, they can use Hashtable or synchhronizedMap wrappers, and live with poorer performance. In any case, it's not a JDK or JVM bug." I agree that the presence of a corrupted data structure alone does not indicate a bug in the JDK.