JDK1.8 后 HashMap 由头插法改为了尾插法,为什么?

0 阅读3分钟

𝙞𝙋𝙖𝙙|电脑横屏壁纸 𝙞𝙋𝙖𝙙|电脑横屏_5_小土豆的旷野~_来自小红书网页版.jpg

JDK 1.8 将 HashMap 的链表插入方式从​​头插法​​改为​​尾插法​​,主要是为了解决头插法在多线程扩容时可能导致的​​环形链表死循环问题​​,同时为红黑树优化提供支持。

 ​​一、头插法在 JDK 1.7 中的问题(环形链表形成机制)​

​1. 头插法的操作逻辑​

  • 当发生哈希冲突时,新节点插入到链表的​​头部​​(即 table[i] 位置)。

  • 代码示例(JDK 1.7 源码):

    void transfer(Entry[] newTable) {
        for (Entry<K,V> e : table) {
            while (e != null) {
                Entry<K,V> next = e.next;  // 记录下一个节点
                int i = indexFor(e.hash, newTable.length);
                e.next = newTable[i];     // 新节点指向链表头部
                newTable[i] = e;          // 更新链表头为新节点
                e = next;                 // 移动到下一个节点
            }
        }
    }
    

​2. 多线程扩容导致死循环(图示)​

假设两个线程同时触发扩容,且操作同一个链表(例如链表 A → B):

  • ​线程1​​:执行到 Entry<K,V> next = e.next; 后挂起(此时 e = A, next = B)。

  • ​线程2​​:完成扩容,新链表变为 B → A(头插法反转了顺序)。

  • ​线程1恢复执行​​:

    • 将 A 插入新链表头部,此时 newTable[i] = A
    • A.next 指向 newTable[i](即 B),形成 A → B
    • 但线程2扩容后 B.next 指向 A(因为原链表已反转),形成 A ⇄ B 的​​环形链表​​(见下图)。
graph LR
    subgraph 线程2扩容后
        B[B] --> A[A]
    end
    subgraph 线程1恢复后
        A[A] --> B[B]
        B[B] --> A[A]
    end

​3. 后果​

  • A 和 B 形成了环形后,线程1 的扩容就已经陷入了死循环

  • 就算完成了扩容,后续调用 get() 或 put() 时,若命中此链表,也会因环形引用导致​​死循环​​和 ​​CPU 100%​​ 问题。


🔄 ​​二、JDK 1.8 尾插法的改进​

​1. 操作逻辑​

  • 新节点插入到链表​​尾部​​(遍历至链表末尾再插入)。

  • 代码示例(JDK 1.8 源码):

    for (Node<K,V> e : tab) {
        while (e != null) {
            Node<K,V> next = e.next;
            if ((e.hash & oldCap) == 0) {
                // 插入到低位链表尾部
            } else {
                // 插入到高位链表尾部
            }
            e = next;
        }
    }
    

​2. 解决环形链表问题​

  • ​尾插法保持链表顺序不变​​(例如 A → B 扩容后仍是 A → B),避免多线程反转链表。
  • 即使多线程并发扩容,也​​不会形成环形结构​​(尾部插入不改变节点间的原有指向)。

⚙️ ​​三、尾插法的其他优势​

  1. ​支持红黑树转换​

    • JDK 1.8 引入红黑树优化长链表查询(链表 ≥8 且数组 ≥64 时树化)。
    • 尾插法需​​遍历链表统计长度​​,便于触发树化检查;头插法无法直接获取长度。
  2. ​扩容性能优化​

    • JDK 1.8 采用​​高低位拆分策略​​:通过 (e.hash & oldCap) == 0 判断节点位置(原索引或原索引+旧容量)。
    • 尾插法结合此策略,只需按顺序拆分链表到高位或低位桶,​​无需重计算哈希值​​(见下图)。
graph LR
    subgraph 旧数组
        A[A - 索引0] --> B[B - 索引0]
    end
    subgraph 新数组
        A1[A - 索引0] 
        B1[B - 索引16] 
    end
  1. ​数据顺序保留​

    • 尾插法保持插入顺序,便于调试和按序遍历(如 LinkedHashMap 的基础)。

💎 ​​四、总结​

​对比项​​头插法(JDK 1.7)​​尾插法(JDK 1.8)​
​插入位置​链表头部链表尾部
​多线程安全​导致环形链表死循环避免环形链表
​扩容性能​需反转链表,开销大高低位拆分,无需重哈希
​红黑树支持​无法统计链表长度便于触发树化检查
​数据顺序​倒序插入,遍历混乱保持插入顺序

💡 ​​核心结论​​:JDK 1.8 改用尾插法,主要解决了头插法在并发扩容时的​​致命死循环问题​​,同时为红黑树优化和高效扩容提供了基础。但需注意:​​HashMap 仍是非线程安全的​​,多线程场景应改用 ConcurrentHashMap