HashMap 扩容机制与 Rehash 细节分析

HashMap 扩容机制与 Rehash 细节分析

HashMap 是 Java 中常用的键值对存储结构,其底层基于数组加链表(JDK 1.8 后引入红黑树)的实现。为了应对元素数量增加,HashMap 提供了动态扩容机制。本文将深入分析其扩容过程及 rehash 的细节,并对比 JDK 1.7 和 JDK 1.8 的不同实现。

一、扩容机制概述

HashMap 的容量(capacity)是指底层数组的大小,默认初始值为 16。当元素数量超过某个阈值(threshold = capacity × loadFactor,默认负载因子为 0.75)时,HashMap 会触发扩容,将数组容量翻倍(例如从 16 变为 32),并重新分配所有已有元素到新数组中,这个过程称为 rehash。

扩容的核心步骤:

  1. 创建新数组:新数组大小为旧数组的两倍。
  2. rehash:将旧数组中的每个元素重新计算哈希值,映射到新数组的位置。
  3. 转移数据:将旧数组中的链表(或红黑树)迁移到新数组。

二、JDK 1.7 的扩容与 rehash

在 JDK 1.7 中,HashMap 的实现相对简单,底层仅使用数组加链表。

扩容过程

当 put 操作导致元素数量超过阈值时,调用 resize() 方法:

  • 新数组大小为旧数组的 2 倍。
  • 调用 transfer() 方法,将旧数组的元素转移到新数组。

rehash 细节

JDK 1.7 的 rehash 使用的是头插法,具体步骤如下:

  1. 对每个键的 hash 值与新数组长度减 1(newCapacity - 1)进行位与运算,计算新位置。
    • 公式:index = hash & (newCapacity - 1)
  2. 将旧链表的节点逐个取出,按计算出的新位置插入新数组对应桶中。
  3. 使用头插法插入,即新节点成为链表的头部,原链表接在其后。

问题:链表逆序与死循环

  • 链表逆序:由于头插法,转移后链表顺序会与原来相反。例如,原链表为 A -> B -> C,转移后变为 C -> B -> A。
  • 多线程隐患:在并发环境下,头插法可能导致链表形成环。例如,线程 1 扩容到一半时,线程 2 也开始扩容,可能出现节点互相指向,形成死循环。这是 JDK 1.7 HashMap 非线程安全的一个著名问题。

三、JDK 1.8 的扩容与 rehash

JDK 1.8 对 HashMap 进行了优化,引入红黑树(当链表长度超过 8 时转换),并改进了扩容和 rehash 的实现。

扩容过程

扩容依然通过 resize() 方法完成:

  • 新数组大小为旧数组的 2 倍。
  • 根据元素类型(链表或红黑树)分别处理转移逻辑。

rehash 细节

JDK 1.8 的 rehash 摒弃了头插法,改为尾插法,并利用了容量翻倍的特性优化计算:

  1. 位置计算优化
    • 容量翻倍后,新数组的索引计算基于一个巧妙的规律:对于旧数组中的每个键,其在新数组中的位置要么保持不变,要么加上旧数组容量(oldCap)。
    • 判断方法:检查 hash 值与 oldCap 的位与结果(hash & oldCap)。
      • 如果结果为 0,新位置与旧位置相同。
      • 如果结果为 oldCap,新位置 = 旧位置 + oldCap。
  2. 链表拆分
    • 将旧链表拆分为两条链表:低位链表(loHead)和高位链表(hiHead)。
    • 低位链表留在原位置,高位链表移到新位置。
    • 使用尾插法保持链表顺序不变。例如,原链表 A -> B -> C,转移后仍是 A -> B -> C。
  3. 红黑树处理
    • 如果桶中是红黑树,先将其拆分为链表,再按链表逻辑拆分,最后在新位置判断是否转换回红黑树。

代码示例(简化版)

java 复制代码
// JDK 1.8 resize 方法中的核心逻辑
Node<K,V>[] oldTab = table;
int oldCap = oldTab.length;
int newCap = oldCap << 1; // 容量翻倍
Node<K,V>[] newTab = new Node[newCap];

for (int j = 0; j < oldCap; j++) {
    Node<K,V> e = oldTab[j];
    if (e != null) {
        oldTab[j] = null;
        if (e.next == null) { // 单个节点
            newTab[e.hash & (newCap - 1)] = e;
        } else { // 链表或红黑树
            Node<K,V> loHead = null, loTail = null;
            Node<K,V> hiHead = null, hiTail = null;
            do {
                if ((e.hash & oldCap) == 0) { // 低位
                    if (loTail == null) loHead = e;
                    else loTail.next = e;
                    loTail = e;
                } else { // 高位
                    if (hiTail == null) hiHead = e;
                    else hiTail.next = e;
                    hiTail = e;
                }
            } while ((e = e.next) != null);
            if (loTail != null) {
                loTail.next = null;
                newTab[j] = loHead;
            }
            if (hiTail != null) {
                hiTail.next = null;
                newTab[j + oldCap] = hiHead;
            }
        }
    }
}

优势

  • 顺序保持:尾插法避免了链表逆序。
  • 线程安全改进:虽然 HashMap 仍非线程安全,但消除了死循环风险。
  • 性能优化:利用位运算和容量翻倍特性,减少了重复计算。

四、JDK 1.7 与 JDK 1.8 的对比

特性 JDK 1.7 JDK 1.8
数据结构 数组 + 链表 数组 + 链表 + 红黑树
插入方式 头插法 尾插法
rehash 计算 每次重新计算 利用 oldCap 优化
链表顺序 逆序 保持原序
并发风险 可能死循环 无死循环风险

五、总结

HashMap 的扩容机制和 rehash 细节在 JDK 1.7 和 JDK 1.8 中有显著差异。JDK 1.7 的头插法简单但存在逆序和并发死循环问题,而 JDK 1.8 通过尾插法和位运算优化,不仅提高了性能,还增强了稳定性。理解这些细节有助于开发者在实际应用中更好地使用 HashMap,并避免潜在的坑点。

如果你对 HashMap 的其他特性(如红黑树转换、哈希冲突)感兴趣,欢迎进一步探讨!

相关推荐
洛小豆4 分钟前
在Java中Exception 和 Error 有什么区别?
java·后端·面试
寻梦人121382 小时前
关于在Spring Boot + SpringSecurity工程中Sercurity上下文对象无法传递至新线程的问题解决
java·spring boot·后端
来自星星的坤8 小时前
SpringBoot 与 Vue3 实现前后端互联全解析
后端·ajax·前端框架·vue·springboot
AUGENSTERN_dc8 小时前
RaabitMQ 快速入门
java·后端·rabbitmq
烛阴9 小时前
零基础必看!Express 项目 .env 配置,开发、测试、生产环境轻松搞定!
javascript·后端·express
燃星cro9 小时前
参照Spring Boot后端框架实现序列化工具类
java·spring boot·后端
追逐时光者12 小时前
C#/.NET/.NET Core拾遗补漏合集(25年4月更新)
后端·.net
FG.12 小时前
GO语言入门
开发语言·后端·golang
转转技术团队13 小时前
加Log就卡?不加Log就瞎?”——这个插件治好了我的精神
java·后端
谦行13 小时前
前端视角 Java Web 入门手册 5.5:真实世界 Web 开发——控制反转与 @Autowired
java·后端