在淘宝内网里看到同事发了贴说了一个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.