跳到主要内容

equals、hashCode 与对象身份

== 判断两个引用是否指向同一个对象,equals 可以定义两个对象在业务上是否相等,hashCode 则让哈希集合能够先缩小查找范围。三者回答的问题不同,但自定义对象相等性时必须保持一致。

1. == 判断对象身份

基本类型使用 == 比较具体的值,引用类型使用 == 判断两个引用是否都为 null,或者是否指向同一个对象:

String first = new String("Java");
String second = new String("Java");
String alias = first;

System.out.println(first == second); // false
System.out.println(first == alias); // true

firstsecond 指向两个独立对象,即使内容相同,== 仍然返回 falsealias 保存了与 first 相同的引用值,所以结果为 true

对象身份不需要解释成“比较内存地址”。Java 语言规范只保证引用相等的语义,垃圾收集器可以移动对象,JVM 也可以选择不同的内部表示。

1.1 字符串常量池会影响 == 的结果

String left = "Java";
String right = "Java";

System.out.println(left == right); // 通常为 true,同一个驻留字符串对象

这不意味着字符串内容应该使用 == 比较。字符串可能来自文件、网络或运行时拼接,并不保证引用相同。判断内容时使用 equals

System.out.println(left.equals(right));

2. equals 定义逻辑相等

Object.equals 的默认实现与引用相等相同,只有同一个对象才相等。值对象通常需要根据组成它的字段定义逻辑相等。

例如,订单编号相同就表示同一个订单标识:

import java.util.Objects;

public final class OrderId {
private final String value;

public OrderId(String value) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("value must not be blank");
}
this.value = value;
}

@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof OrderId orderId)) {
return false;
}
return value.equals(orderId.value);
}

@Override
public int hashCode() {
return value.hashCode();
}
}

先判断 this == other 是一种快速路径,不是正确性的必要条件。随后检查类型,再比较决定逻辑身份的字段。

2.1 equals 必须满足的约束

对所有非 null 引用,正确的 equals 实现应满足:

  • 自反性x.equals(x)true
  • 对称性x.equals(y)y.equals(x) 结果相同。
  • 传递性:如果 x 等于 yy 等于 z,那么 x 也等于 z
  • 一致性:参与相等判断的信息未改变时,多次调用结果保持一致。
  • 非空性x.equals(null)false

这些约束让集合、缓存和业务代码能够稳定使用对象。实现 equals 时只看某些字段,意味着这些字段共同定义了对象的逻辑身份。

3. hashCode 支持哈希查找

HashMapHashSet 不会从头对所有元素逐个调用 equals。它们先使用 hashCode 定位可能的桶,再在桶内比较键。

因此,equalshashCode 必须满足以下关系:

如果两个对象通过 equals 判断相等,它们的 hashCode 必须相同。

反过来不成立。两个不相等的对象可以有相同的哈希值,集合会继续使用 equals 区分它们。

OrderId first = new OrderId("ORDER-101");
OrderId second = new OrderId("ORDER-101");

System.out.println(first == second); // false
System.out.println(first.equals(second)); // true
System.out.println(first.hashCode()
== second.hashCode()); // true

3.1 为什么重写 equals 时通常必须重写 hashCode

如果只重写 equals,两个逻辑相等的对象仍然可能继承各自的 Object.hashCode 结果。它们会被分配到不同位置,HashSet 可能保存两个“相等”元素,HashMap.get 也可能找不到使用等价键写入的值。

Map<OrderId, String> orders = new HashMap<>();
orders.put(new OrderId("ORDER-101"), "paid");

System.out.println(orders.get(new OrderId("ORDER-101"))); // paid

这段代码依赖 OrderId.equalsOrderId.hashCode 使用相同的身份字段。

3.2 哈希值相同不代表对象相等

hashCode 只有 32 位,可能的对象和值远多于可用哈希值,冲突无法彻底避免。好的实现尽量让常见输入分布均匀,但最终相等性仍由 equals 判断。

返回常量也可以满足“相等对象哈希相同”的最低约束,却会让所有键落入同一位置,使哈希查找失去效率。正确性契约和分布质量都需要考虑。

4. 相等性应该基于稳定字段

对象作为 HashMap 的键或 HashSet 的元素后,如果参与 equalshashCode 的字段发生变化,集合可能无法在原来的位置找到它:

final class MutableKey {
private String value;

MutableKey(String value) {
this.value = value;
}

void setValue(String value) {
this.value = value;
}

@Override
public boolean equals(Object other) {
return other instanceof MutableKey key
&& Objects.equals(value, key.value);
}

@Override
public int hashCode() {
return Objects.hashCode(value);
}
}

MutableKey key = new MutableKey("A");
Set<MutableKey> keys = new HashSet<>();
keys.add(key);

key.setValue("B");
System.out.println(keys.contains(key)); // 通常为 false

元素加入集合时按照 "A" 的哈希值放入某个桶。字段改为 "B" 后,查询会按照新的哈希值寻找,原元素却仍在旧位置。

适合作为哈希键的值对象通常具备以下特点:

  • 身份字段在构造后不再变化。
  • equalshashCode 使用相同字段。
  • 字段本身也具有稳定的相等性。

Java 的 record 会根据所有组件自动生成 equalshashCode,适合全部组件共同定义相等性的简单值对象:

public record OrderId(String value) {
public OrderId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("value must not be blank");
}
}
}

5. 继承会增加相等性设计的难度

如果父类只比较父类字段,子类又增加参与身份判断的字段,instanceof 风格的实现可能破坏对称性或传递性。

假设 Money.equals 认为所有金额和币种相同的子类都相等,而 DiscountedMoney.equals 还要求折扣来源相同:

  • money.equals(discountedMoney) 可能为 true
  • discountedMoney.equals(money) 可能为 false

常见处理方式包括:

  • 将值类声明为 final,不允许子类改变相等性语义。
  • 使用 record 表达最终的值类型。
  • 用组合增加附加信息,不让子类扩展父类的身份字段。
  • 如果继承确实存在,明确整个层次的相等性协议并进行成组测试。

使用 getClass() 可以要求两个对象具有完全相同的运行时类型,但也会让父类对象与子类对象永远不相等。它不是通用答案,仍然要先确定业务上的身份定义。

6. 常见问题

6.1 Objects.equals(a, b) 做了什么

它先处理 null:两个引用都为 null 时返回 true,只有一个为 null 时返回 false;否则调用第一个对象的 equals。它简化了空值判断,但不会替对象设计正确的相等性。

6.2 两个对象的 hashCode 不同,是否一定不相等

equalshashCode 实现符合契约的前提下,一定不相等。相等对象必须具有相同哈希值,因此哈希值不同可以直接排除相等。

6.3 两个对象的 hashCode 相同,是否一定相等

不一定。哈希冲突是允许的,还需要调用 equals 判断。

6.4 System.identityHashCode 有什么作用

System.identityHashCode(object) 返回接近 Object.hashCode 默认语义的哈希值,即使对象重写了 hashCode 也不会改用逻辑相等性。它可用于诊断基于对象身份的问题,但返回值不是内存地址,也不能作为全局唯一标识。

6.5 实体对象应该使用数据库主键还是业务字段判断相等

没有适用于所有实体的固定答案。数据库生成主键在持久化前可能不存在,可变业务字段又不适合作为哈希键。需要根据对象生命周期、ORM 代理、集合使用方式和领域身份共同确定,并确保同一生命周期内契约稳定。

7. 面试题

7.1 ==equalshashCode 有什么关系

出现公司:字节跳动、中软国际

考察重点

  • 引用身份与逻辑相等的区别。
  • Object.equals 的默认行为和自定义相等性的约束。
  • 哈希集合为什么要求相等对象具有相同哈希值。

相关内容:第 1 节“== 判断对象身份”、第 2 节“equals 定义逻辑相等”、第 3 节“hashCode 支持哈希查找”。

参考回答

对引用类型来说,== 判断两个引用是否都为 null 或指向同一个对象。Object.equals 默认也使用对象身份,类可以重写它来定义业务上的逻辑相等。

hashCode 用于让哈希集合先定位候选位置。契约要求两个对象只要 equalstrue,哈希值就必须相同;哈希值相同却不代表对象相等,还要继续比较 equals。因此自定义逻辑相等时,通常要使用相同字段同时重写 equalshashCode

7.2 可变对象能否作为 HashMap 的键

出现公司:字节跳动

考察重点

  • 哈希集合写入和查找时怎样使用哈希值。
  • 身份字段变化后为什么可能无法找到原来的键。
  • 怎样设计稳定的值对象。

相关内容:第 3 节“hashCode 支持哈希查找”、第 4 节“相等性应该基于稳定字段”。

参考回答

技术上可以,但作为键使用期间,参与 equalshashCode 的字段不能变化。对象放入 HashMap 时会按照当时的哈希值定位;字段变化导致哈希值改变后,查询会从另一个位置开始,可能找不到仍在旧位置的键。

更稳妥的做法是使用不可变值对象作为键,在构造时确定身份字段,并让 equalshashCode 基于同一组字段。