Backend

99 Ngày Java — Ngày 24: equals, hashCode & toString

SSite Admin
20 tháng 08, 2026 7 phút đọc 0 lượt xem
99 Ngày Java — Ngày 24: equals, hashCode & toString

Ngày 23 khép lại với lời hẹn xuống nền móng của mọi đối tượng — hôm nay ta đến với bộ ba contract của Object: equals, hashCode và toString. Ba method này có sẵn trong mọi lớp, nhưng phiên bản mặc định chỉ hiểu danh tính tham chiếu — còn HashMap, HashSet thì đặt trọn niềm tin vào việc bạn cài chúng đúng. Bài hôm nay đi qua contract của từng method, cách cài chuẩn với Objects utility, và hai cái bẫy khiến phần tử "mất tích" ngay trong map.

Sketchnote Ngày 24: equals, hashCode và toString — contract equals 5 điều, quy tắc vàng equals đi cùng hashCode, hai bước tra cứu của HashMap, Objects utility và record

equals — từ danh tính tham chiếu đến bằng nhau nghiệp vụ

// Mặc định, equals của Object so sánh THAM CHIẾU — hệt như ==:
SanPham a = new SanPham("SP-001", "Bàn phím cơ");
SanPham b = new SanPham("SP-001", "Bàn phím cơ");
a.equals(b);                 // false?! — hai đối tượng khác nhau trên heap

// Muốn "bằng nhau theo nghiệp vụ" (cùng mã sản phẩm) phải override:
@Override
public boolean equals(Object o) {
    if (this == o) return true;                    // 1. chính nó — lối tắt nhanh nhất
    if (!(o instanceof SanPham sp)) return false;  // 2. pattern matching (Ngày 15)
    return ma.equals(sp.ma);                       // 3. so sánh field ĐỊNH DANH
}

// Contract 5 điều: phản xạ · đối xứng · bắc cầu · nhất quán ·
// và x.equals(null) luôn false — instanceof lo sẵn điều cuối:
// o là null thì instanceof trả false, không bao giờ NPE
  • Bản mặc định của equals chính là == — hữu ích khi "bằng" nghĩa là cùng một đối tượng, nhưng đa số kiểu dữ liệu nghiệp vụ cần "bằng" theo giá trị định danh: mã sản phẩm, số CMND, slug bài viết.

  • Ba bước cài chuẩn: lối tắt this == o, kiểm tra kiểu bằng pattern matching (Ngày 15) — gọn hơn hẳn cặp getClass/ép kiểu thủ công, và so sánh đúng bộ field định danh — không lôi mọi field vào.

  • Contract 5 điều nghe hàn lâm nhưng vi phạm là có thật: đối xứng vỡ khi so sánh chéo kiểu cha–con, nhất quán vỡ khi equals dựa trên field thay đổi liên tục — cả hai đều làm collection hành xử khó lường.

hashCode — người bạn đồng hành bắt buộc

// Quy tắc VÀNG: override equals thì BẮT BUỘC override hashCode —
// hai đối tượng equals nhau PHẢI trả cùng một hashCode:
@Override
public int hashCode() {
    return Objects.hash(ma);      // đúng bộ field của equals — không hơn, không kém
}

// Chiều ngược lại KHÔNG bắt buộc: cùng hashCode mà không equals
// vẫn hợp lệ — đó chỉ là va chạm (collision), HashMap xử lý được.

// Vi phạm thì sao? Giả sử equals so theo "ma", hashCode trộn cả "ten":
// → hai đối tượng "bằng nhau" cho ra hai hash khác nhau
// → rơi vào hai bucket khác nhau trong HashMap
// → lỗi IM LẶNG: contains trả false, phần tử "trùng" lọt vào Set,
//   cache tra không thấy — không có exception nào chỉ đường
  • Contract liên kết hai method: equals nhau thì hashCode phải bằng nhau — cách tuân thủ đơn giản nhất là cho Objects.hash nhận đúng bộ field mà equals dùng, thêm bớt một field là contract vỡ.

  • Chiều ngược lại được phép: hai đối tượng khác nhau chung một hash chỉ là collision — làm bucket đông hơn, tra cứu chậm đi chút, nhưng không sai; Ngày 34 sẽ xem HashMap treeify bucket đông thế nào.

  • Lỗi vi phạm contract không ném exception — nó chỉ lặng lẽ làm contains trả false, Set chứa "bản sao", cache luôn miss: loại lỗi tốn nhiều giờ debug nhất vì code trông hoàn toàn hợp lý.

Vai trò trong HashMap — hai bước tra cứu

// HashMap tìm phần tử qua HAI bước — hai contract cùng tham gia:
//   Bước 1: hashCode() → chọn bucket (nhảy thẳng, không duyệt)
//   Bước 2: equals()   → so từng phần tử trong bucket đó

Map<SanPham, Integer> tonKho = new HashMap<>();
tonKho.put(new SanPham("SP-001", "Bàn phím cơ"), 12);

// Quên override hashCode → dùng bản mặc định theo danh tính:
tonKho.get(new SanPham("SP-001", "Bàn phím cơ"));   // null! — sai bucket từ bước 1

// Bẫy thứ hai: SỬA key sau khi put
SanPham key = new SanPham("SP-002", "Chuột");
tonKho.put(key, 5);
key.setMa("SP-999");        // hash đổi → map vẫn giữ nó ở bucket theo hash CŨ
tonKho.get(key);            // null! — "mất tích" ngay bên trong map

// → key của HashMap nên BẤT BIẾN (bài học Ngày 20, đào sâu Ngày 29)
  • Tốc độ O(1) của HashMap đến từ bước 1: hash nhảy thẳng tới bucket thay vì duyệt tuần tự — nhưng chính vì thế, hash sai là tìm sai chỗ ngay từ đầu, equals chuẩn đến đâu cũng không cứu được.

  • Bẫy key bị sửa sau khi put nguy hiểm ở chỗ map không hề biết: entry vẫn nằm ở bucket theo hash cũ, mọi phép get/remove theo hash mới đều trượt — đối tượng chiếm bộ nhớ nhưng không thể chạm tới.

  • Hai bài học gộp một: key nên là kiểu bất biến (String, record, enum — Ngày 23 vừa nói enum còn được EnumMap ưu ái riêng), và mọi kiểu định làm key phải cài đủ cặp equals/hashCode.

Cửa két sắt với bánh xe mã số tổ hợp — tra cứu hai bước của HashMap: mã số (hashCode) dẫn thẳng tới đúng ngăn, bước kiểm tra cuối cùng (equals) mới mở được cửa

toString & bộ tiện ích Objects

// toString mặc định: vn.motdev.SanPham@1b6d3586 — vô dụng khi debug
@Override
public String toString() {
    return "SanPham[ma=%s, ten=%s]".formatted(ma, ten);
}
// log, debugger, String.valueOf, nối chuỗi — tất cả tự gọi toString

// Bộ tiện ích Objects — null-safe cho cả ba contract:
Objects.equals(a, b);            // an toàn khi a là null — không NPE
Objects.hash(ma, ten);           // gộp nhiều field thành một hash
Objects.toString(x, "N/A");      // fallback khi x null
Objects.requireNonNull(ma, "ma không được null");   // fail-fast trong constructor

// Và record (Ngày 20) sinh sẵn CẢ BỘ BA từ các component —
// thêm một lý do để dữ liệu bất biến chọn record thay class thường
  • toString không có contract chặt như hai method kia, nhưng là bề mặt debug của lớp: log và debugger gọi nó liên tục — một dòng override tiết kiệm hàng giờ đọc mã băm vô nghĩa; lưu ý đừng nhét dữ liệu nhạy cảm (mật khẩu, token) vào đây.

  • Lớp Objects đáng dùng ở mọi nơi: Objects.equals né NPE khi so sánh field có thể null, requireNonNull biến lỗi tiềm ẩn thành fail-fast ngay constructor — triết lý gặp từ Ngày 21 của series Spring.

  • Với dữ liệu bất biến, record (Ngày 20) sinh tự động cả ba method từ các component — đúng chuẩn contract; chỉ khi "bằng nhau" khác "mọi component bằng nhau" mới cần tự override lại.

Bài tập nhỏ

  • Cài equals/hashCode/toString cho lớp SanPham (định danh theo ma) — viết test: hai đối tượng cùng mã phải equals nhau và cùng hashCode.

  • Cố tình xóa hashCode override rồi đưa đối tượng vào HashSet — quan sát "bản sao" lọt vào set như thế nào, sau đó khôi phục và so kết quả.

  • Tái hiện bẫy key bị sửa: put một key mutable vào HashMap, đổi field định danh, thử get — rồi giải thích vì sao map.size() vẫn đếm đủ.

  • Chuyển SanPham thành record và xóa cả ba override — chạy lại test ở bài 1 để kiểm chứng record sinh đúng contract.

Kết luận

Bộ contract của Object gói trong bốn ý: equals chuyển "bằng nhau" từ tham chiếu sang định danh nghiệp vụ với contract 5 điều, hashCode phải đồng bộ tuyệt đối với equals vì HashMap tra cứu hai bước hash-rồi-equals, key nên bất biến để hash không đổi sau khi put, và Objects utility + record giúp cài chuẩn ít dòng nhất. Ngày 25 ta đổi nhịp với hai công cụ viết method linh hoạt: varargs và đệ quy — quy tắc tham số biến thiên và nghệ thuật gọi chính mình. Hẹn gặp lại!

S

Site Admin

Engineer and writer. Building things with TypeScript and distributed systems.

Bình luận (0)

Bạn cần đăng nhập bằng Google để bình luận.

Hãy là người bình luận đầu tiên.

Bài viết liên quan

99 Ngày Spring — Ngày 24: Query methods & @Query

Ngày 24 của 99 Ngày Spring: vượt ranh giới derived query với @Query — JPQL truy vấn trên entity với lưới an toàn fail-fast lúc khởi động, bind tham số theo tên làm hàng rào chống SQL injection, native query cho tính năng riêng của DB kèm đánh đổi, và @Modifying cho bulk update đi thẳng xuống DB.

20 thg 8, 20267 phút0
Garbage Collection trong Java: reachability, heap generations và vì sao vẫn memory leak

GC tự động nhưng không "miễn nghĩ": tiêu chí duy nhất của nó là reachability, heap chia thế hệ vì đa số object chết trẻ, stop-the-world pause là cái giá của việc dọn dẹp, và memory leak trong Java nghĩa là object vẫn reachable ngoài ý muốn — kèm 4 thủ phạm leak phổ biến và checklist vận hành GC trên production.

20 thg 8, 20267 phút2
99 Ngày Spring — Ngày 23: Spring Data JPA repository

Ngày 23 của 99 Ngày Spring: Spring Data JPA repository — JpaRepository với bộ CRUD có sẵn trên một interface trống, phả hệ Repository → CrudRepository → JpaRepository và cơ chế proxy trên SimpleJpaRepository, derived query sinh truy vấn từ tên method với lưới fail-fast lúc khởi động.

19 thg 8, 20266 phút12