HashMap 1.8之后是为什么是由头插改为了尾插

65 阅读3分钟
  • JDK 1.7 及之前:HashMap 在桶(bin)链表里采用头插(new 节点插到链表头)。
  • JDK 1.8 起:HashMap 在普通 put扩容迁移时都采用尾插(new 节点接到链表尾部;迁移时也用“保序拆分”接到新表尾部)。

1. 1.8 之后到底怎么插?

普通插入(putVal)

逻辑要点:遍历桶链表检查是否已有同 key;如果没有,把新节点挂到尾部

for (Node<K,V> e = p;; ++binCount) {
    if (e.hash == hash && (e.key == key || key.equals(e.key))) { .../* 覆盖 */ ...; break; }
    Node<K,V> next = e.next;
    if (next == null) {           // 到尾了
        e.next = newNode(hash, key, value, null); // 尾插
        break;
    }
    e = next;
}

扩容迁移(resize)

JDK 8 不再“全部重新计算并头插”,而是把每个旧桶按 e.hash & oldCap 拆成 lo 列表hi 列表,分别 尾插 到新表的两个位置(j 和 j + oldCap),并保持原相对顺序

Node<K,V> loHead=null, loTail=null, hiHead=null, hiTail=null;
for (Node<K,V> e = oldBin; e != null; e = e.next) {
    if ((e.hash & oldCap) == 0) {        // 低位不变:落在原索引 j
        if (loTail == null) loHead = e; else loTail.next = e;
        loTail = e;
    } else {                             // 低位翻转:落到 j + oldCap
        if (hiTail == null) hiHead = e; else hiTail.next = e;
        hiTail = e;
    }
}
if (loTail != null) loTail.next = null;  // 截断
if (hiTail != null) hiTail.next = null;
newTab[j]          = loHead;             // 保序放回
newTab[j + oldCap] = hiHead;

2. 为什么从“头插”改成“尾插”?

  1. 避免扩容时链表反转,保持相对顺序更可控

    JDK 7 的迁移用头插,导致同一桶内元素顺序被反转;JDK 8 的“lo/hi 拆分 + 尾插”保留原相对顺序,让迭代/调试更稳定,也便于后续将长链表树化(转红黑树)时按既有顺序构建。

  2. 降低并发表现出的极端问题风险

    虽然 HashMap 本就非线程安全,但 1.7 时代“多线程并发扩容 + 头插”更容易在极端时产生链表成环等严重问题(典型的“死循环”事故)。1.8 的保序尾插 + 新的拆分策略减少了这种风险暴露(注意:并不等于线程安全)。

  3. 配合树化阈值与性能治理

    JDK 8 引入红黑树化(TREEIFY_THRESHOLD=8),长链表在阈值以上会转树。尾插让“新冲突元素”稳定落在尾部,链表统计和树化构建都更直观一致;再叠加保序迁移,整体行为更可预测。

  4. 插入复杂度不变(均摊 O(1))

    无论头插还是尾插,put 都必须遍历一遍桶链表去找“是否已存在同 key”,因此尾插并未增加额外渐进复杂度;而保序带来的好处更大。


3. 小结 & 注意点

  • JDK 8 之后是“尾插” :普通 put 尾插,扩容迁移也按 lo/hi 保序尾插

  • 这样做的好处:稳定顺序、降低并发极端问题风险、配合树化

  • HashMap 的整体“迭代顺序仍不保证” (它不是 LinkedHashMap),尾插只影响同一个桶内的相对顺序,不要对全表迭代顺序做任何依赖。

  • 并发仍需 ConcurrentHashMap 或外部同步;HashMap 自身没有因为尾插而变线程安全。

一句话记忆:1.7 头插,1.8 尾插;保序迁移、防反转、利于树化,是这次设计调整的核心动机。