HashMap的扩容机制 - resize() 详解

HashMap 是 Java 集合框架中使用最广泛的哈希表实现,它以高效的键值对存储和查询能力著称。在 HashMap 的核心机制中,扩容(resize) 是保证其性能的关键环节之一。当 HashMap 中的元素数量达到一定阈值时,会触发扩容操作,重新分配内存并迁移元素,以避免链表过长导致查询效率下降。本文将深入剖析 HashMap 的 resize 机制,从底层原理到源码实现,结合最佳实践,帮助读者全面理解这一核心过程。

目录#

  1. HashMap 基础回顾
    • 存储结构:数组 + 链表 + 红黑树
    • 核心参数:容量、负载因子、阈值
  2. 为什么需要扩容?
    • 链表过长的性能隐患
    • 负载因子的平衡作用
  3. resize() 的执行时机
    • put 操作触发扩容
    • 初始化时的扩容
  4. resize() 的核心流程(JDK 8)
    • 新数组的创建与容量翻倍
    • 元素迁移:链表拆分与红黑树拆分
    • 位运算优化:高效定位新桶位置
  5. 源码深度解析(JDK 8)
    • resize 方法核心代码分析
    • 链表拆分的位运算魔法
  6. 扩容过程中的线程安全问题
    • JDK 7 的头插法与死循环隐患
    • JDK 8 的尾插法优化
  7. 最佳实践
    • 初始化时指定容量
    • 合理设置负载因子
    • 多线程场景的替代方案
  8. 示例代码:演示扩容过程
  9. 总结
  10. 参考资料

HashMap 基础回顾#

在分析 resize 机制前,我们先回顾 HashMap 的核心设计:

存储结构:数组 + 链表 + 红黑树#

HashMap 的底层是一个数组(桶,bucket),每个数组元素(桶)存储一个链表或红黑树的引用:

  • 数组:初始容量为 16(或构造时指定的初始容量,必须是 2 的幂),用于定位元素的哈希桶。
  • 链表:当多个元素的哈希值映射到同一个桶时,会以链表形式存储(JDK 8 前为头插法,JDK 8 后为尾插法)。
  • 红黑树:当链表长度超过 8 且数组容量 ≥ 64 时,链表会转换为红黑树,以优化查询效率(时间复杂度从 O(n) 降至 O(log n))。

核心参数#

  • 容量(Capacity):数组的长度,始终是 2 的幂(如 16、32、64...),默认初始容量为 16。
  • 负载因子(Load Factor):默认值为 0.75,表示数组的“填充程度”阈值。公式:负载因子 = 已用容量 / 总容量。
  • 阈值(Threshold):触发扩容的临界值,公式:threshold = capacity * loadFactor。当元素数量 size ≥ threshold 时,触发扩容。

为什么需要扩容?#

1. 链表过长的性能隐患#

HashMap 的查询效率依赖于哈希桶的均匀分布。若哈希冲突过多,同一个桶下的链表会越来越长:

  • 链表查询的时间复杂度为 O(n)(需遍历链表)。
  • 当链表长度超过 8 且数组容量 ≥ 64 时,会转换为红黑树(查询复杂度 O(log n)),但红黑树的维护成本更高。

扩容后,数组容量翻倍,哈希桶的数量增加,元素会更均匀地分布到新桶中,减少链表/红黑树的长度,从而提升查询效率。

2. 负载因子的平衡作用#

负载因子是时间效率和空间效率的权衡:

  • 负载因子过小(如 0.5):数组利用率低,浪费空间,但哈希冲突少,查询快。
  • 负载因子过大(如 0.9):数组利用率高,节省空间,但哈希冲突多,链表/红黑树过长,查询慢。

Java 默认选择 0.75 作为负载因子,是基于“泊松分布”的统计结果:当负载因子为 0.75 时,链表长度超过 8 的概率仅为百万分之一,能有效平衡空间和时间。

resize() 的执行时机#

扩容操作由 resize() 方法触发,主要发生在以下场景:

1. put 操作时触发#

当调用 put(key, value) 时,会先检查是否需要扩容:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        // 初始化时扩容(第一次 put)
        n = (tab = resize()).length;
    // ... 哈希桶定位 ...
    if (++size > threshold) // 元素数量超过阈值
        resize(); // 触发扩容
    // ... 插入元素 ...
}

2. 初始化时的扩容#

  • 无参构造的 HashMap:第一次 put 时,会扩容到默认容量 16,阈值为 16 * 0.75 = 12。
  • 指定初始容量的 HashMap:构造时不会立即扩容,第一次 put 时会根据初始容量初始化数组。

resize() 的核心流程(JDK 8)#

JDK 8 中,resize() 的核心是数组扩容 + 元素迁移,并针对链表和红黑树做了优化。以下是关键步骤:

1. 新数组的创建#

扩容后,数组容量翻倍(新容量 = 原容量 × 2),并重新计算阈值:

  • 若原容量 < 最大容量(MAXIMUM_CAPACITY = 1<<30),则新容量 = 原容量 × 2。
  • 阈值更新为 newCapacity * loadFactor(或 Integer.MAX_VALUE,若超过最大容量)。

2. 元素迁移:链表与红黑树的拆分#

扩容的核心是将旧数组的元素迁移到新数组。由于新数组容量翻倍,哈希桶的数量也翻倍,元素的新位置由哈希值的高位决定(无需重新计算哈希,通过位运算优化)。

链表的拆分(核心优化)#

对于每个旧桶中的链表,JDK 8 采用**低位链(lo)和高位链(hi)**的方式拆分:

  • 低位链:元素哈希值的第 n 位(n 为旧容量的二进制最高位)为 0,新位置与旧位置相同。
  • 高位链:元素哈希值的第 n 位为 1,新位置为旧位置 + 旧容量。

位运算优化:通过 e.hash & oldCap 判断高位(旧容量是 2 的幂,二进制只有最高位为 1):

  • 若 e.hash & oldCap == 0:元素属于低位链,新位置 = 旧位置。
  • 若 e.hash & oldCap != 0:元素属于高位链,新位置 = 旧位置 + oldCap。

示例:旧容量 oldCap = 16(二进制 10000),新容量 newCap = 32(二进制 100000)。元素哈希值的第 4 位(从 0 开始)决定其新位置:

  • 第 4 位为 0 → 新位置 = 旧位置(如旧位置 3 → 新位置 3)。
  • 第 4 位为 1 → 新位置 = 旧位置 + 16(如旧位置 3 → 新位置 19)。

红黑树的拆分#

若旧桶中的元素是红黑树,则调用 TreeNode.split() 方法拆分:

  • 红黑树会根据元素哈希的高位拆分为两个子树。
  • 若拆分后子树的节点数 ≤ 6,则退化为链表(避免红黑树的维护成本)。

源码深度解析(JDK 8)#

以下是 JDK 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; // 阈值翻倍
        }
    } else if (oldThr > 0) { // 初始化时指定了初始容量(如 new HashMap(100))
        newCap = oldThr;
    } else { // 无参构造,初始化默认容量
        newCap = DEFAULT_INITIAL_CAPACITY; // 16
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 12
    }
 
    // 处理新阈值未设置的情况(如非默认初始化)
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
 
    // 2. 创建新数组
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
 
    // 3. 迁移旧数组元素
    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) { // 红黑树节点,调用 split 拆分
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                } else { // 链表节点,拆分为低位链和高位链
                    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 { // 高位链(原位置 + oldCap)
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                        e = next;
                    } while (e != null);
                    // 将拆分后的链表放入新桶
                    if (loTail != null) {
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

关键优化点#

  1. 位运算高效定位:通过 e.hash & oldCap 判断元素的新位置,无需重新计算哈希,时间复杂度 O(1)。
  2. 尾插法避免链表环:JDK 8 中链表迁移使用尾插法(按原链表顺序插入新节点),避免了 JDK 7 中头插法导致的链表环问题。
  3. 红黑树的智能拆分:拆分后若子树节点数 ≤ 6,则退化为链表,平衡维护成本和查询效率。

扩容过程中的线程安全问题#

1. JDK 7 的“链表环”问题(头插法)#

JDK 7 中,resize() 使用头插法迁移链表(新节点插入链表头部)。多线程环境下,若两个线程同时扩容,可能导致链表形成环:

  • 线程 A 和线程 B 同时处理同一个链表,迁移时互相修改节点的 next 引用,最终形成环。
  • 后续查询该链表时,会陷入死循环(next 永远不为 null)。

2. JDK 8 的优化(尾插法)#

JDK 8 改用尾插法(新节点插入链表尾部),避免了链表环的问题,但仍存在数据覆盖风险:

  • 多线程 put 时,若两个线程同时写入同一个桶,可能导致后写入的元素覆盖先写入的元素。

结论#

HashMap 本身不是线程安全的,多线程环境下应使用:

  • java.util.concurrent.ConcurrentHashMap(推荐,高效并发)。
  • Collections.synchronizedMap(new HashMap<>())(简单但性能差)。

最佳实践#

1. 初始化时指定容量#

若已知元素数量,建议在初始化时指定容量,避免多次扩容:

// 公式:initialCapacity = (int) (expectedSize / loadFactor) + 1
int expectedSize = 1000;
float loadFactor = 0.75f;
Map<String, Object> map = new HashMap<>((int) (expectedSize / loadFactor) + 1, loadFactor);

2. 合理设置负载因子#

根据业务场景调整负载因子:

  • 空间敏感:使用较大的负载因子(如 0.9),减少数组扩容次数,节省空间。
  • 时间敏感:使用较小的负载因子(如 0.5),减少哈希冲突,提升查询速度。

3. 多线程场景的替代方案#

  • 高并发读多写少:使用 ConcurrentHashMap(JDK 1.8 后性能优异)。
  • 写操作频繁:考虑 java.util.concurrent.ConcurrentSkipListMap(有序且并发安全)。

示例代码:演示扩容过程#

以下代码演示 HashMap 扩容时的容量变化、链表拆分和红黑树转换:

import java.util.HashMap;
import java.util.Map;
 
public class HashMapResizeDemo {
    public static void main(String[] args) {
        // 初始容量 4,负载因子 0.75 → 阈值 3
        Map<Integer, String> map = new HashMap<>(4, 0.75f);
 
        // 1. 插入 3 个元素,未触发扩容(size=3 < threshold=3?不,threshold=4*0.75=3 → size=3 等于阈值,put 第4个时触发)
        map.put(1, "A");
        map.put(2, "B");
        map.put(3, "C");
        System.out.println("插入3个元素后,容量:" + getCapacity(map) + ",size:" + map.size());
 
        // 2. 插入第4个元素,触发扩容(容量从4→8,阈值从3→6)
        map.put(4, "D");
        System.out.println("插入第4个元素后,容量:" + getCapacity(map) + ",size:" + map.size());
 
        // 3. 插入更多元素,观察红黑树转换(链表长度≥8且容量≥64时转换)
        for (int i = 5; i <= 100; i++) {
            map.put(i, "Val" + i);
        }
        System.out.println("插入100个元素后,容量:" + getCapacity(map) + ",size:" + map.size());
    }
 
    // 反射获取 HashMap 的容量(仅用于演示)
    private static int getCapacity(Map<?, ?> map) {
        try {
            java.lang.reflect.Field field = map.getClass().getDeclaredField("table");
            field.setAccessible(true);
            Object[] table = (Object[]) field.get(map);
            return table == null ? 0 : table.length;
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

输出(示例):

插入3个元素后,容量:4,size:3
插入第4个元素后,容量:8,size:4
插入100个元素后,容量:128,size:100

通过反射可观察到,每次扩容后容量翻倍,元素均匀分布到新桶中。

总结#

HashMap 的 resize() 机制是保证其高性能的核心:

  • 作用:通过扩容减少哈希冲突,平衡时间和空间效率。
  • 流程:容量翻倍 → 元素迁移(链表/红黑树拆分)→ 位运算优化定位。
  • 优化:JDK 8 引入尾插法、红黑树智能拆分,提升了扩容效率和线程安全。
  • 实践:初始化指定容量、合理设置负载因子、避免多线程直接使用 HashMap。

理解 resize 机制,能帮助我们在实际开发中更高效地使用 HashMap,避免性能瓶颈。

参考资料#

  1. JDK 8 源码 - HashMap.java
  2. 《Java 并发编程的艺术》(方腾飞 等)
  3. 深入理解 HashMap 扩容机制
  4. HashMap 多线程死循环问题分析