HashMap collision trong Java: từ hash function đến red-black tree

“HashMap hoạt động thế nào?” có lẽ là câu hỏi phỏng vấn Java phổ biến nhất mọi thời đại. Phần khó nhất — và cũng là phần phân biệt câu trả lời tốt với câu trả lời thuộc lòng — nằm ở cách nó xử lý collision.


TL;DR

flowchart LR
    K[key] --> H["hashCode()"] --> S["spread: h ^ (h >>> 16)"] --> I["index = (n-1) & hash"] --> B[bucket]
    B --> E{"bucket rỗng?"}
    E -- Có --> N[Đặt node vào]
    E -- Không --> C["COLLISION: duyệt list / tree, so sánh equals()"]

1. Cấu trúc bên trong

transient Node<K,V>[] table;   // mảng bucket

static class Node<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;            // con trỏ tới node kế tiếp trong cùng bucket
}

Các hằng số quan trọng trong source code của JDK:

Hằng số Giá trị Ý nghĩa
DEFAULT_INITIAL_CAPACITY 16 Capacity mặc định (table được tạo lười, ở lần put đầu tiên)
DEFAULT_LOAD_FACTOR 0.75 Ngưỡng lấp đầy trước khi resize
TREEIFY_THRESHOLD 8 Độ dài chain để chuyển list → tree
UNTREEIFY_THRESHOLD 6 Kích thước để chuyển tree → list (khi resize)
MIN_TREEIFY_CAPACITY 64 Capacity tối thiểu để được treeify; nhỏ hơn thì resize thay vì treeify

Hình dung một table có collision:

flowchart LR
    subgraph table["table (n = 16)"]
        b0["[0]"]
        b1["[1]"]
        b2["[2]"]
        b3["[3]"]
        b15["[15]"]
    end
    b0 --> n0["'apple'"]
    b1 --> x1[null]
    b2 --> n2a["'Aa'"] --> n2b["'BB'"] --> n2c["'cat'"]
    b3 --> n3["'dog'"]
    b15 --> x15[null]

Bucket [2] đang có collision: ba key cùng nằm trong một chain.


2. Từ key đến bucket: ba bước

2.1. hashCode() của key

Do class của key cung cấp. Ví dụ String.hashCode():

s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]

2.2. Spread: trộn bit cao xuống bit thấp

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

Tại sao cần bước này? Phép tính index chỉ dùng vài bit thấp của hash (với n = 16 chỉ dùng 4 bit). Nếu các key chỉ khác nhau ở bit cao, chúng sẽ dồn vào cùng bucket. XOR 16 bit cao vào 16 bit thấp giúp bit cao cũng “góp mặt” vào index, với chi phí gần như bằng 0.

Lưu ý: key null luôn có hash 0 → luôn nằm ở bucket 0. HashMap cho phép một key null.

2.3. Tính index

index = (n - 1) & hash;

Vì n là lũy thừa của 2, n - 1 có dạng 0...01111, nên phép & tương đương hash % n nhưng nhanh hơn nhiều và luôn không âm. Đây là lý do capacity luôn được làm tròn lên lũy thừa của 2 — new HashMap<>(10) thực chất có capacity 16.

Ví dụ với n = 16 (n - 1 = 15 = 0b1111):

hash      = 0b ...1010 1101 0011   →  & 0b1111  →  0b0011 = 3
hash khác = 0b ...0111 0010 0011   →  & 0b1111  →  0b0011 = 3   ← COLLISION dù hash khác nhau

3. Hai loại collision

Loại Nguyên nhân Ví dụ
Full hash collision Hai key khác nhau có cùng hashCode() "Aa" và "BB" đều có hashCode 2112
Index collision hashCode() khác nhau nhưng trùng bit thấp sau phép & Ví dụ ở mục 2.3

Index collision là bình thường và không tránh khỏi (nguyên lý chuồng bồ câu): hash có 2³² giá trị nhưng table chỉ có n bucket. Full hash collision thì do chất lượng hàm hashCode().

Kiểm chứng nhanh:

System.out.println("Aa".hashCode());   // 2112
System.out.println("BB".hashCode());   // 2112
// "AaAa", "AaBB", "BBAa", "BBBB" cũng cùng hashCode → có thể sinh ra vô số key trùng hash

4. put() xử lý collision thế nào

flowchart TB
    S["put(key, value)"] --> T{"table null?"}
    T -- Có --> R0["resize() - khởi tạo table 16"] --> I
    T -- Không --> I["i = (n-1) & hash"]
    I --> E{"table[i] rỗng?"}
    E -- Có --> NEW["table[i] = newNode"] --> SZ
    E -- Không --> F{"node đầu có cùng hash và equals(key)?"}
    F -- Có --> UPD[Ghi đè value, return value cũ]
    F -- Không --> TR{"bucket là TreeNode?"}
    TR -- Có --> PT["putTreeVal() - chèn vào red-black tree"] --> SZ
    TR -- Không --> L["Duyệt linked list"]
    L --> LF{"Tìm thấy key trùng?"}
    LF -- Có --> UPD
    LF -- Không --> APP["Nối node mới vào CUỐI list"]
    APP --> TH{"Độ dài chain đạt TREEIFY_THRESHOLD?"}
    TH -- Có --> TB["treeifyBin()"] --> SZ
    TH -- Không --> SZ{"++size > threshold?"}
    SZ -- Có --> RS["resize()"]
    SZ -- Không --> DONE[Xong]
    RS --> DONE

Điểm quan trọng khi so sánh key trong chain:

p.hash == hash && (p.key == key || (key != null && key.equals(p.key)))
  1. So sánh hash trước (phép so sánh int, rất rẻ) để loại nhanh.
  2. So sánh reference (==).
  3. Cuối cùng mới gọi equals().

Đây là lý do contract equals() ↔ hashCode() quan trọng: nếu hai object equals nhưng hashCode khác nhau, chúng có thể nằm ở hai bucket khác nhau, HashMap sẽ coi là hai key khác nhau.

get() đi theo đúng lộ trình trên: tính index → kiểm tra node đầu → duyệt list hoặc tìm trong tree.


5. Treeify: từ linked list sang red-black tree (Java 8+)

5.1. Vì sao cần?

Với linked list, nếu nhiều key dồn vào một bucket, get()/put() trên bucket đó tốn O(k) với k là độ dài chain. Trường hợp xấu nhất (mọi key trùng hash) → O(n), HashMap thoái hóa thành linked list.

5.2. Điều kiện treeify

flowchart TB
    A[Chain dài tới ngưỡng TREEIFY_THRESHOLD = 8] --> B{"table.length >= MIN_TREEIFY_CAPACITY = 64?"}
    B -- Không --> C["resize() - gấp đôi table, chia chain ra"]
    B -- Có --> D["Chuyển bucket thành red-black tree (TreeNode)"]

Tại sao lại ưu tiên resize khi table còn nhỏ? Vì với table nhỏ, chain dài thường chỉ do thiếu bucket chứ không phải hash tệ — resize là giải pháp rẻ và hiệu quả hơn.

5.3. Tại sao ngưỡng là 8?

Với hash phân bố ngẫu nhiên tốt và load factor 0.75, số node trong một bucket tuân theo phân phối Poisson với tham số khoảng 0.5. Theo comment trong source code JDK, xác suất một bucket có 8 phần tử chỉ khoảng 0.00000006. Nghĩa là: nếu treeify xảy ra, gần như chắc chắn hashCode() có vấn đề (hoặc đang bị tấn công). Tree là “lưới an toàn”, không phải trạng thái bình thường.

Tại sao không treeify luôn từ đầu? TreeNode tốn khoảng gấp đôi bộ nhớ so với Node thường (thêm con trỏ parent, left, right, prev và cờ màu), và chi phí duy trì cân bằng cao hơn. Với chain ngắn, duyệt list tuần tự nhanh hơn.

Tại sao untreeify là 6 chứ không phải 8? Tạo hysteresis — tránh tình trạng một bucket dao động quanh 8 phần tử và liên tục chuyển qua lại giữa list và tree.

5.4. Tree được sắp xếp theo gì?

Red-black tree cần thứ tự giữa các key. HashMap so sánh theo thứ tự:

  1. Giá trị hash.
  2. Nếu trùng hash và key implement Comparable cùng kiểu → dùng compareTo().
  3. Nếu vẫn không phân định được → tieBreakOrder() (so sánh tên class, rồi System.identityHashCode).

Hệ quả: key trùng hash và implement Comparable thì lookup thực sự O(log n). Key trùng hash nhưng không Comparable thì khi tìm kiếm có thể phải duyệt cả hai nhánh con → trong trường hợp tệ nhất vẫn có thể O(n). String, Integer, Long đều là Comparable nên được hưởng lợi đầy đủ.


6. Resize: giảm collision bằng cách thêm bucket

Khi size > threshold (threshold = capacity × loadFactor), table được nhân đôi.

Mẹo thông minh của Java 8: vì capacity gấp đôi, index mới chỉ phụ thuộc thêm một bit của hash — bit ở vị trí oldCap:

if ((e.hash & oldCap) == 0)  → giữ nguyên index j          (list "lo")
else                          → chuyển sang index j + oldCap (list "hi")
flowchart LR
    subgraph Old["Trước resize (n = 16)"]
        o5["[5]: A → B → C → D"]
    end
    subgraph New["Sau resize (n = 32)"]
        n5["[5]: A → C  (hash & 16 == 0)"]
        n21["[21]: B → D  (hash & 16 != 0)"]
    end
    o5 --> n5
    o5 --> n21

Không cần tính lại hash, không cần modulo, và thứ tự tương đối trong list được giữ nguyên. Tree bin cũng được tách đôi tương tự; phần nào còn ≤ 6 node sẽ chuyển về list.

Resize tốn O(n) — lý do nên khởi tạo capacity phù hợp khi biết trước số phần tử:

// Java 19+: tính sẵn capacity để chứa 1000 phần tử mà không resize
Map<String, User> map = HashMap.newHashMap(1000);

// Trước Java 19
Map<String, User> map = new HashMap<>((int) Math.ceil(1000 / 0.75));

Lưu ý: new HashMap<>(1000) không đủ để tránh resize — 1000 được làm tròn thành 1024, threshold là 768.


7. Java 7 vs Java 8

Java 7 Java 8+
Xử lý collision Chỉ linked list Linked list + red-black tree
Worst case lookup O(n) O(log n) với key Comparable
Chèn node mới Đầu list (head insertion) Cuối list (tail insertion)
Hash function Nhiều phép shift/XOR Một phép XOR h ^ (h >>> 16) (vì đã có tree làm lưới an toàn)
Resize Tính lại index từng node, đảo ngược thứ tự list Chia lo/hi, giữ thứ tự

Bug kinh điển của Java 7: khi nhiều thread cùng resize một HashMap, head insertion kết hợp với việc đảo thứ tự list có thể tạo ra vòng lặp trong linked list → get() chạy vòng lặp vô hạn, CPU 100%. Java 8 dùng tail insertion nên không còn vòng lặp này, nhưng HashMap vẫn không thread-safe: vẫn có thể mất dữ liệu, size sai, hoặc hỏng cấu trúc tree.


8. Collision như một lỗ hổng bảo mật: Hash flooding

Kẻ tấn công có thể cố tình gửi hàng nghìn key có cùng hashCode (rất dễ sinh với String, như ví dụ "Aa"/"BB"). Nếu server lưu chúng vào HashMap — ví dụ khi parse query parameter, form data, JSON — mọi thao tác thoái hóa thành O(n), tổng chi phí O(n²) → Denial of Service với rất ít request.

Lịch sử xử lý:

Đây là câu trả lời hay khi được hỏi: “Tại sao Java 8 lại thêm red-black tree vào HashMap?”

Bên cạnh đó, server còn giới hạn số lượng parameter (ví dụ Tomcat có maxParameterCount) như một lớp phòng thủ khác.


9. Những bug thực tế liên quan

9.1. hashCode() tệ

@Override
public int hashCode() {
    return 1;   // hợp lệ về contract, nhưng MỌI key rơi vào cùng bucket
}

Đúng về mặt logic nhưng performance thảm họa. Dùng Objects.hash(field1, field2), hoặc dùng record (Java 16+) để có equals/hashCode chuẩn tự động.

9.2. Override equals() mà quên hashCode()

Map<Point, String> map = new HashMap<>();
map.put(new Point(1, 2), "A");
map.get(new Point(1, 2));   // null! Hai object có hashCode (identity) khác nhau → khác bucket

9.3. Key mutable

List<Integer> key = new ArrayList<>(List.of(1, 2));
map.put(key, "value");
key.add(3);                 // hashCode của key thay đổi
map.get(key);               // null — map tìm ở bucket mới, node vẫn nằm ở bucket cũ
map.containsKey(key);       // false, nhưng map.size() == 1 → "memory leak" logic

Nguyên tắc: key của HashMap nên là immutable (String, Integer, record, enum…), hoặc ít nhất các field tham gia hashCode không được thay đổi sau khi put.

9.4. Dùng HashMap giữa nhiều thread

Dùng ConcurrentHashMap. Trong Java 8+, nó dùng CAS khi chèn vào bucket rỗng và synchronized trên node đầu của bucket khi có collision — nên lock chỉ ảnh hưởng một bucket, không phải cả map. Nó cũng treeify giống HashMap, nhưng không cho phép key hoặc value null (vì null gây mơ hồ giữa “không có key” và “value là null” trong môi trường đồng thời).


10. Độ phức tạp tổng hợp

Thao tác Trung bình Worst case Java 7 Worst case Java 8+
get / put / remove O(1) O(n) O(log n) nếu key Comparable, ngược lại có thể O(n)
resize O(n), nhưng amortized O(1) cho mỗi put O(n) O(n)
Duyệt toàn bộ O(n + capacity)

Duyệt toàn bộ map tỉ lệ với capacity, không chỉ với số phần tử — map có capacity lớn nhưng ít phần tử vẫn duyệt chậm.


11. Câu hỏi phỏng vấn thường gặp

  1. Collision là gì? Java xử lý thế nào? → Hai key cùng bucket; separate chaining bằng linked list, Java 8 thêm red-black tree.
  2. Khi nào linked list chuyển thành tree? → Chain đạt ngưỡng 8 và capacity ≥ 64; nếu capacity < 64 thì resize.
  3. Tại sao ngưỡng là 8, và tại sao untreeify là 6? → Theo phân phối Poisson rất hiếm khi xảy ra với hash tốt; 6 để tạo hysteresis.
  4. Tại sao capacity luôn là lũy thừa của 2? → Tính index bằng & thay cho %; resize chỉ cần xét thêm 1 bit.
  5. Tại sao hash() lại XOR với h >>> 16? → Đưa bit cao vào bit thấp vì index chỉ dùng bit thấp.
  6. Chuyện gì xảy ra nếu override equals() mà không override hashCode()? → Object “bằng nhau” rơi vào bucket khác nhau, get trả về null.
  7. Tại sao nên dùng immutable object làm key? → hashCode đổi sau khi put thì không tìm lại được entry.
  8. HashMap có thread-safe không? Có vấn đề gì ở Java 7? → Không; Java 7 có thể vòng lặp vô hạn khi resize đồng thời; dùng ConcurrentHashMap.
  9. Tại sao Java 8 thêm tree vào HashMap? → Giới hạn worst case, chống hash flooding DoS.
Back
0%