HashMap internals — Separate chaining + treeify từ Java 8
Amortized O(1) của HashMap nghe có vẻ chắc chắn — cho đến khi attacker dùng key cố tình collide và API endpoint của bạn spike CPU 100%. Bài này đi sâu vào bucket array, chaining, treeify Java 8, resize, và khi nào O(1) claim thực sự đứng vững.
Stack Overflow ghi rõ: HashMap.get() là amortized O(1). Junior dev tin tuyệt đối — và phần lớn trường hợp, họ đúng. Nhưng trong một buổi load test, một dev tinh nghịch fuzz API bằng 1000 request, mỗi request chứa key được thiết kế sao cho tất cả đều có hashCode = 0. Endpoint bình thường xử lý 1000 op/s bỗng spike CPU lên 100%, response time từ 5ms lên vài giây. Không có exception, không có timeout — chỉ có hệ thống chậm dần rồi nghẹt.
Bài này giải thích cơ chế bên trong HashMap: bucket array, chaining, treeify Java 8, resize, khi nào O(1) claim thực sự đứng vững và khi nào break. Sau bài này bạn đọc được HashMap.java source và benchmark đúng giá trị HashMap cho production.
1. Analogy — Tủ chia ngăn đựng hồ sơ
Hình dung một tủ hồ sơ có 16 ngăn. Khi nhân viên nộp hồ sơ, họ phân vào ngăn dựa trên chữ cái cuối tên (hash function). Ngăn A nhận hồ sơ tên kết thúc bằng a, ngăn B nhận b, và cứ thế. Khi cần tìm hồ sơ của "Nguyen Van An", bạn chỉ mở ngăn A — không cần lục toàn tủ.
Mỗi ngăn chứa một danh sách (linked list) khi nhiều hồ sơ cùng chữ cuối tên. Để tìm đúng người, bạn duyệt danh sách trong ngăn đó so sánh từng tên — equals(). Khi danh sách quá dài (8 hồ sơ trở lên), thay vì list, ngăn đó chuyển sang cấu trúc cây tìm kiếm — tìm O(log n) thay vì O(n). Khi tủ đầy 75%, thêm ngăn gấp đôi và sắp xếp lại.
| Tủ hồ sơ | HashMap |
|---|---|
| Tủ có N ngăn | Node<K,V>[] table — bucket array, length power-of-2 |
| Chữ cái cuối tên | hash(key) — bucket index |
| Ngăn đựng hồ sơ | Bucket |
| Danh sách trong ngăn | Linked list (chaining) |
| Lục từng hồ sơ trong ngăn | equals() comparison |
| Ngăn quá 8 hồ sơ → đổi sang cây | Treeify thành red-black tree |
| Tủ đầy 75% → tăng gấp đôi ngăn | Resize: double capacity, rehash |
HashMap = tủ ngăn phân luồng. Chaining = list trong ngăn. Treeify = list dài chuyển thành cây. Resize = tủ đầy thì mua tủ to hơn gấp đôi.
2. Cấu trúc bên trong
HashMap lưu dữ liệu trong một array of buckets:
// Core fields -- simplified from OpenJDK 21 HashMap.java
transient Node<K,V>[] table; // bucket array, null until first use
transient int size; // number of key-value pairs
int threshold; // resize trigger: capacity * loadFactor
final float loadFactor; // default 0.75f
Mỗi Node chứa bốn field:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // cached hash of key
final K key;
V value;
Node<K,V> next; // linked list chain within bucket
}
Bucket index được tính bằng bit AND thay vì modulo:
// bucket index: (table.length - 1) & hash
// Since table.length is always a power of 2, (n-1) is a bitmask:
// n=16 -> n-1 = 0b1111 -> keeps only 4 lowest bits
// n=32 -> n-1 = 0b11111 -> keeps only 5 lowest bits
// Faster than % operator, equivalent when n is power-of-2
int i = (table.length - 1) & hash(key);
ASCII diagram của HashMap với 16 bucket và 4 entry, 2 trong đó chained vào bucket 3:
table[]
[0] null
[1] null
[2] --> Node{hash, "beta", 200, null}
[3] --> Node{hash, "alpha", 100, next} --> Node{hash, "delta", 400, null}
[4] null
...
[9] --> Node{hash, "gamma", 300, null}
...
[15] null
size = 4
threshold = 16 * 0.75 = 12
Các hằng số quan trọng:
// Treeify / untreeify thresholds
static final int TREEIFY_THRESHOLD = 8; // list length >= 8 -> treeify
static final int UNTREEIFY_THRESHOLD = 6; // tree size <= 6 -> back to list
static final int MIN_TREEIFY_CAPACITY = 64; // minimum table size before treeify
// (if table < 64, resize instead)
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16
3. Put operation step-by-step (Java 21 source)
Dưới đây là putVal — heart của HashMap.put(). Code đã simplified để tập trung cơ chế, nhưng sát với OpenJDK 21 source (line 628):
// Simplified from HashMap.putVal() -- OpenJDK 21, line ~628
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab;
Node<K,V> p;
int n, i;
// 1. Lazy init: first put allocates the table
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2. Bucket index via bit AND
i = (n - 1) & hash;
p = tab[i];
if (p == null) {
// 3a. Empty bucket: insert directly
tab[i] = newNode(hash, key, value, null);
} else if (p instanceof TreeNode) {
// 3b. Bucket is treeified: delegate to red-black tree insert
((TreeNode<K,V>) p).putTreeVal(this, tab, hash, key, value);
} else {
// 3c. Walk linked list chain
for (int binCount = 0; ; binCount++) {
if (p.next == null) {
// Reached end of chain: append new node
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // binCount is 0-indexed
treeifyBin(tab, i); // chain length hit 8, treeify
break;
}
// Key already exists: update value
if (p.hash == hash && (p.key == key || key.equals(p.key))) {
p.value = value;
break;
}
p = p.next;
}
}
// 4. Resize if size exceeds threshold
if (++size > threshold)
resize();
return null; // simplified: actual code returns old value
}
Hai trường hợp cần chú ý:
Bucket trống (case 3a): tạo Node mới gắn trực tiếp vào tab[i] — đây là happy path O(1) thực sự.
Collision (case 3c): duyệt linked list, so sánh từng node bằng hash == p.hash && key.equals(p.key). Kiểm tra hash trước khi equals() là optimization quan trọng — int comparison cực nhanh, chỉ gọi equals() khi hash match. Khi binCount đạt TREEIFY_THRESHOLD - 1 (tức 7, 0-indexed), chain length là 8 — trigger treeifyBin.
4. Treeify Java 8 — defensive against bad keys
Khi một bucket tích lũy 8 entry, treeifyBin() được gọi. Nhưng không phải lúc nào cũng treeify ngay:
// treeifyBin -- OpenJDK 21, line ~770
final void treeifyBin(Node<K,V>[] tab, int index) {
int n;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) {
// Table too small: resize is cheaper than treeify
// Resize distributes entries to new buckets, reducing chain length
resize();
} else {
// Table is large enough: convert linked list to red-black tree
// ... (TreeNode conversion logic)
}
}
Logic quyết định:
- Table size dưới 64: gọi
resize()thay vì treeify. Lý do: resize đôi capacity thường phân tán key ra các bucket mới — chain tự ngắn lại mà không cần overhead của red-black tree. - Table size từ 64 trở lên: convert bucket đó thành red-black tree — lookup thoái hóa từ
O(n)xuốngO(log n).
Khi entry trong tree giảm xuống 6 (qua remove), tree revert lại linked list — UNTREEIFY_THRESHOLD = 6 tránh thrashing giữa hai cấu trúc.
Vì sao TREEIFY_THRESHOLD = 8? Với uniform hash và load factor 0.75, xác suất để một bucket có đúng 8 entry xấp xỉ 1 trên 100 triệu (phân phối Poisson với tham số 0.75). Khi điều đó xảy ra, gần như chắc chắn hash function đang bị lạm dụng hoặc attacker đang cố tình tạo collision. Treeify là biện pháp defensive: giảm worst case từ O(n) xuống O(log n) mà không ảnh hưởng happy path.
Con số 4 sẽ trigger treeify quá thường xuyên với hash function bình thường — overhead không đáng. Con số 16 thì quá trễ — attacker có thể khai thác window dài hơn.
Hằng số này giải quyết vấn đề "false alarm": HashMap nhỏ với capacity 16-32 thường bị chain dài vì quá ít bucket. Nếu treeify ngay, mỗi resize sẽ phải convert tree lại thành list — lãng phí. Resize rẻ hơn và hiệu quả hơn cho table nhỏ.
5. Resize operation
Resize được trigger khi size vượt threshold (= capacity * loadFactor). Với default values, HashMap 16-bucket resize lần đầu khi có hơn 12 entry.
// Resize doubles capacity and redistributes entries -- OpenJDK 21, line ~700
final Node<K,V>[] resize() {
int oldCap = (table == null) ? 0 : table.length;
int oldThr = threshold;
int newCap = oldCap << 1; // double capacity
int newThr = (int)(newCap * loadFactor);
Node<K,V>[] newTab = (Node<K,V>[]) new Node[newCap];
// ... rehash entries ...
return newTab;
}
Smart re-distribution Java 8 — không cần recompute hash:
// For each node in old bucket[i], determine new bucket in doubled table:
// newCap = oldCap * 2 (e.g., 16 -> 32)
// New bitmask: (newCap - 1) = 0b11111, vs old (oldCap - 1) = 0b01111
// The extra bit is exactly bit at position oldCap (e.g., bit 4 for oldCap=16)
//
// (oldHash & oldCap) == 0 -> extra bit is 0 -> stays at index i
// (oldHash & oldCap) != 0 -> extra bit is 1 -> moves to index i + oldCap
if ((oldHash & oldCap) == 0) {
// Entry stays in same bucket: loNode chain
} else {
// Entry moves to i + oldCap: hiNode chain
}
Ví dụ: oldCap = 16, oldHash = 0b...10011 (bit 4 = 1). Entry ở bucket 3 (0011) di chuyển sang bucket 19 (10011). oldHash & 16 != 0 → new index = 3 + 16 = 19. Không cần gọi lại hash() hay hashCode().
Amortized analysis: mỗi lần resize, N entry phải được rehash. Nhưng resize xảy ra khi size tăng gấp đôi so với lần resize trước — mỗi entry trung bình chịu O(1) work do resize, amortized qua toàn bộ chuỗi put. Cross-link: Module 1 bài 03 (amortized analysis).
Nếu put thứ N+1 trigger resize, đó là O(N) operation — toàn bộ table phải rehash. Code không được giả định single put luôn O(1); chỉ được nói amortized O(1) trên chuỗi put. Trong latency-sensitive code (game loop, trading engine), dùng pre-sized HashMap để tránh resize trong critical path.
6. Pitfall tổng hợp
Pitfall 1 — Concurrent put không thread-safe
Java 7: hai thread đồng thời put() vào cùng bucket trong khi resize có thể tạo cycle trong linked list — thread thứ ba sau đó gọi get() vào bucket đó sẽ loop vô hạn, CPU spike 100%, không bao giờ return.
Java 8: không còn infinite loop (resize dùng tail-insertion thay vì head-insertion, phá cycle), nhưng race condition vẫn tồn tại — entry có thể bị mất, duplicate, hoặc bị ghi đè sai. Data corruption im lặng, khó debug.
// WRONG: HashMap shared across threads
Map<String, Integer> wordCount = new HashMap<>();
words.parallelStream().forEach(w ->
wordCount.merge(w, 1, Integer::sum) // race condition on merge
);
// CORRECT: ConcurrentHashMap
Map<String, Integer> wordCount = new ConcurrentHashMap<>();
words.parallelStream().forEach(w ->
wordCount.merge(w, 1, Integer::sum) // thread-safe
);
// Or: use Collectors.groupingByConcurrent for stream aggregation
Fix: dùng ConcurrentHashMap hoặc Collections.synchronizedMap(new HashMap<>()) (synchronized toàn table — kém hiệu suất hơn nhưng đúng).
Pitfall 2 — Hash collision attack (DoS)
Trước Java 8, attacker biết hash function của JVM (fixed algorithm, không có randomization) có thể tạo hàng nghìn String key với cùng hashCode. Gửi form, JSON, hoặc URL parameter chứa các key đó → tất cả vào cùng bucket → put và get đều O(n) → CPU spike, denial of service.
Java 8 treeify giảm worst case xuống O(log n) — giảm nhẹ thiệt hại nhưng không loại bỏ hoàn toàn. O(log n) với n = 10.000 vẫn là 13 so với 1 của happy path.
Defense:
- Validate và giới hạn số key trong input trước khi cho vào HashMap (Spring
max-request-size, JacksonmaxNestingDepth). - Với untrusted key (user-controlled string), cân nhắc dùng hash function có randomized seed. Một số JVM config cho phép hash seed randomization.
- Log và alert khi một request chứa quá nhiều key trùng bucket.
Pitfall 3 — Mutable key trong HashMap
Put User u vào map khi hashCode() dựa trên email. Sau đó mutate u.email. get(u) trả về null — key bị "lạc" vào bucket cũ, tìm ở bucket mới không có.
// Dangerous: hashCode() depends on mutable field
User u = new User(1L, "[email protected]");
Map<User, String> roles = new HashMap<>();
roles.put(u, "admin");
u.setEmail("[email protected]"); // mutate field used in hashCode!
String role = roles.get(u); // null -- key "lost" in wrong bucket
// Entry still in map (memory leak) but unreachable
Cross-link: Module 3 bài 01 (hash function — pitfall 2 mutable field). Fix: hashCode chỉ dùng final field; prefer record cho value object làm HashMap key.
7. Sizing best practice
Hint initial capacity để tránh resize trong critical path:
// Default HashMap: initial capacity 16, resize at 12 entries
Map<String, User> cache = new HashMap<>(); // will resize multiple times for 1000 entries
// Pre-sized: no resize needed for 1000 entries
// HashMap allocates next power-of-2 >= (expectedSize / loadFactor)
// For 1000 entries: 1000 / 0.75 = 1333 -> next power-of-2 = 2048
int expectedSize = 1000;
Map<String, User> cache = new HashMap<>((int)(expectedSize / 0.75 + 1));
// Java 19+: factory method handles the math for you
Map<String, User> cache = HashMap.newHashMap(expectedSize);
Load factor tradeoffs:
| Load factor | Collision rate | Memory usage | Use case |
|---|---|---|---|
| 0.5 | Thấp (ít chain) | Cao (tủ thưa hơn) | Read-heavy, latency critical |
| 0.75 (default) | Cân bằng | Cân bằng | Phần lớn trường hợp |
| 1.0 | Cao (chain dài hơn) | Thấp (tủ đầy) | Memory-constrained, write-heavy |
Default 0.75 là kết quả benchmark từ Sun Microsystems — điểm cân bằng giữa collision rate và memory footprint.
8. Ứng dụng thực tế
HashMap vs LinkedHashMap: LinkedHashMap thêm doubly linked list xuyên qua tất cả entry theo insertion order (hoặc access order khi accessOrder = true). Access-order LinkedHashMap là backbone của LRU cache pattern — removeEldestEntry() override tự động evict entry cũ nhất. Cross-link: Module 2 bài 07 (LRU mini-challenge).
ConcurrentHashMap Java 8+: Java 7 chia table thành 16 segment cố định, mỗi segment có một lock (segment-level locking). Java 8 bỏ segment — dùng CAS (Compare-And-Swap) ở bucket level: putIfAbsent và addCount không cần lock khi bucket trống. Khi có collision mới cần synchronized block nhỏ hơn. Throughput cao hơn, contention thấp hơn — đặc biệt quan trọng khi key phân tán đều (uniform hash).
Redis dict: Redis dùng hash table với separate chaining tương tự, nhưng rehash theo kiểu incremental — thay vì block toàn bộ trong một resize, Redis duy trì hai table cùng lúc (ht[0] và ht[1]) và di chuyển một bucket mỗi lần có read/write. Không có latency spike do resize. Cross-link: Module 3 bài 11 (Redis case study).
Caffeine / Guava Cache: dùng segmented ConcurrentHashMap pattern với eviction policy bổ sung (W-TinyLFU trong Caffeine). Hiểu internals của HashMap giúp tuning maximumSize, initialCapacity, concurrencyLevel đúng giá trị.
9. Deep Dive
OpenJDK source:
HashMap.java—putVal()line ~628,resize()line ~700,treeifyBin()line ~770. Đọc comment trong source rất chi tiết, giải thích từng decision.- Tìm tại:
openjdk.org/projects/jdk/21hoặcgithub.com/openjdk/jdk/blob/master/src/java.base/share/classes/java/util/HashMap.java
JEP:
- JEP 180 — "Handle Frequent HashMap Collisions With Balanced Trees" (Java 8): mô tả lý do và thiết kế của treeify. Ngắn, dễ đọc, giải thích Poisson distribution và threshold selection.
Bài viết kỹ thuật:
- Aleksey Shipilev "Java HashMap and ConcurrentHashMap in Depth" — benchmark treeify overhead, resize cost, và ConcurrentHashMap segment analysis. Có số liệu thực trên JMH.
Sách:
- Effective Java, 3rd Edition — Item 28: prefer lists over arrays for generic types (liên quan đến unchecked cast trong
Node[]creation).
Cross-link trong khóa học:
- Module 3 bài 01: hash function — uniform distribution, avalanche, hashCode/equals contract
- Module 1 bài 03: amortized analysis — tại sao resize cost là O(1) amortized
- Module 2 bài 07: LRU cache với LinkedHashMap
- Module 3 bài 11: Redis dict — incremental rehash
- Module 9: ConcurrentHashMap deep dive trong distributed context
10. Tóm tắt
- HashMap = bucket array + chaining:
Node<K,V>[] table, mỗi bucket là linked list. Bucket index tính bằng(n-1) & hash(key)— bit AND nhanh hơn modulo, chỉ đúng khi capacity là power-of-2. - Put path: empty bucket →
O(1)insert trực tiếp; collision → walk linked list, compare hash rồiequals(); khi chain đạt 8 entry → treeify. - Treeify Java 8: chain dài từ 8 entry trở lên chuyển thành red-black tree — lookup thoái hóa từ
O(n)xuốngO(log n). Chỉ treeify khi table size từ 64 trở lên; nhỏ hơn thì resize trước. - TREEIFY_THRESHOLD = 8: xác suất Poisson khoảng 1 trên 100 triệu với hash đều — khi đạt ngưỡng này gần như chắc chắn hash function bị lạm dụng.
- Resize: double capacity khi
sizevượtthreshold. Smart re-distribution Java 8 dùng(oldHash & oldCap)để phân entry mà không recompute hashCode. AmortizedO(1)per put. - Mutable key = bug: hashCode đổi sau mutation → key "lạc" bucket,
get()trảnull, memory leak. Dùngfinalfield hoặcrecord. - HashMap không thread-safe: dùng
ConcurrentHashMaphoặcsynchronizedMap()khi multi-thread. Java 7 race condition gây infinite loop; Java 8 sửa loop nhưng vẫn data race. - Pre-size để tránh resize:
HashMap.newHashMap(expectedSize)(Java 19+) hoặcnew HashMap<>((int)(n / 0.75 + 1))cho latency-sensitive path.
11. Tự kiểm tra
Q1Vì sao TREEIFY_THRESHOLD = 8 không phải 4 hoặc 16?▸
Với uniform hash và load factor 0.75, phân phối số entry trên một bucket xấp xỉ phân phối Poisson với tham số 0.75. Xác suất để một bucket có đúng 8 entry xấp xỉ 1 trên 100 triệu. Khi điều đó xảy ra, gần như chắc chắn hash function bị lạm dụng (attacker tạo key collide có chủ ý, hoặc hashCode() của key class cực kỳ tệ).
Nếu threshold = 4: treeify quá thường xuyên ngay cả với hash function bình thường — overhead red-black tree không đáng, và tree nhỏ không nhanh hơn list bao nhiêu do cache locality kém hơn. Nếu threshold = 16: attacker có window dài hơn để exploit trước khi treeify bảo vệ — chain dài 16 tốn gấp đôi so với 8 trước khi defense được kích hoạt.
Q2MIN_TREEIFY_CAPACITY = 64 giải quyết vấn đề gì? Nếu không có hằng số này, điều gì xảy ra?▸
Khi table size nhỏ (ví dụ 16 bucket) mà chain đạt 8 entry, nguyên nhân thường là capacity quá thấp — quá ít bucket nên phải chaining nhiều. Resize double capacity sẽ phân tán entry ra các bucket mới, chain tự ngắn lại mà không cần overhead của red-black tree.
Nếu không có MIN_TREEIFY_CAPACITY, HashMap sẽ treeify một bucket khi table còn nhỏ. Sau đó khi resize, tree phải được untreeify (convert lại thành list, vì entry phân tán ra nhiều bucket) — wasteful round-trip. Hằng số 64 đảm bảo treeify chỉ xảy ra khi capacity đủ lớn để resize không còn là giải pháp rẻ hơn.
Q3Java 8 resize 'smart re-distribution' tránh recompute hash thế nào? Cho ví dụ cụ thể.▸
Khi double capacity từ N lên 2N, bitmask bucket index mở rộng thêm 1 bit. Bucket index cũ dùng (N-1) & hash; bucket index mới dùng (2N-1) & hash — chính xác là thêm 1 bit ở vị trí N vào bitmask.
Giá trị của bit đó trong hash là hash & N. Nếu bằng 0: bit mới là 0 → index giữ nguyên. Nếu khác 0: bit mới là 1 → index mới = index cũ + N. Không cần gọi lại hashCode() hay hash().
Ví dụ: oldCap = 16, entry ở bucket 3 với hash = 0b...010011. hash & 16 = 0b10000 != 0 → entry di chuyển sang bucket 3 + 16 = 19. Entry khác cùng bucket 3 có hash = 0b...000011: hash & 16 = 0 → ở lại bucket 3.
Q4Concurrent put gây infinite loop trong Java 7 — Java 8 fix bằng cách nào?▸
Java 7 resize dùng head insertion khi move entry sang table mới: entry mới luôn được chèn vào đầu list trong bucket mới. Khi hai thread resize đồng thời, thread A và B cùng thấy old table, cùng bắt đầu move entries. Thread A hoàn thành trước, list trong new bucket đã đảo ngược thứ tự. Thread B tiếp tục với pointer đã stale — tạo ra cycle trong linked list. Sau đó bất kỳ thread nào get() vào bucket đó sẽ loop node = node.next vô hạn.
Java 8 fix bằng tail insertion với lo/hi split: thay vì head insertion, Java 8 duyệt list một lần, chia thành hai list riêng (loHead/loTail cho bucket cũ, hiHead/hiTail cho bucket mới), rồi gán nguyên cả list vào new table. Không có đảo ngược thứ tự → không tạo cycle. Nhưng lưu ý: Java 8 vẫn không thread-safe — race condition có thể gây mất entry hoặc duplicate, chỉ không còn infinite loop. Dùng ConcurrentHashMap khi multi-thread.
Q5Hash collision attack (DoS): attacker cần biết gì? Defense tốt nhất là gì?▸
Attacker cần biết hash function của runtime — với Java trước phiên bản có randomization, String.hashCode() là thuật toán cố định, public. Attacker precompute danh sách String có cùng hashCode (ví dụ tất cả cùng hash về 0), nhúng chúng vào request body (JSON keys, form fields, URL params). Server deserialize vào HashMap — tất cả key vào cùng bucket, mỗi put là O(n) → một request nhỏ tiêu tốn CPU lớn.
Defense theo thứ tự hiệu quả: (1) Limit input size — validate số key tối đa trong JSON/form trước khi parse (Jackson maxNestingDepth, Spring max-request-size). (2) Treeify (Java 8+) — giảm worst case xuống O(log n), không loại bỏ nhưng giảm impact. (3) Rate limiting theo IP/user cho endpoints nhận arbitrary key. (4) Hash seed randomization — một số JVM/language cho phép randomize seed per-process để attacker không precompute được.
Q6Cho đoạn code sau: Map<User, Integer> map = new HashMap<>(); map.put(u, 1); u.setEmail("[email protected]"); map.get(u); — kết quả gì? Vì sao?▸
Map<User, Integer> map = new HashMap<>(); map.put(u, 1); u.setEmail("[email protected]"); map.get(u); — kết quả gì? Vì sao?Kết quả: null, giả sử User.hashCode() dùng email trong tính toán.
Khi map.put(u, 1): HashMap tính hash(u) dựa trên email = "[email protected]", tìm bucket index tương ứng, lưu entry tại đó.
Sau khi u.setEmail("[email protected]"): u.hashCode() trả về giá trị khác — email đã thay đổi. HashMap không hay biết gì, entry vẫn nằm ở bucket cũ (tính theo email cũ).
Khi map.get(u): HashMap tính hash(u) với email mới → bucket index mới → tìm trong bucket mới, không có entry → trả về null. Entry (u, 1) vẫn còn trong map (thấy qua entrySet()) nhưng hoàn toàn unreachable — memory leak. Fix: hashCode() chỉ dùng final field, hoặc dùng record cho value object làm key.
Bài tiếp theo: Open addressing — linear/quadratic probing, robin hood
Bài này có giúp bạn hiểu bản chất không?