老生常谈,HashMap的死循环

作者: 美团Java | 来源:发表于2018-01-24 09:21 被阅读5421次

    占小狼 转载请注明原创出处,谢谢!

    问题

    最近的几次面试中,我都问了是否了解HashMap在并发使用时可能发生死循环,导致cpu100%,结果让我很意外,都表示不知道有这样的问题,让我意外的是面试者的工作年限都不短。

    由于HashMap并非是线程安全的,所以在高并发的情况下必然会出现问题,这是一个普遍的问题,虽然网上分析的文章很多,还是觉得有必须写一篇文章,让关注我公众号的同学能够意识到这个问题,并了解这个死循环是如何产生的。

    如果是在单线程下使用HashMap,自然是没有问题的,如果后期由于代码优化,这段逻辑引入了多线程并发执行,在一个未知的时间点,会发现CPU占用100%,居高不下,通过查看堆栈,你会惊讶的发现,线程都Hang在hashMap的get()方法上,服务重启之后,问题消失,过段时间可能又复现了。

    这是为什么?

    原因分析

    在了解来龙去脉之前,我们先看看HashMap的数据结构。

    在内部,HashMap使用一个Entry数组保存key、value数据,当一对key、value被加入时,会通过一个hash算法得到数组的下标index,算法很简单,根据key的hash值,对数组的大小取模 hash & (length-1),并把结果插入数组该位置,如果该位置上已经有元素了,就说明存在hash冲突,这样会在index位置生成链表。

    如果存在hash冲突,最惨的情况,就是所有元素都定位到同一个位置,形成一个长长的链表,这样get一个值时,最坏情况需要遍历所有节点,性能变成了O(n),所以元素的hash值算法和HashMap的初始化大小很重要。

    当插入一个新的节点时,如果不存在相同的key,则会判断当前内部元素是否已经达到阈值(默认是数组大小的0.75),如果已经达到阈值,会对数组进行扩容,也会对链表中的元素进行rehash。

    实现

    HashMap的put方法实现:

    1、判断key是否已经存在

    public V put(K key, V value) {
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);
        int i = indexFor(hash, table.length);
        // 如果key已经存在,则替换value,并返回旧值
        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;
    }
    

    2、检查容量是否达到阈值threshold

    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);
    }
    

    如果元素个数已经达到阈值,则扩容,并把原来的元素移动过去。

    3、扩容实现

    void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        ...
    
        Entry[] newTable = new Entry[newCapacity];
        ...
        transfer(newTable, rehash);
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }
    

    这里会新建一个更大的数组,并通过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;
            }
        }
    }
    

    移动的逻辑也很清晰,遍历原来table中每个位置的链表,并对每个元素进行重新hash,在新的newTable找到归宿,并插入。

    案例分析

    假设HashMap初始化大小为4,插入个3节点,不巧的是,这3个节点都hash到同一个位置,如果按照默认的负载因子的话,插入第3个节点就会扩容,为了验证效果,假设负载因子是1.

    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;
            }
        }
    }
    

    以上是节点移动的相关逻辑。

    插入第4个节点时,发生rehash,假设现在有两个线程同时进行,线程1和线程2,两个线程都会新建新的数组。

    假设 线程2 在执行到Entry<K,V> next = e.next;之后,cpu时间片用完了,这时变量e指向节点a,变量next指向节点b。

    线程1继续执行,很不巧,a、b、c节点rehash之后又是在同一个位置7,开始移动节点

    第一步,移动节点a

    第二步,移动节点b

    注意,这里的顺序是反过来的,继续移动节点c

    这个时候 线程1 的时间片用完,内部的table还没有设置成新的newTable, 线程2 开始执行,这时内部的引用关系如下:

    这时,在 线程2 中,变量e指向节点a,变量next指向节点b,开始执行循环体的剩余逻辑。

    Entry<K,V> next = e.next;
    int i = indexFor(e.hash, newCapacity);
    e.next = newTable[i];
    newTable[i] = e;
    e = next;
    

    执行之后的引用关系如下图

    执行后,变量e指向节点b,因为e不是null,则继续执行循环体,执行后的引用关系

    变量e又重新指回节点a,只能继续执行循环体,这里仔细分析下:
    1、执行完Entry<K,V> next = e.next;,目前节点a没有next,所以变量next指向null;
    2、e.next = newTable[i]; 其中 newTable[i] 指向节点b,那就是把a的next指向了节点b,这样a和b就相互引用了,形成了一个环;
    3、newTable[i] = e 把节点a放到了数组i位置;
    4、e = next; 把变量e赋值为null,因为第一步中变量next就是指向null;

    所以最终的引用关系是这样的:

    节点a和b互相引用,形成了一个环,当在数组该位置get寻找对应的key时,就发生了死循环。

    另外,如果线程2把newTable设置成到内部的table,节点c的数据就丢了,看来还有数据遗失的问题。

    总结

    所以在并发的情况,发生扩容时,可能会产生循环链表,在执行get的时候,会触发死循环,引起CPU的100%问题,所以一定要避免在并发环境下使用HashMap。

    曾经有人把这个问题报给了Sun,不过Sun不认为这是一个bug,因为在HashMap本来就不支持多线程使用,要并发就用ConcurrentHashmap。

    END。
    我是占小狼。 如果读完觉得有收获的话,记得关注和点赞

    相关文章

      网友评论

      • 后端沉思录:分析的挺不错!
      • fa33247eeb75:jdk8已经修复这个问题了,jdk8中扩容时保持了原来链表中的顺序
      • 小_滑_稽:我不知道为什么要在多线程情况下使用非线程安全的hashmap,有什么意义呢?
      • 4654ba5a7376:是因为没有人会在多线程的情况下用非线程安全的hashmap
        4654ba5a7376:最烦这样搞技术的 无视约定无视规则
      • wbo4958:分析的挺好的。

        不过,对于 “最近的几次面试中,我都问了是否了解HashMap在并发使用时可能发生死循环,导致cpu100%,结果让我很意外,都表示不知道有这样的问题,让我意外的是面试者的工作年限都不短。"

        我想说的,楼主问面试者的问题是 ”在不该用的地方用到HashMap 后产生的副作用 去分析? 这是不是强人所难??? 人家面试者在多线程中使用HashMap,都用了锁,面试者根本不会遇到所谓的100%CPU的情况呀?
        你还说对这些面试者比较意外,其实我觉得你能经常遇到这种情况,我才觉得比较意外呢?

        评论只是就事论事。 不喜勿喷。
        美团Java:@wbo4958 遇到和知道是两回事
        wbo4958:@占小狼 我没有遇到过,不过一个非线程安全的类用在多线程环境中肯定会有各种问题的啊
        美团Java:@wbo4958 我没遇过这种情况,只是之前别人的博客看的比较多,看到过这个问题,所以你是知道还是不知道?
      • 79b5f424b9aa:点赞~
        jdk1.8在扩容的情况下,将原来的链表,根据resize后的位置拆成了2个链表,拆完之后在分别放到2个位置上,所以不会出现成环的情况。。。
        79b5f424b9aa:@计冰冰 说的不是一个东西吧;你说的是 hash冲突 比较严重的情况下,将原来的“长链表”拆成“红黑树”吧?这里说的是 hashMap 死循环,发生在链表 “resize” 阶段;
        T_log:那是红黑树吧
      • 634d4c57a828:狼哥,看了您的博客,我觉的这个死循环的问题根本原因是不是因为HashMap中维护的Entry是个单向链表,导致在扩容时,新的表中存储的元素顺序跟之前的正好相反,从而产生了死循环。 另还有一处不明:文中提到的“阈值”和“负载因子”是同一个意思吗?
        634d4c57a828:@Endlesshb 明白了,负载因子越大则当前hash散列的装填程度越高,元素多了,但索引效率降低;反之,装填程度稀松则又会浪费空间。所以负载因子应该是基于空间和查询效率所做的一种平衡吧?
        Endlesshb:阀值与负载因子是两码事,不是同一个意思
      • 52570995798d:狼哥,提个小小的建议,案例分析那里,“不巧的是,三个节点都hash到同一位置”放到“以下是节点移动的相关逻辑”后面,原来容易误解为只有hash到同一节点的时候,才会触发resize。还有个问题想请教一下:由于多线程涉及工作内存与主存的关系,上述逻辑成立的前提条件是:每个线程执行完时间片会把工作内存刷新到主存,对吗?
        美团Java:@mzl9039 嗯,所以这种bug并非一定发生的
      • ba442504df91:这个问题确实是老生常谈了,百度上一搜一大把。
        但是能在面试中说清楚,确实不容易,除非事先特地记忆过。
      • Slience1008:为什么我在1.8里面找不到addEntry()方法呢
        Slience1008:@占小狼 嗯嗯,想着也是的呢,有时间希望博主把1.8整理出来哈
        美团Java:@Slience1008 这是1.7的
      • taobao::smile: 的确不算bug,只能说用错了场合。
      • 梁小无猜:狼哥,你贴的这个是1.7的,这个出现死循环是因为扩容过程新数组会和原数组有指针关系,1.8感觉不会出现了
        jacksu在简书:1.8应该是没有改变链表顺序,避免了死循环
        美团Java:@梁小无猜 嗯
      • 稻草僧:很耐看
      • 57b61c08ce33:想问下狼哥,我String当key的情况较多,string的hash算法会不会让算在同一个点的几率降低…有点害怕
        Storydo:@温柔的枭兽 不算大,hashmap构造方法可以指定容量的大小,你初始化的时候可以指定一个适当的值,测试一下性能,性能太低的话,可以考虑别的数据结构,不一定非要用hashmap
        57b61c08ce33:@Storydo 五千条的map应该不大吧
        Storydo:数据量不大的话,应该没必要担心
      • 57b61c08ce33:狼哥的文章深度可以的,了解一哈
        激情的狼王:一哈,嘎嘎

      本文标题:老生常谈,HashMap的死循环

      本文链接:https://www.haomeiwen.com/subject/pmhgoxtx.html