WMX · 随记
·Java· 5 分钟阅读

HashMap 的键为什么最好不可变:从一次查找说起

理解 hashCode、equals 和可变键之间的关系,避免把放进去的数据变成“查不到的条目”。

HashMap 查找示意:稳定的键生成哈希,定位桶,再通过 equals 匹配条目。
HashMap 查找示意:稳定的键生成哈希,定位桶,再通过 equals 匹配条目。 点击图片查看大图 ↗

用对象作为 HashMap 的键非常常见,例如用订单编号、用户标识或一个复合条件做缓存索引。真正需要问的不是“对象能不能当键”,而是:当对象放进 Map 之后,参与相等性判断的状态还会改变吗?

示例使用 Java 17 及以上版本。代码用于说明集合契约,不涉及性能跑分。

一次 get,大致需要回答两个问题

首先,键的 hashCode() 帮助确定候选位置;然后,Map 使用相等性判断从候选条目中找到目标。不同键可以拥有相同的哈希值,所以“哈希相同”不等于“对象相等”。反过来,若两个对象通过 equals 判断相等,它们必须返回相同的哈希值。

这也是为什么自定义 equals 时通常需要同时实现 hashCode。只覆盖其中一个,代码也许能编译,但集合行为可能不符合你的直觉。

问题藏在放入之后的修改里

Java23 行 · 可横向滚动
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 存储条目时记录了当时的哈希信息。修改键后,新的查找不一定能定位到原条目。即使拿着同一个对象引用,也不能把它当作“必然找得到”的保证。不要依赖修改后偶然还能查到的情况。

把稳定身份表达成类型

Java12 行 · 可横向滚动
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 自动线程安全。

尽量把约束交给类型表达,比依靠每个调用方记住“放进去之后千万别改”更容易维护。

继续阅读

← 返回文章归档