equals() 和 hashCode():契约和常见错误
1. 概述
equals() 和 hashCode() 是两个看起来很简单的方法,但如果实现错误,HashSet 就会包含重复元素,HashMap 就会“丢失” key。这些错误通常不会抛出异常。程序仍然在运行,只是运行结果错误。
本文介绍 Java 为这两个方法制定的契约(contract)。然后逐一讲解常见错误,每个错误都配有再现示例。读完本文,你将能正确实现这两个方法,知道何时应该使用 record,以及如何编写测试来检查实现。
2. equals() 的契约
默认情况下,Object.equals() 比较引用,即 this == obj。当你覆盖此方法时,必须对所有非空引用保持以下五个性质:
- 自反性(reflexive):
x.equals(x)始终为true。 - 对称性(symmetric):
x.equals(y)为true当且仅当y.equals(x)为true。 - 传递性(transitive): 如果
x.equals(y)且y.equals(z),则x.equals(z)。 - 一致性(consistent): 如果对象不变,多次调用必须返回相同的结果。
- 与 null 的关系:
x.equals(null)始终为false。
一个 Customer 类的标准实现如下:
import java.util.Objects;
public final class Customer {
private final String email;
private final String name;
public Customer(String email, String name) {
this.email = email;
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Customer other)) return false;
return Objects.equals(email, other.email)
&& Objects.equals(name, other.name);
}
@Override
public int hashCode() {
return Objects.hash(email, name);
}
}
逐部分解释:
this == o是一个快速路径,当两个引用指向同一个对象时。instanceof Customer other使用模式匹配(Java 16+)。这个表达式在o为null时返回false,所以无需单独检查就能满足“与 null 的关系”这一性质。Objects.equals对null字段是安全的。Objects.hash从equals()中使用的正确字段计算哈希值。
类被声明为 final。原因将在第 6 节中清楚。
3. hashCode() 的契约
hashCode() 有三个规则:
- 对同一个未变的对象多次调用必须返回相同的值。
- 如果
a.equals(b)则a.hashCode() == b.hashCode()。 这是最重要的规则。 - 两个不同的对象可以有相同的哈希码(碰撞)。不过,分布良好的哈希值会使集合运行得更快。
规则 2 的反向不成立。相同的哈希码并不意味着相等。
要理解为什么规则 2 很重要,看看 HashMap 如何查找一个 key。首先它使用 hashCode() 选择一个 bucket。然后它才使用 equals() 来比较该 bucket 中的元素。如果两个对象“相等”但哈希码不同,它们会被放在不同的 bucket 中,equals() 永远不会被调用。
4. 错误 1:覆盖 equals() 但忘记 hashCode()
这是一个经典错误。假设你从第 2 节的类中省略了 hashCode():
public class Customer {
private final String email;
public Customer(String email) { this.email = email; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Customer other)) return false;
return email.equals(other.email);
}
// 缺少 hashCode()
}
Set<Customer> customers = new HashSet<>();
customers.add(new Customer("an@example.com"));
customers.add(new Customer("an@example.com"));
System.out.println(customers.size());
System.out.println(customers.contains(new Customer("an@example.com")));
2
false
两个对象根据 equals() 相等。但 hashCode() 仍然是 Object 的默认实现,对每个实例通常不同。结果是 HashSet 包含两个“重复”元素,而 contains() 返回 false。这个结果几乎肯定会发生。两个默认哈希码重合是很罕见的,所以错误几乎总是会表现出来。
注意: IDE 和 SonarQube 等工具都会对这个错误发出警告。不要关闭这个警告。
5. 错误 2:重载而非覆盖
这个错误更难发现:
public class Order {
private final long id;
public Order(long id) { this.id = id; }
public boolean equals(Order other) { // 错误:参数是 Order,而不是 Object
return other != null && id == other.id;
}
@Override
public int hashCode() { return Long.hashCode(id); }
}
Order a = new Order(1);
Order b = new Order(1);
System.out.println(a.equals(b)); // 调用 equals(Order)
System.out.println(List.of(a).contains(b)); // 调用 equals(Object)
true
false
equals(Order) 是一个新方法(重载)。它不覆盖 equals(Object)。集合总是调用 equals(Object),所以它仍然使用 Object 的引用比较。
防止此错误非常简单:总是加上 @Override。如果你给上面的方法添加 @Override,编译器会立即报错。
6. 错误 3:继承时破坏对称性
考虑一个 Point 类和一个添加 color 字段的子类 ColorPoint:
public class Point {
protected final int x, y;
public Point(int x, int y) { this.x = x; this.y = y; }
@Override
public boolean equals(Object o) {
if (!(o instanceof Point p)) return false;
return x == p.x && y == p.y;
}
@Override
public int hashCode() { return Objects.hash(x, y); }
}
public class ColorPoint extends Point {
private final String color;
public ColorPoint(int x, int y, String color) { super(x, y); this.color = color; }
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint cp)) return false;
return super.equals(cp) && color.equals(cp.color);
}
@Override
public int hashCode() { return Objects.hash(x, y, color); }
}
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, "RED");
System.out.println(p.equals(cp));
System.out.println(cp.equals(p));
true
false
对称性被破坏了。而且 p.equals(cp) 是 true 但两个对象的哈希码不同,所以第 3 节的规则 2 也被违反了。
没有办法既能向子类添加有意义的字段,又能用 instanceof 保持完整的契约。你有三种选择:
- 声明类为
final或使用record。这是最简单的方式。 - 使用
getClass() != o.getClass()而不是instanceof。这样Point和ColorPoint永远不会相等。这种方式保持了契约,但即使子类没有添加字段(例如代理类),也不会等于父类。 - 优先使用组合: 让
ColorPoint包含一个Point而不是继承它。
7. 错误 4:key 中有可变字段
hashCode() 在对象被添加到 HashSet 时计算。如果之后参与哈希计算的字段发生变化,对象仍然会在旧的 bucket 中:
public class Product {
private String sku;
public Product(String sku) { this.sku = sku; }
public void setSku(String sku) { this.sku = sku; }
@Override
public boolean equals(Object o) {
return o instanceof Product p && Objects.equals(sku, p.sku);
}
@Override
public int hashCode() { return Objects.hashCode(sku); }
}
Product product = new Product("SKU-1");
Set<Product> catalog = new HashSet<>();
catalog.add(product);
product.setSku("SKU-2");
System.out.println(catalog.contains(product));
System.out.println(catalog.remove(product));
System.out.println(catalog.size());
false
false
1
对象仍然在 set 中,但你无法找到它,也无法删除它。结果是小的内存泄漏和错误的逻辑。
注意: 在
equals()/hashCode()中只使用不可变(immutable)字段。如果必须使用可变字段,不要在对象作为HashMap的 key 或HashSet的元素时修改它。正确的做法是移除对象,修改它,然后重新添加。
8. 错误 5:BigDecimal 和数组的陷阱
JDK 中的某些类型的 equals() 行为不如你想象的那样。
8.1. BigDecimal 比较 scale
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");
System.out.println(a.equals(b));
System.out.println(a.compareTo(b) == 0);
false
true
BigDecimal.equals() 将 1.0 和 1.00 视为不同,因为 scale 不同。如果你的 Money 类在 equals() 中使用 BigDecimal,请在 constructor 中规范化 scale。例如,调用 amount.setScale(2, RoundingMode.HALF_UP)。注意 stripTrailingZeros() 也可以规范化,但它可能导致负 scale,例如对于 100。
8.2. 数组按引用比较
数组不覆盖 equals()。因此 new int[]{1, 2}.equals(new int[]{1, 2}) 返回 false。当字段是数组时,使用 Arrays.equals() 和 Arrays.hashCode()。这个陷阱也适用于 record,如你在下一节将看到的。
9. 使用 record 避免大多数错误
从 Java 16 开始,record 基于所有组件自动生成 equals()、hashCode() 和 toString():
public record OrderLine(String productSku, int quantity) {}
var line1 = new OrderLine("SKU-1", 2);
var line2 = new OrderLine("SKU-1", 2);
System.out.println(line1.equals(line2));
System.out.println(line1.hashCode() == line2.hashCode());
true
true
record 是 final 类,字段是 final,两个方法都按照契约正确生成。第 4、5、6 节的错误消失了。第 7 节的错误也几乎消失了,除非组件是可变对象,如 mutable List。
使用 record 时需要记住两点:
- 组件为数组时仍然按引用比较。使用
List代替数组。 - 组件为
BigDecimal时仍然有 scale 陷阱。在 compact constructor 中规范化:
public record Money(BigDecimal amount, String currency) {
public Money {
amount = amount.setScale(2, RoundingMode.HALF_UP);
}
}
有了这个声明,new Money(new BigDecimal("1.0"), "VND") 等于 new Money(new BigDecimal("1.00"), "VND"),因为两者都被规范化为 scale 2。
10. 测试 equals() 和 hashCode()
你应该对主要行为有测试。以下是使用 JUnit 5 和 AssertJ 对第 2 节 Customer 类的测试:
import org.junit.jupiter.api.Test;
import java.util.HashSet;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class CustomerTest {
@Test
void equalCustomers_shouldHaveSameHashCode_andDeduplicateInSet() {
var a = new Customer("an@example.com", "An");
var b = new Customer("an@example.com", "An");
assertThat(a).isEqualTo(b);
assertThat(b).isEqualTo(a);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
Set<Customer> set = new HashSet<>(Set.of(a));
assertThat(set).contains(b);
}
@Test
void customer_shouldNotEqualNullOrOtherType() {
var a = new Customer("an@example.com", "An");
assertThat(a).isNotEqualTo(null);
assertThat(a).isNotEqualTo("an@example.com");
}
}
第一个测试检查对称性、“相等则哈希相同”规则,以及在 HashSet 中的实际行为。第二个测试检查 null 情况和不同类型的情况。
手工编写所有情况相当费时。EqualsVerifier 库只用一行代码就能检查整个契约:
<dependency>
<groupId>nl.jqno.equalsverifier</groupId>
<artifactId>equalsverifier</artifactId>
<version>3.16.1</version>
<scope>test</scope>
</dependency>
@Test
void equalsContract() {
EqualsVerifier.forClass(Customer.class).verify();
}
如果你在 hashCode() 中忘记了字段,使用了非 final 字段,或者类不是 final 但使用了 instanceof,测试会失败并附带指出哪条规则被违反的消息。
11. 何时不应该覆盖
并非每个类都需要覆盖 equals()/hashCode():
- 有自然标识的类,如
Thread、PaymentService或 Spring bean。两个服务是不同的对象,引用比较是正确的。 - JPA entity: 避免使用所有字段。也不要幼稚地只用
@Id,因为id在持久化前是null,在保存后会改变哈希值。常见做法是用不可变的业务键进行比较。另一种做法是当id非空时按id比较,当id为空时让hashCode()返回一个类的常数,例如getClass().hashCode()。 - Lombok 的
@Data在 entity 或有双向关系的类上: 这个注解生成使用所有字段的equals()/hashCode()。这可能导致互相引用的两个 entity 发生StackOverflowError,或无意中触发 lazy loading。
12. 结论
equals() 和 hashCode() 之间的契约围绕一个核心规则:两个相等的对象必须有相同的哈希码。现实中大多数错误来自五个原因:
- 忘记覆盖
hashCode(); - 重载
equals(Order)而不是覆盖equals(Object); - 继承导致破坏对称性;
- 在 key 中使用可变字段;
BigDecimal和数组的陷阱。
在新代码中,为 value object 优先使用 record。对于普通类,声明为 final,总是加上 @Override,只使用不可变字段。最后,添加一个使用 EqualsVerifier 的测试,让工具代你检查契约。