高并发环境下,HashMap可能出现的致命问题!

高并发环境下,HashMap可能出现的致命问题!注意:是在 jdk8 以下版本发生!

我们先来看看 Rehash 的概念。

Rehash 是 HashMap 在扩容时候的一个步骤。

HashMap 的容量是有限的。当经过多次元素插入,使得 HashMap 达到一定饱和度时,Key 映射位置发生冲突的几率会逐渐提高。

这时候,HashMap 需要扩展它的长度,也就是进行 Resize。

高并发环境下,HashMap可能出现的致命问题!

影响发生 Resize 的因素有两个:

  • Capacity(HashMap 的当前长度–容量)

HashMap 的当前长度。上一期曾经说过,HashMap 的长度是 2 的幂。

  • LoadFactor(负载因子)

HashMap 负载因子,默认值为 0.75f。

衡量 HashMap 是否进行 Resize 的条件如下:

HashMap.Size >= Capacity * LoadFactor (默认情况下是 == 原来长度 * 0.75)

HashMap 的 Resize 方法具体做了什么事情?

  • 扩容

创建一个新的Entry空数组,长度是原数组的2倍。

  • ReHash

遍历原 Entry 数组,把所有的 Entry 重新 Hash 到新数组。为什么要重新 Hash 呢?因为长度扩大以后,Hash 的规则也随之改变。

让我们回顾一下Hash公式:

index = HashCode(Key) & (Length - 1)

当原数组长度为 8 时,Hash 运算是和 111B(代表二进制的 7)做与运算;新数组长度为16,Hash 运算是和 1111B(代表二进制的 15)做与运算。Hash 结果显然不同

高并发环境下,HashMap可能出现的致命问题!
高并发环境下,HashMap可能出现的致命问题!

ReHash 的 Java 代码如下:

/**
 * Transfers all entries from current table to newTable.
 */
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;
        }
    }
}

注意 HashMap 在多线程下的 Rehash 可能会出现什么样的问题呢?

现在假设一个 HashMap 已经到了 Resize 的临界点。此时有两个线程 A 和 B,在同一时刻对 HashMap 进行 Put 操作:

高并发环境下,HashMap可能出现的致命问题!

此时达到 Resize 条件,两个线程各自进行 Rezie 的第一步,也就是扩容。

这时候,两个线程都走到了 ReHash 的步骤。让我们回顾一下 ReHash 的代码:

高并发环境下,HashMap可能出现的致命问题!

假如此时线程 B 遍历到 Entry3 对象,刚执行完红框里的这行代码,线程就被挂起。

对于线程 B 来说:e = Entry3 next =Entry2 这时候线程 A 畅通无阻地进行着 Rehash,当 ReHash 完成后,结果如下(图中的 e 和 next,代表线程 B 的两个引用):

高并发环境下,HashMap可能出现的致命问题!

直到这一步,看起来没什么毛病。接下来线程 B 恢复,继续执行属于它自己的 ReHash。
线程 B 刚才的状态是:e = Entry3 next = Entry2

高并发环境下,HashMap可能出现的致命问题!

当执行到上面这一行时,显然 i = 3,因为刚才线程 A 对于 Entry3 的 hash 结果也是3。

高并发环境下,HashMap可能出现的致命问题!

我们继续执行到这两行,Entry3 放入了线程 B 的数组下标为 3 的位置,并且 e 指向了 Entry2。

此时 e 和 next 的指向关系:e =Entry2 next = Entry2 整体情况如下图所示:

高并发环境下,HashMap可能出现的致命问题!

接着是新一轮循环,又执行到红框内的代码行:

高并发环境下,HashMap可能出现的致命问题!

e = Entry2

next = Entry3

整体情况如图所示:

高并发环境下,HashMap可能出现的致命问题!

接下来执行下面的三行,用头插法把 Entry2 插入到了线程 B 的数组的头结点:

高并发环境下,HashMap可能出现的致命问题!

整体情况如图所示:

高并发环境下,HashMap可能出现的致命问题!

第三次循环开始,又执行到红框的代码:

高并发环境下,HashMap可能出现的致命问题!

e = Entry3

next = Entry3.next = null

最后一步,当我们执行下面这一行的时候,见证奇迹的时刻来临了。

高并发环境下,HashMap可能出现的致命问题!
newTable[i] = Entry2
e = Entry3
Entry2.next = Entry3
Entry3.next = Entry2

链表出现了环形!整体情况如图所示:

高并发环境下,HashMap可能出现的致命问题!

此时,问题还没有直接产生。当调用 Get 查找一个不存在的 Key,而这个 Key 的 Hash 结果恰好等于 3 的时候,由于位置 3 带有环形链表,所以程序将会进入死循环!

这种情况,不禁让人联想到一道经典的面试题:如何判断链表有环?

如何杜绝这种情况?

高并发环境下,HashMap可能出现的致命问题!

最后总结如下:

  • Hashmap 在插入元素过多的时候需要进行 Resize。

Resize 的条件是 HashMap.Size >= Capacity * LoadFactor

  • Hashmap 的 Resize 包含扩容和 ReHash 两个步骤,ReHash 在并发的情况下可能会形成链表环。

链表头插法的会颠倒原来一个散列桶里面链表的顺序。在并发的时候原来的顺序被另外一个线程 a 颠倒了,而被挂起线程 b 恢复后拿扩容前的节点和顺序继续完成第一次循环后,又遵循 a 线程扩容后的链表顺序重新排列链表中的顺序,最终形成了环。

这也是为什么后来 HashMap 改为尾插法的原因之一。

高并发环境下,HashMap可能出现的致命问题!

: » 高并发环境下,HashMap可能出现的致命问题!

原创文章,作者:306829225,如若转载,请注明出处:https://blog.ytso.com/252263.html

(0)
上一篇 2022年5月4日
下一篇 2022年5月4日

相关推荐

发表回复

登录后才能评论