HashMap 的键为什么最好不可变:从一次查找说起
理解 hashCode、equals 和可变键之间的关系,避免把放进去的数据变成“查不到的条目”。
用对象作为 HashMap 的键非常常见,例如用订单编号、用户标识或一个复合条件做缓存索引。真正需要问的不是“对象能不能当键”,而是:当对象放进 Map 之后,参与相等性判断的状态还会改变吗?
示例使用 Java 17 及以上版本。代码用于说明集合契约,不涉及性能跑分。
一次 get,大致需要回答两个问题
首先,键的 hashCode() 帮助确定候选位置;然后,Map 使用相等性判断从候选条目中找到目标。不同键可以拥有相同的哈希值,所以“哈希相同”不等于“对象相等”。反过来,若两个对象通过 equals 判断相等,它们必须返回相同的哈希值。
这也是为什么自定义 equals 时通常需要同时实现 hashCode。只覆盖其中一个,代码也许能编译,但集合行为可能不符合你的直觉。
问题藏在放入之后的修改里
import java.util.HashMap;
import java.util.Map;
public class MutableKeyDemo {
static final class Key {
int id;
Key(int id) { this.id = id; }
@Override public int hashCode() { return id; }
@Override public boolean equals(Object other) {
return other instanceof Key k && id == k.id;
}
}
public static void main(String[] args) {
Key key = new Key(1);
Map<Key, String> map = new HashMap<>();
map.put(key, "saved");
System.out.println(map.get(key)); // saved
key.id = 2; // 改变了参与哈希计算的状态
System.out.println(map.get(key)); // 此例中为 null
System.out.println(map.size()); // 条目仍然存在
}
}Map 存储条目时记录了当时的哈希信息。修改键后,新的查找不一定能定位到原条目。即使拿着同一个对象引用,也不能把它当作“必然找得到”的保证。不要依赖修改后偶然还能查到的情况。
把稳定身份表达成类型
import java.util.HashMap;
import java.util.Map;
public class StableKeyDemo {
record UserKey(long tenantId, long userId) {}
public static void main(String[] args) {
Map<UserKey, String> names = new HashMap<>();
names.put(new UserKey(7, 42), "Ada");
System.out.println(names.get(new UserKey(7, 42))); // Ada
}
}这里的两个分量都是基本类型,record 提供的相等性与哈希实现足够清楚。需要留意,record 的不可变性是浅层的:如果分量是一个可变 List,List 的内容仍然可能变化。可以在构造阶段使用 List.copyOf 固定列表结构;若元素本身可变,还需要进一步处理。
读代码时的三个检查点
- 键是否代表稳定的业务身份,而不是对象当前展示状态?
- 参与 equals 和 hashCode 的字段是否在键存活期间保持稳定?
- 共享 Map 是否需要并发控制?键不可变并不会使普通 HashMap 自动线程安全。
尽量把约束交给类型表达,比依靠每个调用方记住“放进去之后千万别改”更容易维护。