bigywater.tech
Java 内部原理 · 10

HashMap 的 key 字段变了:真实测出对象没丢,只是永远找不到了

"别把可变对象当 HashMap 的 key"是又一句听过很多遍的忠告,但很少有人真的拆开看过:字段改了之后,那个对象到底是被删掉了,还是仍然在表里、只是找不到路过去了?用反射扫描 HashMap 的内部 table,把答案变成具体的桶下标。

7 号桶 → 14 号桶
真实反射测出:对象插入时落在 7 号桶,改字段后 get() 却去 14 号桶找——对象没动,门牌号算错了
size=1,却 get 不到
get/containsKey/remove 真实全部失效(null/false/null),但 size() 全程真实保持 1,entrySet 仍能遍历到它

上一篇:HashMap 树化:真实测出是插入第 9 个才变红黑树,不是第 8 个

思路:HashMap 定位 key 靠的是"当下"的 hashCode

put 的时候算一次 hashCode 决定放进哪个桶;get 的时候再算一次 hashCode 决定去哪个桶找——这两次计算之间,如果 key 的状态变了,答案就可能对不上。

造一个用可变字段参与 hashCode()/equals() 的 Key 类——这是现实代码里最容易犯的写法之一,比如拿一个有 setter 的字段做业务标识:

MutableKeyBug.java(问题类) static final class Key { final int id; String tag; // 注意:非 final,可以被外部修改 Key(int id, String tag) { this.id = id; this.tag = tag; } @Override public int hashCode() { return tag.hashCode(); } @Override public boolean equals(Object o) { if (!(o instanceof Key)) return false; return tag.equals(((Key) o).tag); } }

真实验证:插入正常,改字段之后 get/containsKey/remove 全部失效

同一个对象引用,插入前后什么都没删,只改了一个字段的值。

真实实测 · 插入时 tag=alpha
get(同一个引用) = value-for-alpha
containsKey(同一个引用) = true
插入时真实落进的桶下标(按 tag=alpha 算) = 7
扫描全表找到该 Key 对象实际所在的桶下标 = 7

反射拿到 HashMap.table 字段,能确认此刻两个下标是一致的:对象按 hashCode() 该落进 7 号桶,扫描全表也确实在 7 号桶里找到了它。现在把 k.tag 从 "alpha" 改成 "beta"——注意,这行代码没有调用任何 Map 的方法,单纯是改了对象的一个字段:

真实实测 · 把 k.tag 改成 beta 之后,同一个 map、同一个引用
get(同一个引用) = null
containsKey(同一个引用) = false
remove(同一个引用) 返回值 = null
上面 remove 调用之后 map.size() = 1
get()/remove() 现在会去搜索的桶下标(按新 hashCode beta 算) = 14
扫描全表找到该 Key 对象实际仍然待着的桶下标 = 7
get、containsKey、remove 三个最基本的操作全部真实失效——不是因为对象被删了(size 全程是 1),而是 get() 拿新的 hashCode 算出该去 14 号桶找,可对象其实还老老实实待在 7 号桶里,两边永远对不上

再用 entrySet() 遍历验证一下这个对象到底还在不在——entrySet 的遍历是按物理顺序扫描整个 table,不经过 hashCode() 定位:

真实实测 · entrySet() 遍历
遍历到: Key(id=1, tag=beta) -> value-for-alpha
map.size() = 1
对象真实还在表里,value 也没丢,只是永远没法再通过 get/containsKey/remove 这些依赖 hashCode 定位的方法访问到它——这类对象在实际项目里往往被戏称为"僵尸 entry":删不掉、找不到,还占着内存,直到整个 Map 被回收

真实验证:正确做法是"先 remove,改完字段再 put 回去"

既然问题出在"两次哈希定位之间状态变了",那就把改字段这件事挪到 Map 的可见范围之外去做。

真实实测 · remove → 改字段 → put 回去
mutate 前 remove(k) 返回 = value-for-alpha
mutate 后重新 put 完成
get(同一个引用) = value-for-alpha
containsKey(同一个引用) = true
map.size() = 1
先把对象从表里真实摘出来,这时候它已经不受 HashMap 内部结构约束,可以随便改字段;改完再用新的 hashCode 重新 put 一次,HashMap 会按新状态重新算桶下标——两次哈希定位之间对象状态没有偷偷变化,结果就正常了
更根本的做法是什么?上面这种"先摘出来再放回去"的补救属于亡羊补牢,真正应该做的是从设计上避免这个场景:参与 hashCode()/equals() 的字段应该全部声明成 final,构造之后不可变。如果业务上确实需要一个"会变的标识",应该用另一个专门的不可变字段(比如自增 id 或 UUID)做 key,把会变的部分作为 value 的一部分,而不是让它参与 key 的身份判定。

分步演示:两个下标是怎么"分家"的

每一帧的桶下标都是真实反射读出 HashMap.table 得到的,不是手工排的。

put → 改字段 → get/containsKey/remove 全部失效 → entrySet() 仍能找到:7 号桶和 14 号桶为什么永远对不上
get()/containsKey()/remove() 这次要查的桶对象真正待着的桶

    参考与说明

    • 本文所有下标、返回值均在本机真实运行 Java 17(反射需要 --add-opens java.base/java.util=ALL-UNNAMED)得到,"7 号桶变 14 号桶"这组数字是这次真实运行的结果,不同 JVM/不同字符串内容跑出的具体桶号会不一样,但"两个下标不一致导致找不到"这个结论是确定的。
    • 定位桶下标用的反射手法和HashMap 容量与阈值、HashMap 树化两篇是同一套。
    • 没有涉及:HashSet(内部就是包了一层的 HashMap)在同样场景下的表现——原理完全一致,这里不重复验证;TreeMap 因为不依赖 hashCode 而是依赖 compareTo,不在本文讨论范围内。
    京ICP备2026067502号-1京公网安备11010502063487号