HashMap的扩容机制 - resize() 详解
HashMap 是 Java 集合框架中使用最广泛的哈希表实现,它以高效的键值对存储和查询能力著称。在 HashMap 的核心机制中,扩容(resize) 是保证其性能的关键环节之一。当 HashMap 中的元素数量达到一定阈值时,会触发扩容操作,重新分配内存并迁移元素,以避免链表过长导致查询效率下降。本文将深入剖析 HashMap 的 resize 机制,从底层原理到源码实现,结合最佳实践,帮助读者全面理解这一核心过程。
目录#
- HashMap 基础回顾
- 存储结构:数组 + 链表 + 红黑树
- 核心参数:容量、负载因子、阈值
- 为什么需要扩容?
- 链表过长的性能隐患
- 负载因子的平衡作用
- resize() 的执行时机
- put 操作触发扩容
- 初始化时的扩容
- resize() 的核心流程(JDK 8)
- 新数组的创建与容量翻倍
- 元素迁移:链表拆分与红黑树拆分
- 位运算优化:高效定位新桶位置
- 源码深度解析(JDK 8)
- resize 方法核心代码分析
- 链表拆分的位运算魔法
- 扩容过程中的线程安全问题
- JDK 7 的头插法与死循环隐患
- JDK 8 的尾插法优化
- 最佳实践
- 初始化时指定容量
- 合理设置负载因子
- 多线程场景的替代方案
- 示例代码:演示扩容过程
- 总结
- 参考资料
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;
}关键优化点#
- 位运算高效定位:通过
e.hash & oldCap判断元素的新位置,无需重新计算哈希,时间复杂度 O(1)。 - 尾插法避免链表环:JDK 8 中链表迁移使用尾插法(按原链表顺序插入新节点),避免了 JDK 7 中头插法导致的链表环问题。
- 红黑树的智能拆分:拆分后若子树节点数 ≤ 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,避免性能瓶颈。
参考资料#
- JDK 8 源码 - HashMap.java
- 《Java 并发编程的艺术》(方腾飞 等)
- 深入理解 HashMap 扩容机制
- HashMap 多线程死循环问题分析