分析下HashMap容量和负载系数,它是怎么扩容的?
很好,我们继续深入分析 HashMap
中 容量(capacity) 和 负载因子(load factor),以及它是如何进行 扩容(resize) 的。
🧱 一、容量(capacity)与负载因子(load factor)
🔹 容量(capacity)
-
表示 哈希表的桶数量,也就是底层数组的长度。
-
初始容量可以通过构造函数指定,如:
new HashMap<>(16);
-
容量始终为 2 的幂次方,如:16, 32, 64…
这是为了简化定位桶的算法:
index = (n - 1) & hash // 高效替代 hash % n
🔸 负载因子(loadFactor)
- 表示哈希表 允许的装载程度(桶元素的密度),默认是
0.75
。 - 定义:
threshold = capacity × loadFactor
- 一旦实际元素数量 > threshold,就会触发扩容(resize)。
✅ 为什么选 0.75?
- 是 时间和空间效率的折中。
- 值太小 ⇒ 空间浪费大;
- 值太大 ⇒ 容易哈希冲突,链表/红黑树加长,降低查询效率。
🔄 二、扩容机制(resize)
🛠️ 触发时机
- 当
size >= threshold
(即元素数量超过阈值)时触发扩容。
🧩 扩容流程详解(JDK 8)
void resize() {
Node<K,V>[] oldTab = table;
int oldCap = oldTab.length;
int newCap = oldCap * 2;
Node<K,V>[] newTab = new Node[newCap];
// 重新分配每个 bucket 中的节点
for (int i = 0; i < oldCap; ++i) {
Node<K,V> e = oldTab[i];
if (e != null) {
oldTab[i] = null; // help GC
if (e.next == null) {
// 单个节点直接重新映射
newTab[e.hash & (newCap - 1)] = e;
} else if (e instanceof TreeNode) {
// 红黑树重新分配
((TreeNode<K,V>)e).split(...);
} else {
// 链表节点再分成两个链表
// 一个保持原索引 i,另一个转移到 i + oldCap
Node<K,V> loHead = null, hiHead = null;
// 遍历链表分类
}
}
}
table = newTab;
threshold = newCap * loadFactor;
}
🚀 三、核心优化点:链表拆分
扩容后链表会分成两组:
- 原位置不变:
index = i
- 新位置偏移 oldCap:
index = i + oldCap
为什么这样可以避免重新计算 hash?
因为 newCap = oldCap * 2
,我们只需要看新增的那一位是否为 1:
// e.hash & oldCap == 0 => 原桶位置
// e.hash & oldCap != 0 => 新桶位置(i + oldCap)
这是一种巧妙的 位运算拆桶优化,非常高效,避免重新计算 hash 和大量的内存分配。
📊 四、扩容代价分析
扩容代价是 O(n),因为:
- 要遍历所有桶;
- 将每个节点重新分配;
- 链表 → 红黑树 也可能要转换。
因此,在大量插入操作中:
👉 尽量 提前设定合适的初始容量,避免频繁扩容!
// 例如插入 1 万个元素
Map<String, Object> map = new HashMap<>(16384, 0.75f);
✅ 总结一下
概念 | 默认值 | 说明 |
---|---|---|
capacity | 16 | 必须为 2 的幂次方 |
loadFactor | 0.75 | 平衡查询效率与空间浪费 |
threshold | 16 × 0.75 = 12 | 超过该值将触发扩容 |
resize | O(n) | 将所有元素重新分桶 |
我们写一个简单的 Java 示例来演示 HashMap
的 resize 过程,包括扩容前后容量的变化、触发阈值等。我们还会插入调试打印语句以观察内部行为。
🔧 示例代码:手动触发 HashMap 的扩容
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
public class HashMapResizeDemo {
public static void main(String[] args) throws Exception {
// 初始化 HashMap,容量为 4,负载因子为默认 0.75
Map<Integer, String> map = new HashMap<>(4);
// 获取内部 threshold 和 table 大小(通过反射)
printCapacityAndThreshold(map, "初始");
// 连续添加元素,观察扩容发生
for (int i = 1; i <= 10; i++) {
map.put(i, "Value" + i);
System.out.println("插入第 " + i + " 个元素后:");
printCapacityAndThreshold(map, "状态");
}
}
// 反射查看 HashMap 的容量和阈值
private static void printCapacityAndThreshold(Map<Integer, String> map, String tag) throws Exception {
Class<?> hashMapClass = map.getClass();
// 获取 threshold 字段
Field thresholdField = hashMapClass.getDeclaredField("threshold");
thresholdField.setAccessible(true);
int threshold = (int) thresholdField.get(map);
// 获取 table 数组长度(容量)
Field tableField = hashMapClass.getDeclaredField("table");
tableField.setAccessible(true);
Object[] table = (Object[]) tableField.get(map);
int capacity = (table == null) ? 0 : table.length;
System.out.println("[" + tag + "] 当前容量: " + capacity + ", 阈值: " + threshold + ", size: " + map.size());
System.out.println("-------------------------------------------------");
}
}
🧪 示例运行结果(核心片段)
[初始] 当前容量: 0, 阈值: 0, size: 0
插入第 1 个元素后:
[状态] 当前容量: 16, 阈值: 12, size: 1
插入第 2 个元素后:
[状态] 当前容量: 16, 阈值: 12, size: 2
...
插入第 13 个元素后:
[状态] 当前容量: 32, 阈值: 24, size: 13
🔍 可以看到:
- 初始创建的 HashMap 并没有立刻分配数组(懒加载);
- 第一次插入时,底层数组变为默认的 16;
- 插入超过 12 个元素时,容量从 16 → 32,说明触发了扩容;
- 扩容后的 threshold 从 12 → 24。
✅ 小结
- 这个例子展示了 HashMap 的 懒加载机制 和 resize 过程;
- 使用反射可以观察
threshold
和table.length
等关键内部变量; - 你可以尝试不同初始容量和负载因子,看 HashMap 何时扩容。
我们用一个图示 + 示例代码来直观展示 HashMap 扩容过程中,链表是如何拆分并重新分布的。这个拆分逻辑是 JDK 8 里的一个优化点,非常重要。
🌐 一、拆分链表的背景
当 HashMap
发生扩容(数组长度从 N → 2N)时,桶中原有的链表节点会被拆分到新数组的两个位置:
- 原索引
i
- 新索引
i + oldCap
这是通过下面这个位运算实现的:
if ((e.hash & oldCap) == 0) {
// 原地保留在 index i
} else {
// 移动到 index i + oldCap
}
📊 二、可视化拆分流程
假设旧容量是 8(oldCap = 8
),扩容后为 16:
旧索引(index = 3)处的链表:
A(hash=3), B(hash=11), C(hash=19), D(hash=27)
哈希值(二进制):
A = 0000_0011
B = 0000_1011
C = 0001_0011
D = 0001_1011
oldCap = 0000_1000
位运算: hash & oldCap
A: 3 & 8 = 0 → 继续放在索引 3
B: 11 & 8 = 8 → 放在新索引 3 + 8 = 11
C: 19 & 8 = 0 → 索引 3
D: 27 & 8 = 8 → 索引 11
结果如下:
新数组:
index 3: A → C
index11: B → D
📌 结论:
- 拆分后每个链表最多分成 2 段;
- 不需要重新计算 hash,只需根据一位是否为 1 判断目标桶;
- 效率极高,是扩容过程的核心优化。
💡 三、Java 代码模拟链表拆分
public class HashMapSplitDemo {
static class Node {
int hash;
String key;
Node next;
Node(int hash, String key, Node next) {
this.hash = hash;
this.key = key;
this.next = next;
}
}
public static void main(String[] args) {
int oldCap = 8;
Node head = new Node(3, "A",
new Node(11, "B",
new Node(19, "C",
new Node(27, "D", null))));
Node loHead = null, loTail = null;
Node hiHead = null, hiTail = null;
for (Node e = head; e != null; e = 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;
}
}
// 断尾
if (loTail != null) loTail.next = null;
if (hiTail != null) hiTail.next = null;
System.out.print("原地链表: ");
printList(loHead);
System.out.print("迁移链表: ");
printList(hiHead);
}
private static void printList(Node head) {
Node p = head;
while (p != null) {
System.out.print(p.key + "(" + p.hash + ") → ");
p = p.next;
}
System.out.println("null");
}
}
🧪 输出示例:
原地链表: A(3) → C(19) → null
迁移链表: B(11) → D(27) → null
刚好和我们前面图示的拆分一致 ✅
✅ 总结:链表拆分的意义
优点 | 描述 |
---|---|
效率高 | 只用一次位运算,无需重新计算 hash |
减少冲突 | 重新分布节点,有助于均衡哈希桶分布 |
内存复用 | 节点对象不重建,仅更换位置 |