• HashMap扩容死循环问题解析


    一、问题和背景

    昨天面试腾讯被问到了HashMap为什么线程不安全,多线程下会有哪些线程不安全的情况,记忆中隐约记得有个扩容链表成环的问题,但是问到为什么,怎么解决的,JDK1.8对这个问题有做出相关优化吗,gg了,不会。为自己点了一首凉凉。

    二、源码解读

    今天特意上网搜了一下答案,看到两篇博客觉得写得很有道理,深入浅出HashMap扩容死循环问题 和 JDK1.7和JDK1.8中HashMap为什么是线程不安全的?下面记录了一下学习过程和自己的理解。
    当插入一个新的键值对时,会先根据 key 对 HashMap底层数组长度取模,得到键值对应该存放的数组下标,然后调用 addEntry()函数把这个键值对插入到这个下标所在的链表中
    void addEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        if (size++ >= threshold)        // 如果键值对个数超过了HashMap当前容量的阈值
            resize(2 * table.length);    // 调用resize()函数进行扩容
    }

    在这个 addEntry() 函数中,会判断键值对个数是否超过了HashMap当前容量的阈值,如果超过了,则说明需要扩容,接下来就调用 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);        // 把老数组中的所有键值对都拷贝到新数组中
        table = newTable;        // 修改老数组的指向,把老数组指向新数组,完成扩容
        threshold = (int)(newCapacity * loadFactor);
    }
    resize()函数会先创建一个新数组,然后调用 transfer() 函数把老数组中的所有键值对都拷贝到新数组中,最后修改老数组的指向,把老数组指向新数组,完成扩容。
    扩容过程中会出现循环链表的情况就是多个线程在执行 transfer() 函数导致的,下面看看 transfer() 函数的代码
    void transfer(Entry[] newTable) {
        Entry[] src = table;        // 老数组
        int newCapacity = newTable.length;     // 新数组的长度
        for (int j = 0; j < src.length; j++) // 遍历老数组,把老数组中所有键值对拷贝到新数组
            Entry<K,V> e = src[j];    // 记录下老数组第 j 个链表,接下来会链表上的键值对都拷贝到新数组
            if (e != null) {        // 如果链表不为空才需要拷贝
                src[j] = null;        // 先老数组第j个链表置为空链表
                do {                // 循环遍历刚才记录下来的链表,把所有键值对都采用头插法插入到新数组对应链表
                    Entry<K,V> next = e.next;        // 记录下当前结点的下个结点
                    int i = indexFor(e.hash, newCapacity);    // 求出该键值对在新数组的下标,即该键值对应该被插入到新数组第几个链表
                    e.next = newTable[i];    // 把结点的next指针指向新数组的第i个链表头结点
                    newTable[i] = e;    // 新数组第i个链表的头结点前移,指向当前结点
                    e = next;        // 把指向当前结点的指针后移
                } while (e != null);
            }
        }
    }
    其中最关键的就是其中的 do while()循环,这里面就是会发生循环链表的代码。下面再贴一遍代码
    do {                // 循环遍历刚才记录下来的链表,把所有键值对都采用头插法插入到新数组对应链表
        Entry<K,V> next = e.next;        // 记录下当前结点的下个结点
        int i = indexFor(e.hash, newCapacity);    // 求出该键值对在新数组的下标,即该键值对应该被插入到新数组第几个链表
        e.next = newTable[i];    // 把结点的next指针指向新数组的第i个链表头结点
        newTable[i] = e;    // 新数组第i个链表的头结点前移,指向当前结点
        e = next;        // 把指向当前结点的指针后移
    } while (e != null);
    现在先走一遍正常扩容的流程,假设有下面这个HashMap, 假设数组大小为2

    现在需要对它进行扩容,扩容后数组大小为原来的两倍,创建一个大小为4的数组
    假设a、b两个数扩容后刚好又hash冲突了,即又在同一个链表中,所在下标为3;c在下标为1的链表中。下面开始扩容。
    e指针指向了老数组的第1个链表

    执行上面的do while循环,第一轮循环:

    第二轮循环:

    第三轮也是最后一轮循环,前面已经假设结点 c 将在新数组中的第二个链表

    至此,老数组中的健值对已全部拷贝到新数组中

    多线程环境中扩容

    假设在第 二 次循环中的第二步(执行完e.next = newTable[i];)后当前线程的时间片刚好用完了,当前线程被挂起,这时刚好又有一个线程 P2 也来执行扩容操作,它并不会从第二步开始执行,而是重新从第一步开始执行,加入新线程后的扩容图为
    可以看到,线程2扩容之后的newTable中的单链表形成了一个环,后续执行get操作的时候,会触发死循环,引起CPU的100%问题。

    四.总结

    通过解读HashMap源码并结合实例可以发现,HashMap扩容导致死循环的主要原因在于扩容过程中使用头插法将oldTable中的单链表中的节点插入到newTable的单链表中,所以newTable中的单链表会倒置oldTable中的单链表。那么在多个线程同时扩容的情况下就可能导致扩容后的HashMap中存在一个有环的单链表,从而导致后续执行get操作的时候,会触发死循环,引起CPU的100%问题。所以一定要避免在并发环境下使用HashMap。

    再次回到面试回忆中

    后面还问到除了成环,线程不安全还会导致什么情况发生,也不会,再次gg, 此时,不禁想问:您看我还有机会吗?
    没机会了,今天官网状态已经变灰了,彻底凉凉了。这真是个让人感到悲伤的故事。

    参考:

    Cyc2018
  • 相关阅读:
    Cannot retrieve the latest commit at this time.
    Github上搭建个人博客记录
    ESPCN超分辨率汇总
    ESPCN处理彩色图像代码
    配置caffe过程中,生成解决方案出错。无法打开包括文件: “gpu/mxGPUArray.h”
    未定义变量 "caffe" 或类 "caffe.reset_all"
    windows下caffe安装配置、matlab接口
    Python3:pyecharts数据可视化插件
    Django:用户登录实例
    Django:提交表单时遇到403错误:CSRF verification failed
  • 原文地址:https://www.cnblogs.com/hi3254014978/p/14122731.html
Copyright © 2020-2023  润新知