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() 有三个规则:

  1. 对同一个未变的对象多次调用必须返回相同的值。
  2. 如果 a.equals(b) 则 a.hashCode() == b.hashCode()。 这是最重要的规则。
  3. 两个不同的对象可以有相同的哈希码(碰撞)。不过,分布良好的哈希值会使集合运行得更快。

规则 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 的测试,让工具代你检查契约。

javaequalshashcodecollectionsrecord