"别把可变对象当 HashMap 的 key"是又一句听过很多遍的忠告,但很少有人真的拆开看过:字段改了之后,那个对象到底是被删掉了,还是仍然在表里、只是找不到路过去了?用反射扫描 HashMap 的内部 table,把答案变成具体的桶下标。
上一篇:HashMap 树化:真实测出是插入第 9 个才变红黑树,不是第 8 个
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(同一个引用) = value-for-alpha containsKey(同一个引用) = true 插入时真实落进的桶下标(按 tag=alpha 算) = 7 扫描全表找到该 Key 对象实际所在的桶下标 = 7
反射拿到 HashMap.table 字段,能确认此刻两个下标是一致的:对象按 hashCode() 该落进 7 号桶,扫描全表也确实在 7 号桶里找到了它。现在把 k.tag 从 "alpha" 改成 "beta"——注意,这行代码没有调用任何 Map 的方法,单纯是改了对象的一个字段:
get(同一个引用) = null containsKey(同一个引用) = false remove(同一个引用) 返回值 = null 上面 remove 调用之后 map.size() = 1 get()/remove() 现在会去搜索的桶下标(按新 hashCode beta 算) = 14 扫描全表找到该 Key 对象实际仍然待着的桶下标 = 7get、containsKey、remove 三个最基本的操作全部真实失效——不是因为对象被删了(size 全程是 1),而是 get() 拿新的 hashCode 算出该去 14 号桶找,可对象其实还老老实实待在 7 号桶里,两边永远对不上
再用 entrySet() 遍历验证一下这个对象到底还在不在——entrySet 的遍历是按物理顺序扫描整个 table,不经过 hashCode() 定位:
遍历到: Key(id=1, tag=beta) -> value-for-alpha map.size() = 1对象真实还在表里,value 也没丢,只是永远没法再通过 get/containsKey/remove 这些依赖 hashCode 定位的方法访问到它——这类对象在实际项目里往往被戏称为"僵尸 entry":删不掉、找不到,还占着内存,直到整个 Map 被回收
既然问题出在"两次哈希定位之间状态变了",那就把改字段这件事挪到 Map 的可见范围之外去做。
mutate 前 remove(k) 返回 = value-for-alpha mutate 后重新 put 完成 get(同一个引用) = value-for-alpha containsKey(同一个引用) = true map.size() = 1先把对象从表里真实摘出来,这时候它已经不受 HashMap 内部结构约束,可以随便改字段;改完再用新的 hashCode 重新 put 一次,HashMap 会按新状态重新算桶下标——两次哈希定位之间对象状态没有偷偷变化,结果就正常了
每一帧的桶下标都是真实反射读出 HashMap.table 得到的,不是手工排的。