一、扩容基础概念
1. 什么是扩容?
HashMap 底层是数组 + 链表 / 红黑树 ,数组是定长结构。当元素越来越多,哈希冲突变多、性能下降,因此需要:
- 创建新的更大数组
- 重新计算所有元素位置
- 把旧数据转移到新数组这个过程就是扩容(resize)。
2. 扩容核心目标
- 减少哈希冲突,提升查询 / 插入效率
- 保证数组空间利用率与性能平衡
- 始终维持容量为 2 的幂次方
二、扩容通用核心参数
所有版本都依赖这 3 个参数决定扩容:
- 容量(capacity)
- 数组长度,默认 16,必须是 2^n
- 负载因子(loadFactor)
- 默认 0.75,时间 / 空间平衡最佳值
- 阈值(threshold)
阈值 = 容量 × 负载因子- 元素数量 超过阈值 就触发扩容
三、扩容触发条件(所有版本一致)
满足任意一个就触发:
- 新增元素后,size > threshold(最常见)
- 链表要树化,但数组长度 < 64(JDK 1.8)
- 初始化 HashMap 时,第一次 put 元素(懒加载)
四、JDK 1.8 扩容全流程(重点)
1. 扩容整体流程
- 旧数组为空 → 初始化默认容量(16)、阈值(12)
- 旧数组非空 → 新容量 = 旧容量 ×2,新阈值也 ×2
- 创建新的空数组
- 遍历旧数组每个位置
- 对每个位置的节点分三种情况处理:
- 无节点:跳过
- 单节点:重新计算下标放入新数组
- 链表节点:高低位拆分,放入原位置 / 原位置 + 旧容量
- 红黑树节点:树拆分 + 退化判断
- 新数组替换旧数组,扩容完成
2. JDK 1.8 扩容源码关键逻辑
java
运行
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) {
// 容量翻倍
newCap = oldCap << 1;
newThr = oldThr << 1;
}
// 2. 初始化逻辑(第一次put)
else if (oldThr > 0) {
newCap = oldThr;
} else {
newCap = DEFAULT_INITIAL_CAPACITY; // 16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 12
}
threshold = newThr;
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;
// 情况1:单个节点
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 情况2:红黑树节点
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 情况3:链表节点(高低位拆分)
else {
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 高位=0 → 留在原下标
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 高位=1 → 新下标=原下标+旧容量
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
3. JDK 1.8 扩容核心创新:高低位拆分
原理
容量是 2 的幂,扩容后:
- 新增最高一位二进制
- 用
hash & oldCap判断这一位是 0 还是 1
结果只有两种:
- 0 → 留在原下标
- 1 → 新下标 = 原下标 + 旧容量
优势
- 不需要重新计算 hash
- 不需要取模运算
- 链表保持原有顺序,避免死循环
五、JDK 1.7 扩容全流程(经典对比)
1. 核心流程
- 新容量 = 旧容量 ×2
- 新阈值 = 新容量 × 负载因子
- 创建新数组
- 遍历旧数组 → 遍历每个链表
- 头插法重新插入到新数组
- 替换数组,完成扩容
2. JDK 1.7 关键源码
java
运行
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
// 1. 创建新数组
Entry[] newTable = new Entry[newCapacity];
// 2. 旧数据转移(核心)
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : oldTable) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
// 重新计算下标
int i = indexFor(e.hash, newCapacity);
// 头插法!!!
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
3. JDK 1.7 致命问题
多线程并发扩容 → 链表环 + 死循环
原因:
- 采用头插法
- 链表顺序会被反转
- 多线程下指针互相指向,形成闭环
- 一旦 get 数据,就会无限循环 CPU 100%
六、JDK 1.7 VS JDK 1.8 扩容逐点拆解(面试必背)
表格
| 维度 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 数据结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
| 插入方式 | 头插法 | 尾插法 |
| 链表顺序 | 扩容后反转 | 保持原顺序 |
| 下标计算 | 全部重新 hash % 容量 |
高低位拆分,无需重算 |
| 并发风险 | 链表环、死循环 | 无线程安全问题,但仍非线程安全 |
| 转移效率 | 低(频繁计算 + 冲突) | 高(优化拆分) |
| 树化逻辑 | 无 | 支持树拆分、退化 |
| 空节点处理 | 直接遍历 | 提前置空,帮助 GC |
七、扩容高频面试题(标准答案)
1. 为什么 HashMap 扩容是 2 倍?
- 保证容量始终是2 的幂
- 下标计算用
hash & (len-1),比取模更快 - 扩容可使用高低位拆分,效率极高
- 数据分布均匀,减少哈希冲突
2. JDK 1.7 为什么会出现死循环?
- 头插法
- 多线程并发扩容
- 链表反转,形成环形链表
- 查询时进入死循环,CPU 飙升
3. JDK 1.8 解决死循环了吗?
- 解决了!
- 采用尾插法,保持链表顺序
- 不会形成闭环
4. 1.8 扩容高低位拆分原理?
- 扩容后新增一个高位 bit
hash & oldCap判断该位- 0 → 原位置
- 1 → 原位置 + 旧容量
- 无需重算 hash,效率极高
5. 扩容为什么非常消耗性能?
- 需要新建数组
- 需要重新排布所有数据
- 时间复杂度 O(n)
- 因此建议:初始化时指定容量,减少扩容次数
八、最佳实践
-
初始化指定容量
java
运行
Map<String, Object> map = new HashMap<>(16); -
高并发场景绝对不用 HashMap,用 ConcurrentHashMap
-
尽量使用不可变对象作 key(String、Integer)
-
避免频繁扩容,提升性能
九、总结(极简背诵版)
- 扩容触发:size > 阈值 或 树化前数组不足 64
- 扩容大小 :始终 2 倍扩容
- JDK1.7 :头插法、链表反转、多线程死循环
- JDK1.8 :尾插法、高低位拆分、无死循环、效率更高
- 核心优化:1.8 扩容不再重新计算哈希,直接按高低位拆分
- 容量必须是 2 的幂:为了位运算高效 + 数据均匀