← 返回题目列表

Java 中 equals() 和 hashCode() 有什么关系?

高频 中等 第 15 / 32 题 更新于 2026/07/25
Java 基础equalshashCode

简化版

equals() 判断两个对象在业务语义上是否相等,hashCode() 返回一个用于哈希表分桶的整数。核心约定只有一句:两个对象 equals() 相等,hashCode() 就必须相等。所以重写 equals() 时几乎总要一起重写 hashCode()

详细版

Object 默认的 equals() 比的是对象地址(this == other),hashCode() 默认和对象的内存地址相关。对业务类来说这套默认行为往往不对——我们想要的是「两个 User 的 id 一样就算同一个人」,而不是「必须是同一个对象引用」。

于是我们重写 equals()。但如果只重写 equals() 不重写 hashCode(),把对象放进 HashMapHashSet 时就会出问题:哈希容器先用 hashCode() 定位桶,再在桶里用 equals() 比。两个业务上相等的对象若 hashCode() 不同,会被分到不同的桶,容器就认为它们是两个不同的键——于是出现「明明 put 进去了却 get 不到」「Set 里出现重复元素」。

反过来,hashCode() 相等不能推出 equals() 相等,因为不同对象可能哈希碰撞落到同一个桶,这时才靠 equals() 做最终区分。

class User {
    private final Long id;
    private final String name;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof User u)) return false;
        return Objects.equals(id, u.id) && Objects.equals(name, u.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, name);   // 参与 equals 的字段,一字不差
    }
}

完整版教学

一、为什么哈希容器要「双保险」

HashMap 找一个 key 的过程是两步:先 hashCode() 算出该放在哪个桶(O(1) 定位),再在桶内链表/红黑树上逐个 equals() 比对。

为什么不直接全表 equals()?因为那是 O(n),哈希表存在的意义就是用 hashCode() 把范围从「整张表」缩小到「一个桶」。而为什么定位到桶后还要 equals()?因为哈希值只有那么多种,不同对象必然会碰撞到同一个桶,最终判等还得靠 equals()

理解了这个两步流程,那条约定就顺理成章了:equals() 相等的对象必须 hashCode() 相等,否则它们会被分到不同桶,第二步 equals() 根本没机会执行

二、equals 的五条契约

equals() 不是随便写的,Object 文档规定它必须满足:

  • 自反性a.equals(a) 恒为 true。
  • 对称性a.equals(b)b.equals(a) 结果一致。
  • 传递性a.equals(b)b.equals(c),则 a.equals(c)
  • 一致性:对象不变时,多次调用结果一致。
  • 非空性a.equals(null) 恒为 false。

⚠️ 对称性最容易被破坏:父类 Point 和子类 ColorPoint 之间用 instanceof 混写 equals,常常出现 p.equals(cp) 为 true 但 cp.equals(p) 为 false。稳妥做法是用 getClass() != o.getClass() 精确判类型,或干脆用组合代替继承。

三、hashCode 写得「合法」但「很烂」

只要「equals 相等 ⇒ hashCode 相等」,hashCode() 永远返回常量 1 都是合法的。但那样所有对象都挤进同一个桶,HashMap 退化成一条链表(JDK 8 后桶内元素超阈值转红黑树,也只是从 O(n) 变 O(log n)),完全失去哈希表的性能优势。

所以好的 hashCode() 要让不同对象尽量散开。实践上直接用 Objects.hash(field1, field2, ...) 就够了,它内部就是 31 * result + fieldHash 的经典累积——用 31 是因为它是奇质数,且 31 * i == (i << 5) - i,JVM 能优化成移位。

四、最容易踩的坑:可变字段参与哈希

如果把一个可变字段放进 hashCode(),然后把对象作为 key 放进 HashMap,之后又改了这个字段——对象的 hashCode 变了,但它还待在按旧 hashCode 分好的桶里。此后你再也 get 不到它了:

Set<User> set = new HashSet<>();
User u = new User(1L, "old");
set.add(u);
u.setName("new");          // 改了参与 hashCode 的字段
set.contains(u);           // false!它还在按 "old" 算出的桶里

记忆点:用作哈希键的对象,参与 equals/hashCode 的字段最好是不可变的(用 final)。这也是为什么 String、包装类适合当 key。

五、用数字走一遍查找过程

假设哈希表当前有 16 个桶,两个业务上相等的订单键都表示 orderId=42。如果它们都返回哈希值 106,经过扰动和下标计算后都会进入同一个桶,容器才有机会调用 equals() 确认相等;如果其中一个仍使用 Object.hashCode() 得到 9817,它们可能落入不同桶,查找会直接错过目标。

bucketIndex = spread(hashCode) & (tableLength - 1)
106  → spread → 桶 10 ─┐
106  → spread → 桶 10 ─┴→ equals 比较 → 同一个业务键
9817 → spread → 桶  3    → 根本遇不到桶 10 中的对象
关系是否一定成立原因
a.equals(b) 为 true → 哈希相等哈希容器契约要求
哈希相等 → a.equals(b) 为 true有限整数空间必然会碰撞
a == ba.equals(b) 为 true通常是同一非空对象应满足自反性
字段改变 → 哈希不变不一定取决于改变的字段是否参与计算

这个流程也解释了为什么哈希值“分布均匀”影响性能,而 equals/hashCode“字段一致”影响正确性:一个是减少桶内比较次数,一个是确保相等对象能相遇。

六、常见误区与追问

  • 误区:只重写 equals() 就足够。 哈希容器先按哈希值选桶,哈希不一致时不会跨桶调用 equals()
  • 误区:hashCode() 相等就说明对象相等。 哈希碰撞是正常现象,最终业务判等仍由 equals() 完成。
  • 误区:让 hashCode() 永远返回 1 不影响使用。 契约虽未破坏,但所有键挤在一个桶里,性能严重退化。
  • 追问:为什么参与判等的字段最好不可变? 入表后字段变化会改变目标桶,而节点仍留在旧桶,造成查找失败。
  • 追问:record 是否还要手写这两个方法? Java record 会根据全部组件生成一致的 equals()hashCode();若业务身份不是全部组件,仍需重新审视模型。
  • 追问:继承层次中为何难写 equals? 父子类增加不同状态时容易破坏对称性或传递性,值对象通常优先 final 类或组合设计。

记忆钩子:hashCode() 负责“把候选人带进同一间屋”,equals() 才负责“确认是不是同一个人”。

测试自定义值对象时,不要只测一对正常数据。至少构造自反、相等副本、不同字段、null 和哈希容器查找五组用例;若允许继承,还要加入父子对象双向比较,专门验证对称性。

assertEquals(a, copyOfA);
assertEquals(a.hashCode(), copyOfA.hashCode());
assertTrue(new HashSet<>(List.of(a)).contains(copyOfA));

最后一条把方法契约放进真实容器流程验证,比只调用一次 equals() 更容易发现“判等正确但分桶错误”的组合缺陷。

七、加强记忆

把哈希容器的两段式查找记牢:先用 hashCode() 缩小到一个桶,再用 equals() 做最终判等。由此自然推出相等对象必须产生相同哈希,但相同哈希允许碰撞;两种方法必须使用同一组业务身份字段,这些字段作为键时还应保持稳定。正确性靠契约,性能靠分布,二者缺一不可。