HashMap死循环问题追踪
简介
HashMap在设计之初并没有考虑多线程并发的情况,多线程并发的情况下理论上应该使用ConcurrentHashMap,但是程序中经常会无意中在多并发的情况下使用了HashMap,如果是jdk1.8以下的版本,有可能会导致死循环,打满cpu占用,下面基于jdk1.7源码分析下原因。
HashMap的原理
我们从put看起
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
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++;
addEntry(hash, key, value, i);
return null;
}
- HashMap基于Hash表来存储数据,冲突的键通过拉链法形成一条链表
- 插入的时候,首先判断在链表中有没有key相同的项存在,如果有,则直接进行替换,并返回旧值。
- 如果key不存在,则需要在链表中新加一个项,进入addEntry方法。
addEntry方法长这样:
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
- 如果当前key的数量大于扩容阈值而且当前要放的位置已经有值,则会进行resize进行扩容,这里可以看出,如果hash表的长度为4,按照hashmap双倍扩容的策略,在hash表里的数据量大小为8的时候才会进行扩容,并且保证当前放的这个位置已经有值。平均hash每个位置会存储两个值,应该是综合扩容效率和查找效率设计的。
- 确认hash表之后,则会新建一个entry放入hashmap,没什么特殊处理。
下面就是resize方法:
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
- 首先判断容量是否已经到达MAXIMUM_CAPACITY,这里是为了避免双倍扩容导致int越界,如果已经到达最大值,则直接返回。
- 如果没有,则申请新的容量为之前两倍的数组,并将之前的数据转移到新的数组中。
然后就是关键方法transfer:
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
- trans方法设计比较巧妙,每次取旧的key链表的头,放入新的key链表的头,这样会导致一个情况出现:
假设原本是A->B->C的链表,旧链表为null,则第一次之后旧链表会变成B->C,新链表为A。
第二次旧链表变成C,新链表为B->A。
第三次旧链表为null,新链表为C->B->A,整个链表的顺序被颠倒。 - 这种颠倒在单线程的情况下不会出现任何问题,方法简单有效,但是在多线程下,则会出现问题,下面分析一下。
死循环原因
- 现在假设有thread1,thread2两个线程,旧链表的情况为A->B->null。
- 当thread1执行到
Entry<K,V> next = e.next;
这句时,发生线程切换,thread2开始扩容,此时对线程A来说,存在以下关系。
thread1:
e=A
next=B
旧链表:
A->B->null
新链表:
null
- thread2扩容完成,线程切换回Thread1,此时存在如下关系:
thread1:
e=A
next=B
旧链表:
null
新链表:
B->A->null
- thread1执行下一句
e.next = newTable[i];
,执行完变成如下关系:
thread1:
e=A
next=B
旧链表:
null
新链表:
B->A->B(出现循环)
- thread1继续执行
newTable[i] = e;
,执行完变成如下关系:
thread1:
e=A
next=B
旧链表:
null
新链表:
A->B->A
- thread1继续执行
e = next;
,执行完变成如下关系:
thread1:
e=B
next=B
旧链表:
null
新链表:
A->B->A
- thread1已经进入死循环。
jdk1.8优化
那么对于jdk1.8是如何做的呢,1.8的resize比较复杂,核心点在下面:
Node<K,V> loHead = null, loTail = null;
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);
- 这里oldCap是2的整数倍,通过判断这一位0/1来决定放在新的链表的位置,如果是0则放在原位置,是1则放在原位置+oldCap的位置。
- loHead和loTail形成链表1,hiHead和hiTail形成链表2,直接将原有元素一一迁移到新的链表,没有颠倒等任何动作发生,因此不会出现死循环。
- 但是如果有两个线程同时操作,则有可能出现数据丢失的情况,每个线程都是将原有数据+自己的数据形成新的链表。