很好,我们继续深入分析 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 GCif (e.next == null) {// 单个节点直接重新映射newTab[e.hash & (newCap - 1)] = e;} else if (e instanceof TreeNode) {// 红黑树重新分配((TreeNode<K,V>)e).split(...);} else {// 链表节点再分成两个链表// 一个保持原索引 i,另一个转移到 i + oldCapNode<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.75Map<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_0011B = 0000_1011C = 0001_0011D = 0001_1011oldCap = 0000_1000位运算: hash & oldCapA: 3 & 8 = 0 → 继续放在索引 3B: 11 & 8 = 8 → 放在新索引 3 + 8 = 11C: 19 & 8 = 0 → 索引 3D: 27 & 8 = 8 → 索引 11结果如下:
新数组:index 3: A → Cindex11: 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 |
减少冲突 | 重新分布节点,有助于均衡哈希桶分布 |
内存复用 | 节点对象不重建,仅更换位置 |