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àtruekhi và chỉ khiy.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 == olà lối tắt khi hai tham chiếu trỏ cùng một object.instanceof Customer otherdùng pattern matching (Java 16+). Biểu thức này trả vềfalsekhiolà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.equalsan toàn với fieldnull.Objects.hashtính hash từ đúng các field đã dùng trongequals().
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:
- 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ị.
- Nếu
a.equals(b)thìa.hashCode() == b.hashCode(). Đây là quy tắc quan trọng nhất. - 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
finalhoặc dùngrecord. Đây là cách đơn giản nhất. - Dùng
getClass() != o.getClass()thay choinstanceof. Khi đóPointvàColorPointkhô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
ColorPointchứa mộtPointthay vì kế thừaPoint.
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 trongHashMaphoặc phần tử trongHashSet. 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
Listthay cho mảng. - Component
BigDecimalvẫ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,PaymentServicehay 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
@Idtự sinh một cách ngây thơ, vìidlànulltrướ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 theoidkhi nó khácnull, và chohashCode()trả về một hằng số theo lớp, ví dụgetClass().hashCode(). - Lombok
@Datatrên entity hoặc lớp có quan hệ hai chiều: annotation này sinhequals()/hashCode()dùng mọi field. Điều đó có thể gâyStackOverflowErrorkhi 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ì overrideequals(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
BigDecimalvà 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.