HashMap的死循环【JDK1.7.0_79】
车祸现场
HashMap本身非线程安全,当将其作为全局变量,高并发场景下进行put、get、remove等操作的时候CPU会爆满,业务无响应。
源码分析
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
//计算key的hash值
int hash = hash(key);
//定位key在HashMap中的位置
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++;
//key值不同或者该位置不存在值,进入添加新节点方法
addEntry(hash, key, value, i);
return null;
}
假设全局变量HashMap容量为4,当前扩容阈值为2,a和b两个key不同、hash值相同组成的一个链表结构。当线程Thread1、Thread2同时做put操作,默认a和b的hash值和HashMap中现有hash值均不冲突,此时均进入添加新节点addEntry()方法。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oGHkswOk-1578280925463)
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);
}
因为现在扩容阈值是2,添加新节点时候先进行扩容操作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);
}
创建本线程可见的新Entry数组,通过transfer()方法将旧数组数据迁移到新数组,而a、b的hash值恰巧又相同再次形成链表结构。
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; ----案发现场
//迁移数据不一定重新计算hash值
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;
}
}
}
Thread1执行到“案发现场”时候,e = a、next = e.next = b,此时CPU时间片用尽;Thread2获得CPU时间片后,数组扩大为之前的一倍,将旧数组数据迁移到新数组并将新值插入数组,最后把本线程可见的数组写入内存中。执行结束,Thread1获得时间片继续执行。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-t3Cb9r9e-1578280925465)
Thread1第一次循环结束,本线程可见新数组如下图所示。因为在将a赋值到新数组newTable[i]位置之前,newTable[i]为null所以e.next为null。
while(null != e) {
Entry<K,V> next = e.next; --案发现场 next = b、e = a
//迁移数据不一定重新计算hash值
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i]; e.next = null
newTable[i] = e; newTable[i] = a
e = next; e = b
}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7YTYpOIB-1578280925466)
Thread1执行第二次循环,此时(e = b) != null,此时线程读取到的b.next为Thread2刷入内存中的新数组内容,next = b.next = a。
while(null != e) {
Entry<K,V> next = e.next; --案发现场 next = a、e = b
//迁移数据不一定重新计算hash值
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i]; e.next = a
newTable[i] = e; newTable[i] = b
e = next; e = a
}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EhBUJfNa-1578280925466)
Thread1执行第三次循环,此时(e = a) != null,此时线程读取到的next = a.next = null。newTable[i] = b,此时a.next被赋值为b形成死循环。
while(null != e) {
Entry<K,V> next = e.next; --案发现场 next = null、e = a
//迁移数据不一定重新计算hash值
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i]; e.next = b
newTable[i] = e; newTable[i] = a
e = next; e = null
}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JNL5TD4G-1578280925468)
当通过get()方法获取死循环中键值对时候,线程陷入死循环链表遍历e.next一直不为null,造成死循环。
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
int hash = (key == null) ? 0 : hash(key);
//当出现死循环时候,如果查询hash值相同而key不同
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) { -- 进入死循环链表e.next永远不为null
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null &&
key.equals(k))))
return e;
}
return null;
}
总结
JDK8之前HashMap之所以会产生死循环,主要是原因是transfer()方法在迁移链表数据时候将链表数据顺序倒置所致。JDK8之后在扩容之后通过head和tail机制保证链表数据顺序不变。
// preserve order
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);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
但是新加入的红黑树机制还可能引起死循环,所以并发场景下还是用ConcurrentHashMap。JDK8之前官方文档都就说啦:高并发场景下,必须上锁,推荐使用Collections.synchronizedMap(new HashMap(…))哦。
* <p><strong>Note that this implementation is not synchronized.</strong>
* If multiple threads access a hash map concurrently, and at least one of
* the threads modifies the map structurally, it <i>must</i> be
* synchronized externally. (A structural modification is any operation
* that adds or deletes one or more mappings; merely changing the value
* associated with a key that an instance already contains is not a
* structural modification.) This is typically accomplished by
* synchronizing on some object that naturally encapsulates the map.