HashMap 的扩容有几个流传很广的说法:默认容量 16、负载因子 0.75、放到第 13 个元素扩容、扩容后节点"重新计算位置"。这次不背,全部在本机的 OpenJDK 17 里用反射读 HashMap 内部的 table 和 threshold 字段,逐个 put 地看:table 什么时候才被创建;什么时候变长;扩容时每个节点到底去了哪个桶,规则是什么;指定初始容量之后表长为什么常常不是你传的数;以及预设容量到底能省掉多少次扩容和多少时间。最后是一个分步演示,每一帧都是同一段真实程序在每次 put 之后读出的 table 快照。
所有实验都是一个 Java 源文件,用反射读取 java.util.HashMap 的私有字段。JDK 17 默认不允许反射访问 java.base 里的私有字段,需要显式加 --add-opens。堆固定为 2g,是为了减少实验五里 GC 对计时的干扰。
真实命令 · shell $ java -version $ java -Xms2g -Xmx2g --add-opens java.base/java.util=ALL-UNNAMED HashMapExp.java
openjdk version "17.0.20.1" 2026-08-18 OpenJDK Runtime Environment Temurin-17.0.20.1+1 (build 17.0.20.1+1) OpenJDK 64-Bit Server VM Temurin-17.0.20.1+1 (build 17.0.20.1+1, mixed mode, sharing) java.version = 17.0.20.1,OpenJDK 64-Bit Server VM,aarch64
反射读字段的核心代码(完整源码见文末说明):
真实命令 · java Field table = HashMap.class.getDeclaredField("table"); // Node<K,V>[] table Field threshold = HashMap.class.getDeclaredField("threshold"); // 下一次扩容的阈值 Class<?> node = Class.forName("java.util.HashMap$Node"); Field next = node.getDeclaredField("next"), hash = node.getDeclaredField("hash"), key = node.getDeclaredField("key"); // 全部 setAccessible(true),之后 table.get(map) 得到桶数组,node 的 next 字段串起一个桶里的链表
new HashMap<>() 之后: size=0 table=null threshold=0 put(1, 1) 之后: size=1 table=长度 16 threshold=12
构造之后 table 是 null、threshold 是 0,第一次 put 才分配长度 16 的数组,同时把 threshold 设为 12(16 × 0.75)。也就是说,new HashMap<>() 本身几乎不占内存,"默认容量 16"是第一次 put 才兑现的。
put 第 1 个之后: 表长 0 -> 16,threshold = 12 put 第 13 个之后: 表长 16 -> 32,threshold = 24 put 第 25 个之后: 表长 32 -> 64,threshold = 48 put 第 49 个之后: 表长 64 -> 128,threshold = 96 put 第 97 个之后: 表长 128 -> 256,threshold = 192 put 第 193 个之后: 表长 256 -> 512,threshold = 384 put 第 11 个之后: size=11 table=长度 16 threshold=12 put 第 12 个之后: size=12 table=长度 16 threshold=12 put 第 13 个之后: size=13 table=长度 32 threshold=24
表长每次翻倍:16 → 32 → 64 → 128 → 256 → 512,触发扩容的 put 依次是第 13、25、49、97、193 个,正好是 threshold 加一(12+1、24+1、48+1、96+1、192+1)。第 12 个放完时 size = 12,没有超过 threshold = 12,表长还是 16;第 13 个放完 size = 13 > 12,表长变成 32,threshold 变成 24。
对照 JDK 17.0.20.1 自带的源码(lib/src.zip 里的 java.base/java/util/HashMap.java),putVal 的结尾:
真实命令 · shell $ unzip -p $JAVA_HOME/lib/src.zip java.base/java/util/HashMap.java | sed -n '659,662p'
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);判断是 ++size > threshold,先把新节点放进去、再加一、再和阈值比较,所以是"放进第 13 个之后才扩",与上面观察到的完全一致。这里有一个容易被忽略的推论:元素是先插入到旧表,然后整体搬到新表的(下一节的演示里你能看到这次 put 的键最终落在新表里的哪个桶)。
-- 对照:键全部落在同一个桶里(键 = 16 的倍数 + 1,对 16、32、64… 取余都得到同一个桶)--
put 第 1 个之后: 表长 0 -> 16,threshold = 12,size = 1
put 第 8 个之后: 表长 16,桶 1 的第一个节点的类 = Node,桶里节点数 = 8
put 第 9 个之后: 表长 16 -> 32,threshold = 24,size = 9
put 第 9 个之后: 表长 32,桶 1 的第一个节点的类 = Node,桶里节点数 = 9
put 第 10 个之后: 表长 32 -> 64,threshold = 48,size = 10
put 第 10 个之后: 表长 64,桶 1 的第一个节点的类 = Node,桶里节点数 = 10
put 第 11 个之后: 表长 64,桶 1 的第一个节点的类 = TreeNode,桶里节点数 = 11
put 第 12 个之后: 表长 64,桶 1 的第一个节点的类 = TreeNode,桶里节点数 = 12这一组键都是 64 的倍数加 1,无论表长是 16、32 还是 64,它们都落在桶 1。结果:第 9 个 put 之后表长就从 16 变成 32(此时 size 才 9,远没到阈值 12),第 10 个之后又变成 64;到第 11 个,桶 1 的第一个节点的类变成了 TreeNode,也就是链表变成了红黑树。这个现象来自 treeifyBin:
真实命令 · shell $ unzip -p $JAVA_HOME/lib/src.zip java.base/java/util/HashMap.java | sed -n '753,757p'
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {链表长度达到阈值想树化时,如果表长还小于 MIN_TREEIFY_CAPACITY(64),不树化,而是先扩容。所以这里表长到 64 之后才真正变树。"到底是第几个元素才树化"是另一篇文章的主题(见HashMap 树化),这里只借它说明:扩容不一定是因为 size 超过阈值。
插入 100000 个字符串键,共触发 14 次扩容,扩容时被重新分配的节点共 196596 个
位置不变: 98447,搬到「旧下标 + 旧容量」: 98149
「新下标 ∈ {旧下标, 旧下标+旧容量},且是否搬走恰好等于 (hash & 旧容量) != 0」成立的节点: 196596 / 196596我用 10 万个随机字符串键,在每次扩容前先读出所有节点的桶下标和 hash,扩容后再读一遍:一共触发了 14 次扩容,被重新分配的旧节点共 196596 个,其中 98447 个留在原下标,98149 个搬到「旧下标 + 旧容量」,两边各约一半。并且对每一个节点都验证了:新下标要么等于旧下标,要么等于旧下标加旧容量,而且"是否搬走"恰好等于 (hash & 旧容量) != 0——196596 个节点全部成立。
源码里的对应部分(resize() 拆分链表的地方):
真实命令 · shell $ unzip -p $JAVA_HOME/lib/src.zip java.base/java/util/HashMap.java | sed -n '719p'
if ((e.hash & oldCap) == 0) {为什么可以这样:表长是 2 的幂,桶下标是 (n - 1) & hash,即 hash 的低 log₂(n) 位。表长翻倍后多用了 hash 的下一位,这一位就是 oldCap 对应的那一位:是 0,新下标 = 旧下标;是 1,新下标 = 旧下标 + oldCap。所以扩容不需要对每个节点重新算 hash,只需要看这一位;一个桶里的链表会被拆成"低位链"和"高位链"两条。
new HashMap<>( 0): 构造后 threshold = 1;第一次 put 后 表长 = 2,threshold = 1 new HashMap<>( 1): 构造后 threshold = 1;第一次 put 后 表长 = 2,threshold = 1 new HashMap<>( 2): 构造后 threshold = 2;第一次 put 后 表长 = 2,threshold = 1 new HashMap<>( 3): 构造后 threshold = 4;第一次 put 后 表长 = 4,threshold = 3 new HashMap<>( 5): 构造后 threshold = 8;第一次 put 后 表长 = 8,threshold = 6 new HashMap<>( 12): 构造后 threshold = 16;第一次 put 后 表长 = 16,threshold = 12 new HashMap<>( 13): 构造后 threshold = 16;第一次 put 后 表长 = 16,threshold = 12 new HashMap<>( 16): 构造后 threshold = 16;第一次 put 后 表长 = 16,threshold = 12 new HashMap<>( 17): 构造后 threshold = 32;第一次 put 后 表长 = 32,threshold = 24 new HashMap<>( 100): 构造后 threshold = 128;第一次 put 后 表长 = 128,threshold = 96 new HashMap<>(1000): 构造后 threshold = 1024;第一次 put 后 表长 = 1024,threshold = 768 想放 13 个元素不触发扩容:new HashMap<>(13) 表长 = 32;new HashMap<>(17) 表长 = 32;new HashMap<>((int)(13 / 0.75f) + 1) = new HashMap<>(18) 表长 = 32
传入的 n 会被 tableSizeFor 换成不小于它的最小 2 的幂,存进 threshold,第一次 put 才据此建表:new HashMap<>(13) 得到表长 16、threshold 12;new HashMap<>(17) 得到表长 32、threshold 24;new HashMap<>(1000) 得到 1024、768。对应源码:
真实命令 · shell $ unzip -p $JAVA_HOME/lib/src.zip java.base/java/util/HashMap.java | sed -n '377,380p'
static final int tableSizeFor(int cap) {
int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}最后一行输出说明了一个常见的坑:打算放 13 个元素就传 13,结果 new HashMap<>(13) 建出的表长是 16、threshold 是 12,放第 13 个时照样触发一次扩容,最终表长 32。想要"一次都不扩容",需要传入的容量除以 0.75 之后再向上取,例如 (int)(13 / 0.75f) + 1 = 18(表长 32、threshold 24)。另外注意一个小细节:new HashMap<>(0) 和 new HashMap<>(1) 第一次 put 后表长是 2,threshold 只有 1,再放第二个元素就会扩容——极小的容量参数没有意义。
默认容量:扩容 18 次;预设 (int)(n/0.75f)+1:扩容 0 次 11 轮(含前几轮热身),单位 ms(堆固定为 2g,减少 GC 抖动): 默认容量 最快 31 中位 97 最慢 213 预设容量 最快 14 中位 83 最慢 168
不预设容量时,200 万个键从 16 一路翻倍到 4194304,共扩容 18 次(每次都要把所有已有节点搬一遍);预设 (int)(n / 0.75f) + 1 之后是 0 次。计时的部分我做了 11 轮(前几轮包含 JIT 热身),每个 JVM 进程内取最快、中位、最慢。为了确认单次运行不是巧合,我另外独立启动了 3 次 JVM,只看最后两行:
真实命令 · shell $ for i in 1 2 3; do java -Xms2g -Xmx2g --add-opens java.base/java.util=ALL-UNNAMED HashMapExp.java | tail -2; done
--- 第 1 次独立启动 JVM --- 默认容量 最快 104 中位 186 最慢 234 预设容量 最快 89 中位 126 最慢 195 --- 第 2 次独立启动 JVM --- 默认容量 最快 22 中位 95 最慢 230 预设容量 最快 17 中位 53 最慢 179 --- 第 3 次独立启动 JVM --- 默认容量 最快 21 中位 82 最慢 170 预设容量 最快 15 中位 36 最慢 106
四次独立运行(含上面主实验那次)里,预设容量的中位耗时都比默认小:83 对 97、126 对 186、53 对 95、36 对 82 毫秒,少了大约 14% 到 56%。但抖动很大:同一个配置下最快和最慢相差 2 到 12 倍,不同次运行的中位数也差了一倍以上,所以这里只能说"预设容量在这个场景下稳定地更快,幅度大概是一成到一半",不能给出一个精确的倍数。这个数字也包含了 Integer 装箱、GC 的开销,它不是单独的扩容开销。
下面的演示把前面的现象串在一起:键依次是 1、17、33、2、3、4、5、6、7、8、9、10、49,其中 1、17、33、49 在 16 长的表里都落在桶 1(它们对 16 取余都是 1)。每一帧都是同一段真实程序在每次 put 之后用反射读出的 table、size、threshold,不是手工画的。注意最后一步:第 13 个键 49 放进去之后 size 超过了阈值,表从 16 扩成 32,桶 1 里的 1、17、33、49 按 hash 的第 4 位(值 16)被拆开:17 和 49 搬到桶 17,1 和 33 留在桶 1。