hashmap在高并发下的死循环和数据丢失

2020-03-03  本文已影响0人  凉风拂面秋挽月

本文数据丢失和死循环均来自于jdk7版本。jdk8修复了死循环。

数据丢失

数据都是很好理解,在并发条件下,t1,t2同时向hashmap写入一个entry,且经过hash算法得到的key相同(hash冲突),此时若t1先写入,t2后写入,结果将是t2的entry覆盖掉t1的entry。

死循环

死循环问题比较复杂,主要产生死循环的场景出现在并发下同时触发数组扩容。也就是A,B同时进行Resize。当当前hashmap中的size超过数组长度*装载因子的时候,将会新建一个两倍于当前数组长度的数组,并把老数组中的元素重新放入新数组(当然需要每个entry的key重新hash确定在新数组的位置)。
t1线程添加entry4触发resize,t2线程触发entry5触发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]; // 头插法,新节点next指向该位置首节点
                newTable[i] = e; // 新元素归位
                e = next; // 指向下一个节点,继续遍历
            }
        }
    }
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);
    }

假如此时线程B遍历到Entry3对象,刚执行完Entry<K,V> next = e.next;时间片用完,线程就被挂起。对于线程B来说:
e = Entry3
next = Entry2
这时候线程A畅通无阻地进行着Rehash直接完成resize,在将entry3,entry2放入新的数组中,假设再次出现hash碰撞,由于采用头插法,最终结果应是entry2->entry3。当ReHash完成后,结果如下(图中的e和next,代表线程B的两个引用):


A,B线程图

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

 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]; // 头插法,新节点next指向该位置首节点
                newTable[i] = e; // 新元素归位
                e = next; // 指向下一个节点,继续遍历
            }
        }
    }

由于A线程的原因,entry2.next=entry3,entry3.next = null而在线程B中认为entry3.next=entry2(其实已经不是了,但此时e=entry3,next=entry2)。
我们继续往下走,e=entry3,entry3放入新数组,然后next = entry2。


image.jpg
image.jpg

接着entry2进入下一轮循环,此时e=entry2,next = entry3!(虽然看起来现在出了问题,但其实还没有)。
我们继续执行到下图这一步。


image.jpg

entry2被放入线程b的数组的元素头结点,entry3被挤下去了。


image.jpg
如果是单线程,现在就结束了,因为entry2.next=null。但由于entry2.next=entry3(线程A的锅)。导致while循环并没有结束!
entry3的循环又一次抢占头结点,把entry2挤下去,然后entry再次抢占头结点,把entry3挤下去。(因为此时形成了闭环)最终结果就是无限循环,cpu负载100%,程序崩溃。
20180211102047225.jpg

java8的改进

1.添加了红黑树,当链表长度大于8时,会将链表转为红黑树。
2.jdk7中的死循环原因来自于头插法,扩容复制后,如果在老数组中的链表hash冲突且复制到新数组后依然冲突,则会让老数组中的链表顺序倒置,导致多线程下有几率形成链表闭环。在jdk8中,扩容后,新数组中的链表顺序依然与旧数组中的链表顺序保持一致。具体JDK8是用 head 和 tail 来保证链表的顺序和之前一样(双指针保证插入顺序和链表顺序一致),这样就不会产生循环引用。也就没有死循环了。
3.虽然修复了死循环的BUG,但是HashMap 还是非线程安全类,仍然会产生数据丢失等问题。

上一篇下一篇

猜你喜欢

热点阅读