可变对象作为哈希表 key 会有什么问题?
简化版
可变对象作为哈希表 key 最大风险是:放入 HashMap 后,参与 hashCode 或 equals 的字段被修改,key 的桶位置和判等语义变了,导致明明对象还在表里却 get 不到。哈希 key 最好不可变。
详细版
HashMap 查找 key 时先算 hash 定位桶,再在桶内用 equals 比较。如果 key 入表后字段变化,新的 hash 会定位到另一个桶,而对象仍躺在旧桶里,查找自然失败。
User u = new User(1, "A");
Map<User, String> map = new HashMap<>();
map.put(u, "ok");
u.setId(2); // id 参与 hashCode/equals
System.out.println(map.get(u)); // 可能得到 null
解决方法是让 key 不可变,或不要把会变化的字段放进 equals/hashCode,更推荐用稳定业务 id、String、Long、record 等作 key。
完整版教学
一、HashMap 查找依赖两件事
HashMap 不是拿着 key 全表逐个比。它先调用 hashCode() 算出桶位置,再在对应桶里用 equals() 找精确 key。也就是说,hash 决定“去哪间房找”,equals 决定“房间里是不是这个人”。
key -> hashCode -> bucket index -> bucket nodes -> equals
如果 key 的 hash 变化,查找会走到新桶;但节点还在旧桶,于是错过。
二、可变字段为什么危险
假设 User.id 参与 hashCode。入表时 id=1,对应桶 5;后来 id 改成 2,对应桶 9。对象本体仍在桶 5,但 get(u) 会去桶 9 找。
| 时刻 | id | hash 对应桶 | 对象实际位置 |
|---|---|---|---|
| put 时 | 1 | 5 | 桶 5 |
| 修改后 | 2 | 9 | 桶 5 |
| get 时 | 2 | 9 | 找不到 |
这就是“幽灵 key”:看似还在 Map 中,却用正常方式取不出来。
三、equals 改变也会出问题
即使 hash 没变,如果参与 equals 的字段变了,也可能导致桶内比较失败。哈希容器要求 key 的判等语义在存储期间保持稳定,否则 contains、remove、get 都可能表现异常。
例如两个对象原本不相等,修改字段后变成相等,HashSet 里可能出现逻辑重复;或者原本相等的对象改成不相等,查找副本时失败。
四、怎样设计安全 key
安全 key 的原则是不可变、hashCode 和 equals 一致、字段语义稳定。常用做法包括:
| 做法 | 说明 |
|---|---|
使用 String/Long/Integer | 天然不可变 |
| 使用不可变值对象 | 字段 final,不暴露 setter |
| 使用稳定业务 id | 不把昵称、状态等易变字段放入 key |
| 入表后不修改 key | 靠代码约束,但风险较高 |
Java record 默认根据组件生成 equals/hashCode,但组件本身若引用可变对象,仍要小心。
五、如何修复已经踩坑的场景
如果必须修改 key 字段,正确顺序是先从 Map 中 remove 旧 key,修改字段,再重新 put。不要在 Map 内直接改 key。
map.remove(u);
u.setId(2);
map.put(u, "ok");
更好的工程设计是让 Map key 和业务对象分离,例如 Map<Long, User>,用稳定 id 作 key,User 作为 value 可以自由变化。
六、常见误区与追问
记忆钩子:哈希 key 一旦入表,就像贴了桶号标签;你改了能决定桶号的字段,标签和实际位置就对不上了。
- 误区:对象引用没变,所以一定能取到。 HashMap 查找不是按引用全表找,而是先按 hash 定桶。
- 误区:只要不改 hashCode 字段就安全。 equals 字段变化也可能让桶内匹配失败。
- 误区:可变对象绝对不能作 key。 可以,但必须保证参与哈希和判等的字段在存储期间不变。
- 追问:为什么 String 适合作 key? String 不可变且缓存 hash,语义稳定。
- 追问:HashMap 里已经坏了怎么办? remove 旧 key 通常也可能失败,必要时重建 Map。
- 追问:IdentityHashMap 会不会受影响? 它按引用身份比较,不看业务字段,但语义也完全不同。
七、加强记忆
可变 key 的问题要抓住 HashMap 两阶段查找:hashCode 定桶,equals 定对象。入表后只要参与这两者的字段变了,就可能 get/remove/contains 失效。最稳的设计是用不可变、稳定、语义明确的值作为 key。