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 的其他特性(如红黑树转换、哈希冲突)感兴趣,欢迎进一步探讨!

相关推荐
uzong1 天前
软件工程师应该关注的几种 UML 图
后端
上进小菜猪1 天前
基于 YOLOv8 的 100 类中药材智能识别实战 [目标检测完整源码]
后端
码事漫谈1 天前
AI 技能工程入门:从独立能力到协作生态
后端
码事漫谈1 天前
构建高并发AI服务网关:C++与gRPC的工程实践
后端
颜酱1 天前
前端必备动态规划的10道经典题目
前端·后端·算法
半夏知半秋1 天前
rust学习-闭包
开发语言·笔记·后端·学习·rust
LucianaiB1 天前
【保姆级教程】10分钟把手机变成AI Agent:自动刷课、回消息,学不会我“退网”!
后端
Mr -老鬼1 天前
功能需求对前后端技术选型的横向建议
开发语言·前端·后端·前端框架
IT=>小脑虎1 天前
Go语言零基础小白学习知识点【基础版详解】
开发语言·后端·学习·golang
源代码•宸1 天前
Golang语法进阶(并发概述、Goroutine、Channel)
服务器·开发语言·后端·算法·golang·channel·goroutine