equals() và hashCode(): hợp đồng và lỗi hay gặp

1. Tổng quan

equals() và hashCode() là hai method trông đơn giản, nhưng cài sai thì HashSet sẽ chứa phần tử trùng và HashMap sẽ “mất” key. Các lỗi này thường không ném exception. Chương trình vẫn chạy, chỉ là chạy sai.

Bài này trình bày hợp đồng (contract) mà Java đặt ra cho hai method. Sau đó bài đi qua các lỗi hay gặp, mỗi lỗi kèm một ví dụ tái hiện. Đọc xong, bạn sẽ cài đặt đúng hai method, biết khi nào nên dùng record và biết cách viết test để kiểm tra.

2. Hợp đồng của equals()

Mặc định, Object.equals() so sánh tham chiếu, tức là this == obj. Khi override, bạn phải giữ năm tính chất sau với mọi tham chiếu khác null:

  • Phản xạ (reflexive): x.equals(x) luôn là true.
  • Đối xứng (symmetric): x.equals(y) là true khi và chỉ khi y.equals(x) là true.
  • Bắc cầu (transitive): nếu x.equals(y) và y.equals(z) thì x.equals(z).
  • Nhất quán (consistent): nếu object không đổi, gọi nhiều lần phải cho cùng kết quả.
  • Với null: x.equals(null) luôn là false.

Cài đặt chuẩn cho một lớp Customer trông như sau:

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);
    }
}

Giải thích từng phần:

  • this == o là lối tắt khi hai tham chiếu trỏ cùng một object.
  • instanceof Customer other dùng pattern matching (Java 16+). Biểu thức này trả về false khi o là null, nên tính chất “với null” được đảm bảo mà không cần kiểm tra riêng.
  • Objects.equals an toàn với field null.
  • Objects.hash tính hash từ đúng các field đã dùng trong equals().

Lớp được khai báo final. Lý do sẽ rõ ở mục 6.

3. Hợp đồng của hashCode()

hashCode() có ba quy tắc:

  1. Gọi nhiều lần trên cùng một object không đổi phải trả về cùng một giá trị.
  2. Nếu a.equals(b) thì a.hashCode() == b.hashCode(). Đây là quy tắc quan trọng nhất.
  3. Hai object khác nhau được phép có cùng hash code (collision). Tuy vậy, hash phân tán tốt sẽ giúp collection chạy nhanh hơn.

Chiều ngược lại của quy tắc 2 không đúng. Cùng hash code không có nghĩa là bằng nhau.

Để thấy vì sao quy tắc 2 quan trọng, hãy xem cách HashMap tìm một key. Đầu tiên nó dùng hashCode() để chọn bucket. Sau đó nó mới dùng equals() để so sánh các phần tử trong bucket đó. Nếu hai object “bằng nhau” nhưng hash khác nhau, chúng nằm ở hai bucket khác nhau, và equals() không bao giờ được gọi.

4. Lỗi 1: Override equals() mà quên hashCode()

Đây là lỗi kinh điển. Giả sử bạn bỏ hashCode() khỏi lớp ở mục 2:

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);
    }
    // thiếu 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

Hai object bằng nhau theo equals(). Nhưng hashCode() vẫn là bản mặc định của Object, thường khác nhau với mỗi instance. Kết quả là HashSet chứa hai phần tử “trùng”, và contains() trả về false. Kết quả này gần như chắc chắn xảy ra. Hiếm khi hai hash mặc định trùng nhau, nên lỗi càng khó tái hiện ổn định.

Lưu ý: IDE và các công cụ như SonarQube đều cảnh báo lỗi này. Đừng tắt cảnh báo đó.

5. Lỗi 2: Overload thay vì override

Lỗi này khó thấy hơn nhiều:

public class Order {
    private final long id;

    public Order(long id) { this.id = id; }

    public boolean equals(Order other) {   // SAI: tham số là Order, không phải 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));                  // gọi equals(Order)
System.out.println(List.of(a).contains(b));       // gọi equals(Object)
true
false

equals(Order) là một method mới (overload). Nó không override equals(Object). Collection luôn gọi equals(Object), nên nó vẫn dùng bản so sánh tham chiếu của Object.

Cách phòng tránh rất đơn giản: luôn gắn @Override. Nếu bạn thêm @Override vào method trên, compiler sẽ báo lỗi ngay.

6. Lỗi 3: Phá tính đối xứng khi kế thừa

Xét lớp Point và lớp con ColorPoint thêm field color:

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

Tính đối xứng đã bị phá. Ngoài ra, p.equals(cp) là true nhưng hash của hai object khác nhau, nên quy tắc 2 ở mục 3 cũng bị vi phạm.

Không có cách nào vừa thêm field có ý nghĩa vào lớp con, vừa giữ trọn hợp đồng với instanceof. Bạn có ba lựa chọn:

  • Khai báo lớp final hoặc dùng record. Đây là cách đơn giản nhất.
  • Dùng getClass() != o.getClass() thay cho instanceof. Khi đó Point và ColorPoint không bao giờ bằng nhau. Cách này giữ được hợp đồng, nhưng một lớp con không thêm field gì (ví dụ proxy) cũng không còn bằng lớp cha.
  • Ưu tiên composition: cho ColorPoint chứa một Point thay vì kế thừa Point.

7. Lỗi 4: Field thay đổi được trong key

hashCode() được tính tại thời điểm object được thêm vào HashSet. Nếu sau đó field tham gia tính hash thay đổi, object vẫn nằm ở bucket cũ:

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

Object vẫn nằm trong set, nhưng bạn không tìm thấy nó và cũng không xóa được nó. Kết quả là một rò rỉ bộ nhớ nhỏ, và logic chạy sai.

Lưu ý: Chỉ dùng field bất biến (immutable) trong equals()/hashCode(). Nếu buộc phải dùng field thay đổi được, đừng sửa object khi nó đang là key trong HashMap hoặc phần tử trong HashSet. Cách làm đúng là xóa object ra, sửa, rồi thêm lại.

8. Lỗi 5: Bẫy của BigDecimal và mảng

Một số kiểu trong JDK có equals() không như bạn nghĩ.

8.1. BigDecimal so sánh cả 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() coi 1.0 và 1.00 là khác nhau vì scale khác nhau. Nếu lớp Money của bạn dùng BigDecimal trong equals(), hãy chuẩn hóa scale trước. Ví dụ, gọi amount.setScale(2, RoundingMode.HALF_UP) trong constructor. Lưu ý rằng stripTrailingZeros() cũng chuẩn hóa được, nhưng nó có thể cho ra scale âm, ví dụ với 100.

8.2. Mảng so sánh tham chiếu

Mảng không override equals(). Vì vậy new int[]{1, 2}.equals(new int[]{1, 2}) trả về false. Khi field là mảng, hãy dùng Arrays.equals() và Arrays.hashCode(). Bẫy này cũng áp dụng cho record, như bạn sẽ thấy ở mục tiếp theo.

9. Dùng record để tránh phần lớn lỗi

Từ Java 16, record tự sinh equals(), hashCode() và toString() dựa trên tất cả các component:

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 là lớp final, các field là final, và hai method được sinh đúng hợp đồng. Lỗi ở các mục 4, 5 và 6 biến mất. Lỗi ở mục 7 cũng gần như biến mất, trừ khi component là object thay đổi được, ví dụ List mutable.

Hai điểm bạn cần nhớ khi dùng record:

  • Component là mảng vẫn bị so sánh theo tham chiếu. Hãy dùng List thay cho mảng.
  • Component BigDecimal vẫn dính bẫy scale. Hãy chuẩn hóa trong compact constructor:
public record Money(BigDecimal amount, String currency) {
    public Money {
        amount = amount.setScale(2, RoundingMode.HALF_UP);
    }
}

Với khai báo này, new Money(new BigDecimal("1.0"), "VND") bằng new Money(new BigDecimal("1.00"), "VND"), vì cả hai đều được đưa về scale 2.

10. Kiểm thử equals() và hashCode()

Bạn nên có test cho hành vi chính. Dưới đây là test với JUnit 5 và AssertJ cho lớp Customer ở mục 2:

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");
    }
}

Test thứ nhất kiểm tra tính đối xứng, quy tắc “bằng nhau thì cùng hash”, và hành vi thực tế khi dùng trong HashSet. Test thứ hai kiểm tra trường hợp null và trường hợp khác kiểu.

Viết tay đủ mọi trường hợp khá tốn công. Thư viện EqualsVerifier kiểm tra toàn bộ hợp đồng chỉ với một dòng:

<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();
}

Nếu bạn quên field trong hashCode(), dùng field không final, hoặc lớp không final mà dùng instanceof, test sẽ fail kèm thông báo chỉ rõ quy tắc nào bị vi phạm.

11. Khi nào KHÔNG nên override

Không phải lớp nào cũng cần override equals()/hashCode():

  • Lớp có định danh tự nhiên theo instance, như Thread, PaymentService hay các bean Spring. Hai service là hai object khác nhau, và so sánh tham chiếu là đúng.
  • JPA entity: tránh dùng tất cả field. Cũng đừng dùng @Id tự sinh một cách ngây thơ, vì id là null trước khi persist, khiến hash thay đổi sau khi lưu. Cách phổ biến là so sánh bằng business key bất biến. Một cách khác là so sánh theo id khi nó khác null, và cho hashCode() trả về một hằng số theo lớp, ví dụ getClass().hashCode().
  • Lombok @Data trên entity hoặc lớp có quan hệ hai chiều: annotation này sinh equals()/hashCode() dùng mọi field. Điều đó có thể gây StackOverflowError khi hai entity tham chiếu nhau, hoặc kích hoạt lazy loading ngoài ý muốn.

12. Kết luận

Hợp đồng giữa equals() và hashCode() xoay quanh một quy tắc cốt lõi: hai object bằng nhau phải có cùng hash code. Phần lớn lỗi thực tế đến từ năm nguyên nhân:

  • quên override hashCode();
  • overload equals(Order) thay vì override equals(Object);
  • kế thừa làm phá tính đối xứng;
  • dùng field thay đổi được trong key;
  • các bẫy của BigDecimal và mảng.

Trong code mới, hãy ưu tiên record cho value object. Với lớp thường, hãy khai báo final, luôn gắn @Override, và chỉ dùng field bất biến. Cuối cùng, thêm một test với EqualsVerifier để công cụ kiểm tra hợp đồng thay cho bạn.

javaequalshashcodecollectionsrecord