面试题:JDK 1.8 对 HashMap 除了红黑树还进行了哪些改动?

在 JDK 1.8 中,除了引入红黑树(用于优化链表过长时的查询效率)外,HashMap 还进行了多项重要改动,以下是主要的优化点:


1. 哈希函数的优化

  • 改进计算方式
    在 JDK 1.7 中,HashMap 的哈希值计算直接使用 key.hashCode() 的值。而在 JDK 1.8 中,通过 扰动函数 对哈希值进行二次处理:
  static final int hash(Object key) {
      int h;
      return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
  }
  • 目的:将哈希值的高位参与低位计算,使得哈希值分布更均匀,减少哈希冲突。
  • 效果:降低链表过长的概率,提升性能。

2. 扩容机制优化

  • 减少重新计算哈希值的次数
    在 JDK 1.8 中,扩容时不再对每个元素重新计算哈希值,而是通过 原数组长度的高位 判断元素在新数组中的位置:
  • 如果 (e.hash & oldCap) == 0,则留在原位置;
  • 否则,迁移到 oldIndex + oldCap 的位置。
  • 原理:由于数组长度是2的幂次,新数组的长度是旧数组的2倍,因此只需判断高位即可确定新位置。
  • 效果:显著提升扩容效率,减少计算开销。

3. 链表转红黑树的条件调整

  • 触发条件
    当链表长度超过 8 且数组长度大于等于 64 时,链表会转换为红黑树;否则,优先扩容。
  • 反向转换:当红黑树节点数小于 6 时,会重新转为链表。
  • 目的
    • 避免在数组较小时频繁树化/反树化,减少额外开销。
    • 在极端哈希冲突场景下(如恶意构造攻击),将查询时间复杂度从 O(n) 优化到 O(log n)

4. 初始化方式的优化(懒惰初始化)

  • JDK 1.7:在创建 HashMap 时会立即初始化数组(默认容量为16)。
  • JDK 1.8:延迟初始化数组,在第一次插入元素时才分配数组空间
  • 优点:减少内存浪费,尤其适合初始化后未立即使用的场景。

5. 头插法 → 尾插法

  • JDK 1.7:使用 头插法 插入链表节点(新节点插入到链表头部)。
  • JDK 1.8:改为 尾插法(新节点插入到链表尾部)。
  • 目的:解决多线程扩容时可能出现的 环形链表死循环 问题。
  • 背景:JDK 1.7 多线程扩容时,头插法可能导致链表逆序,进而形成环形链表,引发死循环或 ConcurrentModificationException

6. 并发性支持增强

  • 细粒度锁机制
    虽然 HashMap 本身不是线程安全的,但在某些并发场景下(如 put 操作),JDK 1.8 通过 仅锁定特定桶 而非整个表,提高了并发性能。
  • 局限性HashMap 仍不建议在多线程环境下直接使用,需配合 Collections.synchronizedMapConcurrentHashMap

7. 其他细节优化

  • 键为 null 的处理
    • JDK 1.7 中,null 键会直接存放在 table[0]
    • JDK 1.8 中,null 键的哈希值为 0,与普通键一样通过 (n - 1) & hash 计算索引,逻辑更统一。
  • 链表插入顺序
    JDK 1.8 的尾插法保持了插入顺序的稳定性,避免了 JDK 1.7 头插法导致的链表逆序问题。

总结:JDK 1.8 对 HashMap 的改动对比

特性JDK 1.7JDK 1.8
数据结构数组 + 链表数组 + 链表 + 红黑树
哈希函数直接使用 hashCode()扰动函数混合高低位
扩容机制逐个重新计算哈希值根据原数组高位快速定位新位置
链表转红黑树超过阈值(8)且数组长度 ≥ 64 时转换
插入方式头插法尾插法
初始化方式立即初始化数组懒惰初始化(首次插入时分配数组)
多线程安全性多线程扩容可能形成环形链表通过尾插法避免环形链表问题

实际影响

  • 性能提升:在高并发和哈希冲突严重的情况下,HashMap 的查询和插入效率显著提高。
  • 内存优化:延迟初始化和更均匀的哈希分布减少了内存浪费。
  • 健壮性增强:通过尾插法和红黑树优化,避免了多线程下的死循环风险。

这些改动使 HashMap 在 JDK 1.8 中成为更高效、稳定的数据结构,适用于更广泛的场景。

THE END
喜欢就支持一下吧
点赞15 分享