菜单

Administrator
发布于 2026-05-18 / 1 阅读
0
0

HashMap 扩容问题详解

HashMap 扩容时出现的各种问题及解决方案

一、前言

HashMap 是 Java 中最常用的集合类之一,它基于哈希表实现,提供了 O(1) 时间复杂度的键值对存取。然而,HashMap 的扩容机制是其设计的核心难点,也是各种并发问题的根源。本文将深入分析 HashMap 的扩容机制,对比 JDK 7 和 JDK 8 的实现差异,并重点剖析并发场景下扩容导致的问题(死循环、数据丢失等)以及如何避免。


二、HashMap 基础结构

2.1 数据结构

HashMap 底层采用 数组 + 链表(或红黑树) 的混合结构:

  • 数组(table):每个元素称为桶(bucket)或槽(slot)
  • 链表:解决哈希冲突,多个 key 落在同一桶时,以链表形式存储
  • 红黑树(JDK 8+):当链表长度超过阈值(默认 8)时,转换为红黑树以提升查找性能
// JDK 8 核心字段
public class HashMap<K,V> extends AbstractMap<K,V> {
    // 底层数组,长度始终为 2 的幂
    transient Node<K,V>[] table;
    
    // 阈值 = capacity * loadFactor,当 size > threshold 时触发扩容
    int threshold;
    
    // 负载因子,默认 0.75
    final float loadFactor;
    
    // 链表树化的阈值,默认 8
    static final int TREEIFY_THRESHOLD = 8;
    
    // 树退化为链表的阈值,默认 6
    static final int UNTREEIFY_THRESHOLD = 6;
    
    // 树化时数组的最小长度,默认 64
    static final int MIN_TREEIFY_CAPACITY = 64;
}

2.2 扩容机制概览

HashMap 中存储的元素数量(size)超过 thresholdcapacity × loadFactor)时,会触发 resize(扩容) 操作。扩容过程如下:

  1. 新建一个容量为原来 2 倍 的新数组
  2. 将原数组中的所有元素重新计算哈希索引,迁移到新数组
  3. 更新 threshold 为新容量 × loadFactor

三、JDK 7 的扩容实现

3.1 transfer() 方法

JDK 7 的扩容核心在 transfer() 方法中,它遍历每个桶中的链表,使用头插法将节点迁移到新数组:

// JDK 7 HashMap.transfer() — 简化伪代码
void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {        // 遍历每个桶
        while (null != e) {              // 遍历链表
            Entry<K,V> next = e.next;    // ① 保存下一个节点
            int i = indexFor(e.hash, newCapacity); // 计算新索引
            e.next = newTable[i];        // ② 头插法:e.next 指向桶的当前头节点
            newTable[i] = e;             // ③ e 成为新的头节点
            e = next;                    // ④ 处理下一个节点
        }
    }
}

3.2 头插法的特点

头插法意味着在迁移链表时,会将原链表的节点依次插入到新桶的头部,这会导致 链表反转

原链表:A → B → C
迁移过程(头插):
  step1: newTable[i] = A → null
  step2: newTable[i] = B → A → null
  step3: newTable[i] = C → B → A → null
最终链表:C → B → A(反转了!)

3.3 扩容阈值计算

// JDK 7 构造函数
public HashMap(int initialCapacity, float loadFactor) {
    // 找到大于等于 initialCapacity 的最小的 2 的幂
    int capacity = 1;
    while (capacity < initialCapacity)
        capacity <<= 1;
    this.loadFactor = loadFactor;
    threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY);
}

四、JDK 8 的扩容实现 — 重要改进

4.1 resize() 方法

JDK 8 对扩容进行了重大重构,使用 尾插法(或更准确地说是拆分链表),并且引入了红黑树的处理:

// JDK 8 HashMap.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;
        }
        // 容量翻倍
        newCap = oldCap << 1;
        newThr = oldThr << 1;   // 阈值也翻倍
    } else if (oldThr > 0) {
        newCap = oldThr;
    } else {
        newCap = DEFAULT_INITIAL_CAPACITY;    // 16
        newThr = (int)(newCap * DEFAULT_LOAD_FACTOR); // 12
    }
    
    // 2. 创建新数组
    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 = oldTab[j];
            if (e != null) {
                oldTab[j] = null;  // 帮助 GC
                if (e.next == null) {
                    // 情况1:只有一个节点,直接计算新索引
                    newTab[e.hash & (newCap - 1)] = e;
                } else if (e instanceof TreeNode) {
                    // 情况2:红黑树节点 → 拆分红黑树
                    ((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;
                        // 关键:利用 (e.hash & oldCap) == 0 判断是否移动
                        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) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

4.2 低位 / 高位拆分原理

JDK 8 利用容量是 2 的幂 这个特性,通过 (e.hash & oldCap) 判断节点在新数组中的位置:

e.hash & oldCap 含义 新索引
== 0 低位 原索引 j
== 1 高位 原索引 j + oldCap

为什么可以这样判断?

因为扩容后数组长度翻倍,哈希值对应的掩码多了一位。oldCap 正好是新增的最高位的权值。例如旧容量 16(二进制 10000),新容量 32(二进制 100000),e.hash & oldCap 就是在检查哈希值的第 5 位是否为 1。

旧索引计算:e.hash & (16 - 1) = e.hash & 1111
新索引计算:e.hash & (32 - 1) = e.hash & 11111
            = e.hash & 01111  |  e.hash & 10000
            = (原索引)         |  (oldCap 位)

4.3 使用尾插法避免死循环

JDK 8 使用 尾插法(维护 loTail/hiTail 指针),保持了链表原有的顺序,不会产生反转。这从根本上避免了 JDK 7 头插法在并发时产生的循环链表问题。


五、JDK 7 vs JDK 8 扩容对比总结

对比项 JDK 7 JDK 8
插入方式 头插法(链表反转) 尾插法(保持顺序)
数据迁移 遍历链表逐个 rehash 拆分低位/高位链表
树化 ❌ 仅链表 ✅ 链表长度 ≥ 8 转红黑树
并发安全 ❌ 死循环、数据丢失 ❌ 仍会数据丢失/覆盖
rehash 计算 hash & (newCap - 1) (e.hash & oldCap) == 0 判断
扩容后顺序 反转 不变(低位)/ 后移(高位)

六、树化(Treeification)机制

6.1 为什么需要树化?

当哈希冲突严重时,链表长度会变得很长,查找时间复杂度退化为 O(n)。红黑树可以将查找时间复杂度降低到 O(log n)。

6.2 树化条件

// 两个条件必须同时满足:
// 条件1:链表长度 ≥ TREEIFY_THRESHOLD(默认 8)
// 条件2:数组容量 ≥ MIN_TREEIFY_CAPACITY(默认 64)
if (binCount >= TREEIFY_THRESHOLD - 1)  // binCount 从 0 开始
    treeifyBin(tab, hash);

为什么选择 8? 官方注释给出了解释:在随机哈希码下,链表长度服从泊松分布,长度为 8 的概率极小(约 0.00000006)。选择 8 作为阈值是在时间和空间上权衡的结果。

6.3 扩容时红黑树的处理

JDK 8 中,如果某个桶是红黑树结构,扩容时会调用 split() 方法将红黑树拆分为低位和高位两部分。如果拆分后的子树长度小于 UNTREEIFY_THRESHOLD(6),会退化为链表。

// TreeNode.split() — 简化逻辑
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
    TreeNode<K,V> loHead = null, loTail = null;  // 低位树
    TreeNode<K,V> hiHead = null, hiTail = null;  // 高位树
    
    // 遍历红黑树节点,根据 (e.hash & bit) 拆分为两个双向链表
    // ...
    
    // 如果低位头节点不为空
    if (loHead != null) {
        if (lc <= UNTREEIFY_THRESHOLD)  // 长度 ≤ 6,退化为链表
            tab[index] = loHead.untreeify(map);
        else
            tab[index] = loHead;
    }
    // 高位同理
}

七、并发扩容问题详解

7.1 JDK 7 并发死循环 — 经典 Bug

这是 Java 社区最著名的并发 Bug 之一,由 头插法 + 多线程并发 resize 导致。

场景:两个线程 A 和 B 同时触发扩容,操作同一个链表。

发生步骤

  1. 线程 B 在 transfer() 中执行到 Entry<K,V> next = e.next; 后被挂起
  2. 线程 A 完成完整的扩容,链表被反转
  3. 线程 B 恢复执行,使用已经反转的链表继续头插法迁移
  4. 形成循环链表:节点 A.next = B, B.next = A
  5. 后续对 HashMap 的任何 get/put 操作都可能陷入无限循环
初始链表(桶中):A → B → null

线程A 完成扩容后(头插法反转):C → B → A → null

线程B 继续执行时的引用状态:
  e = A (原头)
  next = B (原 A.next)
  
但此时链表已经被线程A反转了,B 变成了:
  next = B
  e = A (此时 A.next 指向 B?还是 null?取决于时序)
  
在这种混乱状态下,可能出现:
  A.next = B, B.next = A → 循环链表!

实际案例复现(经典 JDK 7 代码):

// JDK 7 下运行 — 可能触发死循环
final HashMap<Integer, String> map = new HashMap<>(2);
Thread t1 = new Thread(() -> {
    for (int i = 0; i < 10000; i++) {
        map.put(i, String.valueOf(i));
    }
});
Thread t2 = new Thread(() -> {
    for (int i = 10000; i < 20000; i++) {
        map.put(i, String.valueOf(i));
    }
});
t1.start();
t2.start();
t1.join();
t2.join();
// 此时 HashMap 内部可能已经形成循环链表
// 调用 map.get(key) 很可能导致 CPU 100% 死循环

7.2 JDK 8 的改进与残留问题

JDK 8 虽然通过尾插法解决了死循环问题,但并发场景下仍然存在数据丢失和覆盖的问题

问题 1:数据覆盖

// JDK 8 HashMap.putVal() — 并发场景
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)
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);  // ① 两个线程同时走到这里!
    else {
        // ...
    }
}

当两个线程同时判断 tab[i] 为 null,并同时执行 newNode 赋值,后赋值的会覆盖先赋值的,导致数据丢失。

问题 2:扩容时数据丢失

并发 resize 时,多个线程同时创建新数组并进行数据迁移。线程 A 迁移完成后赋值给 table,线程 B 随后也完成迁移,覆盖掉线程 A 的结果。更严重的是,线程 B 使用的可能是旧数组的引用,导致部分数据永久丢失。

// resize() 最后 — 并发覆盖
table = newTab;  // 线程A: table = newTab_A
                  // 线程B: table = newTab_B  ← 覆盖了 A 的结果

7.3 JDK 8 中仍然可能存在的死循环

虽然 JDK 8 解决了经典的 resize 死循环,但在 红黑树操作 中仍然可能存在死循环(虽然概率极低):

// TreeNode 在并发 put 时可能破坏红黑树结构
// 导致 treeify() 或 balanceInsertion() 进入无限循环
// 这是 JDK 8 中已知的 Bug — JDK-8175105 等

实际上,JDK 8 中 HashMap.computeIfAbsent 也曾报告过死循环问题(JDK-8071667),在并发场景下 TreeNode 的树化过程可能出现环形父节点引用。


八、如何避免 HashMap 的并发问题

方案一:使用 ConcurrentHashMap(推荐)

ConcurrentHashMap 是专为并发设计的哈希表实现,内部通过 分段锁(Segment,JDK 7)或 CAS + synchronized(JDK 8+)保证线程安全。

// JDK 8 ConcurrentHashMap 推荐
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();

// 并发安全,性能优秀
map.put("key", "value");
map.get("key");

方案二:使用 Collections.synchronizedMap()

返回一个线程安全的包装类,所有方法都通过 synchronized 块同步:

Map<String, String> map = Collections.synchronizedMap(new HashMap<>());

// 注意:迭代时需要外层同步
synchronized (map) {
    for (Map.Entry<String, String> entry : map.entrySet()) {
        // ...
    }
}

方案三:使用 HashTable(不推荐)

HashTable 是线程安全的,但所有方法都加 synchronized,性能较差,已经不推荐使用。

方案四:预先设置合理容量避免扩容

// 如果已知数据量,预先指定容量避免频繁扩容
int expectedSize = 10000;
// 避免扩容:capacity = expectedSize / loadFactor + 1
int capacity = (int) (expectedSize / 0.75f) + 1;
HashMap<String, String> map = new HashMap<>(capacity);

方案五:使用 GuavaMaps.newHashMapWithExpectedSize()

// Guava 工具类 — 自动计算合理初始容量
Map<String, String> map = Maps.newHashMapWithExpectedSize(10000);

方案六:扩容过程中禁止并发操作

在需要保证数据一致性的场景,使用显式锁或读写锁控制:

private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private HashMap<String, String> map = new HashMap<>();

public void put(String key, String value) {
    lock.writeLock().lock();
    try {
        map.put(key, value);
    } finally {
        lock.writeLock().unlock();
    }
}

九、面试高频问题

Q1:为什么 HashMap 的容量必须是 2 的幂?

:为了保证 (n - 1) & hash 能均匀分布。如果 n 是 2 的幂,n - 1 的二进制全是低位 1,与哈希值做与运算等价于取模,且效率更高。如果不是 2 的幂,某些位永远为 0,会导致哈希冲突增加。

Q2:JDK 8 为什么要引入红黑树?

:当哈希冲突严重时,链表长度 O(n) 会严重降低性能。红黑树的查找时间复杂度为 O(log n),在极端哈希冲突情况下显著提升性能。同时,通过 TREEIFY_THRESHOLD=8 和泊松分布的考量,在正常场景下几乎不会触发树化,不影响常规性能。

Q3:JDK 8 的扩容为什么是线程不安全的?

resize() 方法没有加任何同步措施。多线程并发时可能出现:

  1. 多个线程同时创建新数组,互相覆盖
  2. put 操作时 (p = tab[i]) == null 判断为非原子操作,导致数据覆盖丢失
  3. 扩容和新 put 操作交替执行,数据丢失或索引错乱

Q4:JDK 8 还有死循环问题吗?

:JDK 8 使用尾插法解决了因链表反转导致的 resize 死循环。但并发场景下仍然不安全,可能出现数据丢失、put 返回 null 等问题。且在极少数情况下,并发操作红黑树仍可能引发死循环或 ConcurrentModificationException


十、总结

版本 扩容策略 并发安全性 主要风险
JDK 7 头插法 + 逐个 rehash 死循环、数据丢失
JDK 8 尾插法 + 高低位拆分 数据覆盖、丢失
ConcurrentHashMap CAS + 细粒度锁

核心结论

  1. 永远不要在并发场景下直接使用 HashMap,无论是 JDK 7 还是 JDK 8
  2. 并发场景请使用 ConcurrentHashMap,它在 JDK 8 中的性能已经非常优秀
  3. 了解 HashMap 的扩容机制有助于写出更好的单线程代码(合理设置初始容量、选择合适的负载因子)
  4. JDK 8 的尾插法 + 高低位拆分 + 红黑树是对 JDK 7 的重大改进,但也引入了新的复杂度

附录:关键源码参考

JDK 8 HashMap.resize() 完整流程:

resize()
  ├── 计算新容量 newCap 和阈值 newThr
  │     ├── oldCap > 0 → 翻倍
  │     ├── oldThr > 0 → 使用 oldThr
  │     └── 默认 → 16 / 12
  ├── 创建新数组 New Node[newCap]
  ├── 遍历旧数组
  │     ├── 空桶 → 跳过
  │     ├── 单节点 → 直接 rehash 放入
  │     ├── 红黑树 → split() 拆分
  │     └── 链表 → 拆分为 lo/hi 两个链表
  └── table = newTab

参考资源

  • JDK 源码:java.util.HashMap(JDK 7 / JDK 8 / JDK 11+)
  • 《Java 并发编程的艺术》 — 方腾飞
  • JDK Bug 数据库:JDK-8071667, JDK-8175105

评论