说说 HashMap 的扩容机制?如何扩容的?

一则或许对你有用的小广告

欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于 Spring AI + Spring Boot3.x + JDK 21...点击查看;
  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot3.x + JDK 17...点击查看项目介绍; 演示链接: http://116.62.199.48:7070/;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/

面试考察点

  1. 基础掌握度:面试官不仅仅是想知道 "会扩容" 这个事实,更是想知道你是否理解扩容的触发条件、扩容阈值计算、以及扩容时元素如何重新分布。

  2. 源码理解深度:考察你是否读过 resize() 方法的源码,是否理解 JDK 1.8 中 "高低位链表" 的优化设计,以及为什么扩容后元素要么在原位置,要么在 "原位置 + 原容量" 的位置。

  3. 版本演进意识:JDK 1.7 和 JDK 1.8 的扩容机制有显著差异(头插法 vs 尾插法),考察你是否了解这些变化以及背后的原因(死循环问题)。

核心答案

HashMap 的扩容机制可以概括为:当元素数量超过 容量 × 负载因子 时,容量翻倍,并重新分配所有元素

关键点说明
默认初始容量16
默认负载因子0.75
扩容阈值capacity × loadFactor(首次为 12 = 16 × 0.75)
扩容方式容量翻倍(newCap = oldCap << 1
触发条件size > threshold 且当前桶位置非空
元素重分布根据新增的最高位判断:原位置 或 原位置 + 原容量

深度解析

一、扩容触发时机

扩容发生在调用 putVal() 方法插入元素后,满足以下两个条件时触发:

上图展示了扩容的两个必要条件。这里有几个关键点需要理解:

  • 为什么需要两个条件? 这是一个性能优化。如果当前桶为空,直接放入即可,不需要立即扩容。只有当发生哈希冲突时,才考虑扩容以减少冲突概率。

  • threshold 的计算threshold = capacity × loadFactor。默认情况下,首次扩容阈值为 12(16 × 0.75)。

  • 负载因子为什么是 0.75? 这是时间和空间的权衡:

    • 太大(如 1.0):空间利用率高,但哈希冲突多,查询效率低
    • 太小(如 0.5):哈希冲突少,查询效率高,但空间浪费
    • 0.75 是经过数学计算得出的平衡值,来自泊松分布

二、扩容核心流程

上图展示了 HashMap 扩容的完整流程。整个过程分为 5 个关键步骤:

  • 步骤一 - 计算新容量:使用位运算 oldCap << 1 实现翻倍,效率极高。容量始终保持 2 的幂次方,这是为了让 (n - 1) & hash 能够均匀分布。

  • 步骤二 - 计算新阈值:新阈值也需要翻倍,保持负载因子的约束。

  • 步骤三 - 创建新数组:在堆中分配一个新的 Node 数组,大小为新容量。

  • 步骤四 - 迁移元素:这是最核心的步骤,需要遍历旧数组的每个桶,将链表或红黑树中的元素重新分配到新数组中。

  • 步骤五 - 更新引用:将 table 指向新数组,完成扩容。

三、元素重分布原理(核心!)

这是面试中最重要的考点。JDK 1.8 中,扩容后元素的位置分布有一个巧妙的规律:

上图揭示了 JDK 1.8 扩容的核心优化。让我详细解释这个精妙的设计:

  • 为什么是两种可能? 因为容量从 2^n 变成 2^(n+1),掩码只多了一位。这一位要么是 0,要么是 1,所以元素的新位置只有两种可能。

  • 低位链表(loHead/loTail):第 n 位为 0 的元素,扩容后索引不变,组成 "低位链表"。

  • 高位链表(hiHead/hiTail):第 n 位为 1 的元素,扩容后索引 = 原索引 + 原容量,组成 "高位链表"。

  • 为什么用 (e.hash & oldCap) == 0 判断? 这里不是用 n-1,而是直接用 oldCap(如 16 = 10000)。只有 hash 的第 5 位为 0 时,hash & 16 才等于 0。

四、源码核心片段

// JDK 1.8 resize() 方法核心逻辑
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;

    // 1. 计算新容量和新阈值
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {  // 已达最大容量,不再扩容
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&  // 容量翻倍
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1;  // 阈值翻倍
    }
    // ... 省略其他分支

    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;

    // 2. 遍历旧数组,迁移元素
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;  // 帮助 GC
                if (e.next == null)
                    // 单个节点,直接计算新位置
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    // 红黑树拆分
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else {
                    // 3. 链表拆分:高低位链表
                    Node<K,V> loHead = null, loTail = null;  // 低位链表
                    Node<K,V> hiHead = null, hiTail = null;  // 高位链表
                    Node<K,V> next;
                    do {
                        next = e.next;
                        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 = next) != null);

                    // 4. 将拆分后的链表放到新数组
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;  // 低位放原位置
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;  // 高位放原位置+原容量
                    }
                }
            }
        }
    }
    return newTab;
}

五、JDK 1.7 vs JDK 1.8 扩容对比

对比项JDK 1.7JDK 1.8
链表插入方式头插法尾插法
扩容后顺序链表元素顺序反转链表元素顺序保持不变
元素位置计算重新计算 h & (length-1)高低位链表优化,无需重新计算
并发问题多线程扩容可能导致死循环死循环问题已解决
红黑树链表 ≥ 8 且容量 ≥ 64 时转红黑树

JDK 1.7 死循环问题原因

上图解释了 JDK 1.7 的经典死循环问题。关键点:

  • 头插法:新元素总是插到链表头部
  • 并发场景:两个线程同时扩容,一个暂停,另一个完成
  • 顺序反转:线程 2 完成后,链表顺序从 A→B 变成 B→A
  • 形成环:线程 1 继续执行时,基于旧的 next 指针操作,导致 A 和 B 互指
  • JDK 1.8 解决:改用尾插法,顺序保持不变,避免了这个问题

面试高频追问

  1. 为什么容量必须是 2 的幂次方?

    • (n - 1) & hash 等价于 hash % n,位运算效率更高
    • 保证散列均匀,避免空间浪费
  2. 扩容时每个元素都需要重新计算 hash 吗?

    • 不需要!只需判断新增的高位是 0 还是 1,用 (e.hash & oldCap) == 0 即可
  3. HashMap 是线程安全的吗?扩容时会有什么问题?

    • 不是线程安全的。JDK 1.7 并发扩容可能死循环,JDK 1.8 可能数据丢失
    • 推荐使用 ConcurrentHashMap
  4. 负载因子可以修改吗?什么场景需要调整?

    • 可以,构造函数可指定 loadFactor
    • 内存紧张时可调高(如 0.85),查询频繁时可调低(如 0.5)

常见面试变体

  • "HashMap 的负载因子为什么是 0.75?"
  • "JDK 1.7 和 1.8 的 HashMap 扩容有什么区别?"
  • "为什么 JDK 1.8 改用尾插法?解决了什么问题?"
  • "扩容时元素如何重新分配?为什么只有两种可能?"

记忆口诀

扩容三要素:容量翻倍、阈值翻倍、高低位分家

位置判断:新位为 0 留原地,新位为 1 加原容((hash & oldCap) == 0 → 低位链表,否则高位链表)

总结

HashMap 扩容在 size > threshold 时触发,容量翻倍(<< 1),使用高低位链表优化元素重分布——根据 hash 新增位的值,元素要么留在原位置,要么移动到 "原位置 + 原容量"。JDK 1.8 改用尾插法解决了 JDK 1.7 并发扩容的死循环问题。记住:负载因子 0.75、容量 2 的幂、高低位分家