Java 1.7 中 HashMap
的多线程死循环问题主要是由于并发环境下对哈希表进行扩容时,可能会出现环形链表,导致 get()
方法陷入无限循环。这个问题在 Java 8 中得到了解决,因为 Java 8 对 HashMap
的实现做了较大的修改,包括引入红黑树来处理链表过长的问题。
问题产生的原因:
在 Java 1.7 及以前的版本中,HashMap 的 resize()
方法在扩容过程中重新分配元素位置时,是通过头插法来反转链表的。这个过程没有进行同步,所以在多线程并发扩容的情况下,会导致链表出现环形结构。
让我们看一看这个过程具体是如何发生的:
-
假设有两个线程 A 和 B 同时对
HashMap
进行写操作,并触发了扩容。 -
线程 A 开始执行扩容,它遍历旧的桶数组,取得一个节点,计算它在新数组中的位置,并将其插入到新桶里(头插法)。
-
线程 B 同样开始执行扩容,它可能会在线程 A 还没来得及将所有节点移到新桶前,对同一个旧桶里的节点进行操作。
-
由于头插法是反转链表,线程 A 和线程 B 在操作相同的节点时可能会导致链表出现环形结构。
问题的源码分析:
在 Java 1.7 的 HashMap 实现中,resize 过程中涉及到的代码如下:
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;
}
}
}
上述代码中 e.next = newTable[i];
这一行会改变原先节点的 next
指向,如果两个线程同时到达这里,一个线程的更改可能会被另一个线程的更改覆盖,导致链表结构破坏,进而可能形成环形链表。
代码演示:
这个问题是并发编程中的一个经典问题,不容易直接通过代码演示来复现,因为它依赖于线程调度和执行的精确时序。不过,可以用下面的伪代码来模拟这个问题:
// 假设链表只有两个节点e1 -> e2,现在要将这个链表复制到新的表newTable中。
// 线程A 处理 e1
Entry<K,V> e1_next = e1.next; // 记录 e1 的下一个节点 e2
e1.next = newTable[index]; // 将 e1 插入到 newTable 中
newTable[index] = e1; // 更新 newTable 的头部为 e1
// 线程切换到线程B之前,线程A暂停执行
// 线程B 开始处理 e2
Entry<K,V> e2_next = e2.next; // 记录 e2 的下一个节点,null
e2.next = newTable[index]; // e2.next 指向 e1
newTable[index] = e2; // newTable 的头部更新为 e2
// 线程切换回线程A
// 线程A 继续执行,使用旧的 e1.next 值
e1.next = newTable[index]; // e1.next 也指向 e2 了,形成环形链表
newTable[index] = e1; // 实际上这时 newTable[index] 已经是 e2
// 此时 newTable[index] 形成环形链表 e2 -> e1 -> e2 ...
解决办法:
为了避免这种情况,在并发环境下,我们应该使用 ConcurrentHashMap
而不是 HashMap
。ConcurrentHashMap
采用了分段锁(Java 7)和 CAS 操作(Java 8)来减少锁的竞争,从而提供了更好的并发性能,并避免了这种死循环的问题。如果你的应用程序还在使用 Java 1.7 或更早的版本,并且需要处理并发场景,强烈建议升级或改用 ConcurrentHashMap
。