【Java】HashMap 的 put 到底做了什么?------冲突、扩容与树化实测

HashMap 的面试题常被压缩成一句话:数组加链表加红黑树,链表长度到 8 就树化。背下这句话不难,真正写代码时却会留下几个空洞:为什么 new HashMap<>(4) 刚创建时桶数组长度还是 0?为什么第四次 put 才从 4 扩到 8?为什么我塞进 9 个相同 hashCode 的键,桶里仍然是普通 Node,没有变成 TreeNode?
这些问题不能靠继续背阈值解决。本文以 JDK 21 为准,运行一个可复现的小程序,直接观察 table、threshold 与桶首节点类型的变化,再顺着 OpenJDK 源码走完一次 put。反射只用于实验观察,不建议放进业务代码。
1. 先看现场:第 9 个碰撞键为什么没有树化
实验先创建一个初始容量为 4 的表,再构造一组 hashCode() 永远返回 1 的键。下面是本机 Java 21 的实际输出摘要:

最值得停一下的是中间四行。
- 从容量 16 开始,放入第 8 个碰撞键时,桶首节点仍是
Node。 - 第 9 个碰撞键到来后,容量变成 32,节点仍是
Node。 - 第 10 个碰撞键让容量继续变成 64,节点依然是
Node。 - 第 11 个碰撞键到来,桶首节点才成为
TreeNode。
换一张一开始就具备 64 容量的表,第 9 个碰撞键便能触发树化。这说明"达到 8 就树化"只描述了一个触发点,没有把容量条件说完整。treeifyBin 发现当前表容量小于 64 时,会先调用 resize(),希望通过增加桶数把碰撞拆开;只有容量足够时,才值得承担树节点更高的空间成本。
完整实验命令如下。Java 21 的模块封装默认不允许随意读取 java.util 的私有字段,因此观察程序需要临时打开模块边界:
bash
javac HashMapProbe.java
java --add-opens java.base/java.util=ALL-UNNAMED HashMapProbe
这里的 --add-opens 是诊断手段,不是业务系统的启动建议。应用代码应当依赖 Map 的公开契约,而不是内部字段名称。
为了让碰撞可重复,实验键没有依赖字符串"碰巧撞上",而是明确把所有键的哈希值固定为 1,同时仍用 id 区分对象:
java
record BadKey(int id) {
@Override
public int hashCode() {
return 1;
}
}
HashMap<BadKey, Integer> map = new HashMap<>(16);
for (int i = 1; i <= 11; i++) {
map.put(new BadKey(i), i);
}
记录类自动生成的 equals 会比较 id,所以这些键彼此不相等,却被送进同一个桶,正好把"哈希相同"与"键相同"分开。若把 equals 也改成始终返回 true,后续 put 只会覆盖同一映射,size 不会增长,那就测不到链表和树化。设计复现实验时,必须只控制待观察变量,否则输出看似稳定,解释的却是另一件事。
观察函数读取 table.length、threshold 和指定桶首对象的简单类名。它不遍历红黑树,也不修改任何字段,因而证据只回答三个有限问题:数组何时分配、何时扩容、桶首节点何时从 Node 变成 TreeNode。它不能证明生产负载的平均延迟,也不能据此声称红黑树一定更快;性能结论仍需结合真实键分布和基准测试。
2. 一次 put 要经过哪些岔路
公开方法 put 很短,它先计算扰动后的哈希值,再把工作交给 putVal:
java
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
真正的路径可以压缩成四个问题:桶数组是否已分配;目标桶是否为空;如果不为空,遇到的是同一个键、链表还是树;插入完成后,size 是否超过扩容阈值。

#mermaid-svg-na9qddNhptecYf2t{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-na9qddNhptecYf2t .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-na9qddNhptecYf2t .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-na9qddNhptecYf2t .error-icon{fill:#552222;}#mermaid-svg-na9qddNhptecYf2t .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-na9qddNhptecYf2t .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-na9qddNhptecYf2t .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-na9qddNhptecYf2t .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-na9qddNhptecYf2t .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-na9qddNhptecYf2t .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-na9qddNhptecYf2t .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-na9qddNhptecYf2t .marker{fill:#333333;stroke:#333333;}#mermaid-svg-na9qddNhptecYf2t .marker.cross{stroke:#333333;}#mermaid-svg-na9qddNhptecYf2t svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-na9qddNhptecYf2t p{margin:0;}#mermaid-svg-na9qddNhptecYf2t .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-na9qddNhptecYf2t .cluster-label text{fill:#333;}#mermaid-svg-na9qddNhptecYf2t .cluster-label span{color:#333;}#mermaid-svg-na9qddNhptecYf2t .cluster-label span p{background-color:transparent;}#mermaid-svg-na9qddNhptecYf2t .label text,#mermaid-svg-na9qddNhptecYf2t span{fill:#333;color:#333;}#mermaid-svg-na9qddNhptecYf2t .node rect,#mermaid-svg-na9qddNhptecYf2t .node circle,#mermaid-svg-na9qddNhptecYf2t .node ellipse,#mermaid-svg-na9qddNhptecYf2t .node polygon,#mermaid-svg-na9qddNhptecYf2t .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-na9qddNhptecYf2t .rough-node .label text,#mermaid-svg-na9qddNhptecYf2t .node .label text,#mermaid-svg-na9qddNhptecYf2t .image-shape .label,#mermaid-svg-na9qddNhptecYf2t .icon-shape .label{text-anchor:middle;}#mermaid-svg-na9qddNhptecYf2t .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-na9qddNhptecYf2t .rough-node .label,#mermaid-svg-na9qddNhptecYf2t .node .label,#mermaid-svg-na9qddNhptecYf2t .image-shape .label,#mermaid-svg-na9qddNhptecYf2t .icon-shape .label{text-align:center;}#mermaid-svg-na9qddNhptecYf2t .node.clickable{cursor:pointer;}#mermaid-svg-na9qddNhptecYf2t .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-na9qddNhptecYf2t .arrowheadPath{fill:#333333;}#mermaid-svg-na9qddNhptecYf2t .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-na9qddNhptecYf2t .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-na9qddNhptecYf2t .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-na9qddNhptecYf2t .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-na9qddNhptecYf2t .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-na9qddNhptecYf2t .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-na9qddNhptecYf2t .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-na9qddNhptecYf2t .cluster text{fill:#333;}#mermaid-svg-na9qddNhptecYf2t .cluster span{color:#333;}#mermaid-svg-na9qddNhptecYf2t div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-na9qddNhptecYf2t .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-na9qddNhptecYf2t rect.text{fill:none;stroke-width:0;}#mermaid-svg-na9qddNhptecYf2t .icon-shape,#mermaid-svg-na9qddNhptecYf2t .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-na9qddNhptecYf2t .icon-shape p,#mermaid-svg-na9qddNhptecYf2t .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-na9qddNhptecYf2t .icon-shape .label rect,#mermaid-svg-na9qddNhptecYf2t .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-na9qddNhptecYf2t .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-na9qddNhptecYf2t .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-na9qddNhptecYf2t :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
是
否
key
hashCode
高低位扰动
定位候选桶
hash 相同?
继续遍历桶
引用相同或 equals?
同一个键
目标桶为空时,插入只需要创建一个 Node。目标桶不为空时,HashMap 会先比较哈希值,再用引用相等或 equals 判断是不是已有键。已有键只更新值,不增加 size;桶首节点是 TreeNode 时走树插入;否则遍历链表,找到同键就覆盖,走到末尾才追加。
这也解释了一个常见误会:put 返回 null 不一定表示以前没有这个键。如果旧值本来就是 null,返回值同样是 null。需要区分这两种情况时,应配合 containsKey,不能只看 put 的返回值。
3. new HashMap 并不会立即分配桶数组
实验中的第一行是:
text
new HashMap<>(4) size=0 capacity=0 threshold=4
构造完成后,table 仍为 null,所以这里观察到的容量是 0。构造器会检查初始容量是否合法,再把向上取整后的 2 次幂目标暂存在 threshold;第一次真正插入时,resize() 才创建数组。这种延迟分配很实际:不少对象会被创建,却未必真的放入元素,提前分配桶只会占用内存。
第一次 put 后,容量成为 4,默认负载因子 0.75 对应阈值 3。前三个元素放入时不会扩容;第四个新键插入完成后,++size > threshold 成立,容量才从 4 变成 8,阈值变成 6。
注意比较顺序:不是"即将等于阈值时扩容",也不是"桶被填满才扩容",而是新映射插入后,元素总数超过阈值时扩容。更新已有键不会增加 size,自然不会因为这次覆盖而触发扩容。
容量、元素数和阈值不是同一个量
size:当前键值映射数量。capacity:桶数组table.length,通常保持为 2 的幂。loadFactor:允许表达到多"密"再扩容,默认 0.75。threshold:下一次扩容的元素数边界,通常约等于capacity × loadFactor。
把这四个词混在一起,会进一步误解"16 个桶只能放 16 个元素"。桶可以挂链表或树,容量说的是桶数,不是映射数量的硬上限。
4. hashCode 决定候选桶,equals 决定是不是同一个键
JDK 21 中,HashMap 不是直接拿原始 hashCode() 取模,而是让高 16 位与自身做一次异或:
java
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
桶下标再由 (n - 1) & hash 得到。因为 n 是 2 的幂,这个位运算等价于在当前容量下取低位,开销小,也为扩容时的拆分留下了条件。高位扰动的目的,是让只在高位不同的哈希值也有机会影响较小表的桶下标。
可以拿容量 16 做一个很小的手算。n - 1 是二进制 1111,与运算只保留哈希值最低 4 位;如果两个原始哈希值低 4 位相同、差异只在高 16 位,直接取低位就会稳定碰撞。h ^ (h >>> 16) 把一部分高位信息折到低位,再参与桶定位。它不是加密散列,也不会消灭所有碰撞,只是在成本很低的前提下改善常见键的分布。
null 键走的是另一条明确路径:hash(null) 返回 0,因此候选桶是下标 0。HashMap 允许一个 null 键和多个 null 值,这一点与 ConcurrentHashMap 不同。把实现替换为并发容器时,如果原代码依赖 null,迁移会直接遇到 NullPointerException,不能只换类名。
但 hashCode 相同绝不意味着键相等。它只把候选范围缩到同一个桶;真正遇到同哈希节点时,还要看引用是否相同或 equals 是否返回 true。因此自定义键必须遵守基本契约:两个对象如果 equals 相等,就必须拥有相同的 hashCode。反过来不要求成立,不同对象完全可以发生哈希碰撞。
最危险的是把可变字段放进 equals 和 hashCode,然后在键进入 HashMap 后修改它。对象还留在原来的桶,新的哈希却会把查询带到另一个桶,于是 get、containsKey 可能像"丢了键"一样返回失败。不是 HashMap 偷走了数据,而是键自己改变了地址线索。
5. 树化为什么要同时看 8 和 64
OpenJDK 21 源码给出了三个容易混淆的常量:TREEIFY_THRESHOLD = 8、UNTREEIFY_THRESHOLD = 6、MIN_TREEIFY_CAPACITY = 64。第一个是树化触发附近的阈值,第二个用于扩容拆分等场景下节点太少时退回普通桶,第三个限制了允许树化的最小表容量。

#mermaid-svg-DXACIxdg74ddptxy{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DXACIxdg74ddptxy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DXACIxdg74ddptxy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DXACIxdg74ddptxy .error-icon{fill:#552222;}#mermaid-svg-DXACIxdg74ddptxy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DXACIxdg74ddptxy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DXACIxdg74ddptxy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DXACIxdg74ddptxy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DXACIxdg74ddptxy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DXACIxdg74ddptxy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DXACIxdg74ddptxy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DXACIxdg74ddptxy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DXACIxdg74ddptxy .marker.cross{stroke:#333333;}#mermaid-svg-DXACIxdg74ddptxy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DXACIxdg74ddptxy p{margin:0;}#mermaid-svg-DXACIxdg74ddptxy .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-DXACIxdg74ddptxy .cluster-label text{fill:#333;}#mermaid-svg-DXACIxdg74ddptxy .cluster-label span{color:#333;}#mermaid-svg-DXACIxdg74ddptxy .cluster-label span p{background-color:transparent;}#mermaid-svg-DXACIxdg74ddptxy .label text,#mermaid-svg-DXACIxdg74ddptxy span{fill:#333;color:#333;}#mermaid-svg-DXACIxdg74ddptxy .node rect,#mermaid-svg-DXACIxdg74ddptxy .node circle,#mermaid-svg-DXACIxdg74ddptxy .node ellipse,#mermaid-svg-DXACIxdg74ddptxy .node polygon,#mermaid-svg-DXACIxdg74ddptxy .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-DXACIxdg74ddptxy .rough-node .label text,#mermaid-svg-DXACIxdg74ddptxy .node .label text,#mermaid-svg-DXACIxdg74ddptxy .image-shape .label,#mermaid-svg-DXACIxdg74ddptxy .icon-shape .label{text-anchor:middle;}#mermaid-svg-DXACIxdg74ddptxy .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-DXACIxdg74ddptxy .rough-node .label,#mermaid-svg-DXACIxdg74ddptxy .node .label,#mermaid-svg-DXACIxdg74ddptxy .image-shape .label,#mermaid-svg-DXACIxdg74ddptxy .icon-shape .label{text-align:center;}#mermaid-svg-DXACIxdg74ddptxy .node.clickable{cursor:pointer;}#mermaid-svg-DXACIxdg74ddptxy .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-DXACIxdg74ddptxy .arrowheadPath{fill:#333333;}#mermaid-svg-DXACIxdg74ddptxy .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-DXACIxdg74ddptxy .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-DXACIxdg74ddptxy .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DXACIxdg74ddptxy .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-DXACIxdg74ddptxy .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DXACIxdg74ddptxy .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-DXACIxdg74ddptxy .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-DXACIxdg74ddptxy .cluster text{fill:#333;}#mermaid-svg-DXACIxdg74ddptxy .cluster span{color:#333;}#mermaid-svg-DXACIxdg74ddptxy div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-DXACIxdg74ddptxy .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-DXACIxdg74ddptxy rect.text{fill:none;stroke-width:0;}#mermaid-svg-DXACIxdg74ddptxy .icon-shape,#mermaid-svg-DXACIxdg74ddptxy .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DXACIxdg74ddptxy .icon-shape p,#mermaid-svg-DXACIxdg74ddptxy .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-DXACIxdg74ddptxy .icon-shape .label rect,#mermaid-svg-DXACIxdg74ddptxy .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DXACIxdg74ddptxy .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-DXACIxdg74ddptxy .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-DXACIxdg74ddptxy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
是
链表追加新节点
达到树化触发点?
保持链表
table 容量至少 64?
优先 resize 分散碰撞
转换为 TreeNode 并树化
源码在链表尾部追加节点后,以 binCount >= TREEIFY_THRESHOLD - 1 调用 treeifyBin。计数从已有节点的遍历过程开始,因此读代码时容易出现"到底第 8 个还是第 9 个"的争论。比单看一行条件更可靠的方法,是把触发调用和 treeifyBin 内部的容量判断一起看,再用对应 JDK 做实验。本文在容量 64 的表中观察到第 9 个碰撞键插入后桶首节点变为 TreeNode。
红黑树不是免费的升级。OpenJDK 源码注释明确提到,树节点大约是普通节点的两倍大小。正常 hashCode 分布良好时,长链表本就少见;容量不足时先扩容,比立刻把每个碰撞桶都换成更重的树结构更合算。
树化也不意味着所有操作从此只做一次比较。同一桶中,节点会先比较哈希;哈希相同时,如果键属于可比较类型,树结构可借助比较顺序打破平局,否则实现还需要查找相等键并使用内部的平局顺序。业务键若长期产生相同哈希,树结构只是限制最坏退化,不能替代一个质量正常的 hashCode。
删除节点后的"退化回链表"也不适合机械理解成"节点一到 6 马上切换"。UNTREEIFY_THRESHOLD = 6 主要参与扩容拆分等路径,树删除代码还会根据树的形态判断是否已经太小。速记可以记常量,源码分析必须看调用位置,否则会把一个内部阈值误写成无条件状态机。
6. 扩容不是重新乱排:节点只会留下或移动 oldCap
容量翻倍有一个很漂亮的性质。旧容量为 4 时,hash=1 与 hash=5 的低两位相同,所以都落在桶 1;新容量为 8 后,多看一位,二者分别落在 1 和 5。

对于旧桶中的链表,JDK 21 检查 (e.hash & oldCap) == 0:结果为 0 的节点留在原下标,结果非 0 的节点移动到 原下标 + oldCap。源码分别维护 loHead/loTail 与 hiHead/hiTail,完成后把两条链挂到新表,因此不需要对每个节点执行一次通用取模,桶内相对顺序也得到保留。
实验中的两行输出正好对应这件事:
text
before trigger capacity=4 indexes: hash1=1 hash5=1
after trigger capacity=8 indexes: hash1=1 hash5=5
扩容仍然不是零成本。它要分配新数组并迁移旧桶中的节点;如果在请求高峰中连续扩容,延迟抖动和瞬时分配都可能更明显。能合理估算规模时,给出适当初始容量是工程优化;但估算必须同时考虑负载因子,不能简单把预计元素数原样传给构造器。
7. 初始容量怎么选,为什么不是越大越好
假设预计放入 1000 个不同键,使用默认负载因子 0.75。new HashMap<>(1000) 并不代表"可以装 1000 个元素而不扩容":首次分配的表会取到 1024 个桶,阈值约为 768,放入第 769 个新映射后仍需扩容。若目标是容纳 1000 项而不触发这次扩容,估算值应至少为 1000 / 0.75 ≈ 1334,内部再取合适的 2 次幂容量。

Java 19 起提供了 HashMap.newHashMap(int numMappings),它直接接受预计映射数量,适合不想手算的代码;本文运行环境是 Java 21,可以使用这个工厂方法。面向旧版本源码时则要确认 API 是否存在,不能把 Java 21 的写法复制到 Java 8 项目。
容量也不能无限放大。Oracle 的 Java 21 API 文档指出,遍历集合视图所需时间与容量加元素数有关。一个只有几十个元素却预分配巨大桶数组的表,不仅浪费空间,遍历还要跨过更多空桶。默认负载因子 0.75 是时间和空间的折中,大部分业务没有证据时不必改它。
如果确实需要比较不同容量,避免用一次 System.nanoTime() 包住几百次操作便下结论。JVM 有类加载、即时编译、逃逸分析和垃圾回收,短循环很容易测到预热噪声。更可靠的方式是使用 JMH,分别构造"分布良好的键""人为碰撞键""大量遍历"三类基准,并报告 JDK、堆参数、元素规模、预热与测量轮次。容量优化只有在目标负载下改善了可观察指标才成立。
内存方面也应看对象全貌。桶数组保存引用,链表节点还包含 hash、key、value、next,树节点字段更多;键和值对象本身往往才是主要占用。仅凭 capacity 推算总内存会严重失真。需要定位大表时,可结合堆转储、对象直方图或 JFR 找到实例数量与保留关系,而不是通过反射把每张表都扫描一遍。
8. 七个比背源码更值得排查的问题
8.1 自定义键只重写 equals,没有重写 hashCode
相等对象可能被送到不同桶,覆盖、查询和去重表现都会违背预期。IDE 可以生成两个方法,但仍要检查选择的字段是否稳定、是否真正表达业务身份。
8.2 键入表后又修改参与哈希的字段
这是"明明打印得到键,get 却返回 null"的高频原因。优先使用不可变键,例如记录类、不可变值对象或稳定 ID;不要用会继续编辑的 DTO 整体充当键。
8.3 看到链表长度 8 就断言一定树化
同时检查表容量是否至少为 64。容量不足时,treeifyBin 会先扩容。诊断时还要确认实际 JDK 版本,不要拿 JDK 7 的结构解释 JDK 21。
8.4 把 HashMap 当作并发容器
Java 21 API 明确说明 HashMap 本身不同步。多个线程并发访问且至少一个线程进行结构修改时,需要外部同步,或选择 ConcurrentHashMap。即使没有复现到异常,也不代表数据竞争是安全的。
8.5 依赖遍历顺序
HashMap 不保证顺序,而且扩容会改变桶分布。需要插入顺序时选择 LinkedHashMap,需要排序时考虑 TreeMap;不要把一次测试中碰巧稳定的输出写进协议或断言。
8.6 初始容量直接填写预计元素数
元素数和桶数之间还隔着负载因子。大批量装载时按 预计数量 / loadFactor 估算,Java 19+ 可用 HashMap.newHashMap;小集合则不必为省一次微小扩容而夸张预分配。
8.7 用反射读取 table 作为业务逻辑
本文的反射探针只为验证机制。私有字段不是稳定 API,模块系统也会阻止默认访问。线上监控应该观察业务规模、耗时、分配和冲突键来源,而不是让业务依赖 table 或 TreeNode 的内部名字。
8.8 把不同 JDK 版本的结论拼在一起
JDK 7 的 HashMap 没有本文讨论的红黑树桶,扩容迁移细节也不同;Java 8 以后才形成读者熟悉的数组、链表与树结构。本文源码链接指向 OpenJDK 21u,实验运行在 Java 21.0.11。若项目仍在 Java 8、11 或 17,应打开对应版本源码复核,不要因为常量名字相同就默认每条内部路径永远不变。
8.9 把树化当成外部输入碰撞的完整安全方案
树化能限制长链表退化,但接口仍可能受到超大请求体、巨量不同键、昂贵 equals 或内存耗尽影响。接收外部批量参数时,应同时限制条目数量、键长度和请求大小,并评估业务对象的构造成本。数据结构的防退化设计是一道防线,不是输入治理的替代品。
9. 把源码知识落回代码评审
读完 putVal 后,代码评审可以少问"阈值是多少",多问几个会改变真实行为的问题:键是否不可变;equals/hashCode 是否一致;规模是否可估算;是否在并发修改;是否依赖遍历顺序;大量输入是否来自外部,恶意或低质量哈希能否制造集中碰撞。
上线前可以按下面的顺序检查:
- 明确运行 JDK 版本,源码与实验使用同一主版本。
- 检查自定义键的相等性与哈希契约,键入表后不再改变身份字段。
- 预计映射数量较大时,按负载因子设置容量或使用对应版本的工厂方法。
- 多线程结构修改改用并发容器或可靠的外部同步。
- 不依赖
HashMap遍历顺序,也不把内部节点类型写入业务判断。 - 性能异常时先记录元素数、键分布、分配与扩容现场,再决定是否调容量。
HashMap 的设计并不是"数组、链表、红黑树"三个名词的拼接。一次 put 会在延迟分配、同键覆盖、碰撞追加、优先扩容和条件树化之间做选择。把实验输出与源码条件对上,8、64、0.75 这些数字才不再是孤立答案。
参考资料