HashMap treeify — phòng thủ hash-collision DoS từ Java 8
Attacker collide key chủ ý làm CPU spike 100%. Vì sao TREEIFY_THRESHOLD = 8, MIN_TREEIFY_CAPACITY = 64, và treeify chặn suy biến O(n) tới đâu.
TL;DR: HashMap trước Java 8 có worst case O(n): mọi key rơi cùng bucket thì get/put phải duyệt cả linked list. Attacker biết hash function có thể chế hàng nghìn key cùng hashCode — một request nhỏ đủ làm CPU spike 100% (hash-collision DoS). Java 8 (JEP 180) thêm treeify: chain đạt TREEIFY_THRESHOLD = 8 và table có từ MIN_TREEIFY_CAPACITY = 64 bucket trở lên → bucket chuyển thành red-black tree, worst case còn O(log n). Ngưỡng 8 chọn theo phân phối Poisson (tham số ~0.5 với load factor 0.75): hash tử tế gần như không bao giờ chạm ngưỡng — chạm nghĩa là hash bị lạm dụng. Treeify giảm thiệt hại nhưng không thay được validate input.
Trong một buổi load test, một dev fuzz API bằng 1000 request, mỗi request chứa key thiết kế sao cho tất cả đều hashCode = 0. Endpoint đang 1000 op/s bỗng spike CPU 100%, response time từ 5ms lên vài giây — không exception, không timeout, chỉ chậm dần rồi nghẹt.
Bài trước đã trace happy path: hash đều, chain ngắn, get là O(1). Bài này mổ đường phòng thủ khi hash không tử tế: vì sao 8, vì sao 64, red-black tree cứu được gì — và tầng phòng thủ nào vẫn phải đứng trước HashMap.
1. Ngăn tủ quá đầy — từ danh sách sang cây
Nhớ lại analogy tủ hồ sơ ở bài trước: mỗi ngăn (bucket) đựng một danh sách hồ sơ, tìm đúng người thì duyệt danh sách so từng tên. Ngăn càng dài, duyệt càng lâu. Khi một ngăn dồn tới 8 hồ sơ, tủ đổi chiến thuật: xếp ngăn đó thành cây tìm kiếm — tra O(log n) thay vì lật từng tờ O(n).
| Tủ hồ sơ | HashMap |
|---|---|
| Một ngăn dồn tới 8 hồ sơ → xếp thành cây | TREEIFY_THRESHOLD = 8 → red-black tree |
| Rút bớt hồ sơ, còn 6 tờ → về lại xấp giấy | UNTREEIFY_THRESHOLD = 6 → linked list |
| Tủ còn nhỏ → mua tủ to thay vì xếp cây | dưới MIN_TREEIFY_CAPACITY = 64 → resize() |
Ba hằng số điều khiển cơ chế này trong HashMap.java:
static final int TREEIFY_THRESHOLD = 8; // chain length >= 8 -> try treeify
static final int UNTREEIFY_THRESHOLD = 6; // tree size <= 6 -> back to list
static final int MIN_TREEIFY_CAPACITY = 64; // min table size before treeify (else resize)
Ba con số này không tùy hứng — mỗi con số trả lời một câu hỏi thiết kế riêng, đi lần lượt dưới đây.
2. Vì sao TREEIFY_THRESHOLD = 8?
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.5 (trung bình mỗi bucket chứa ~0.5 entry, vì table luôn thưa sau resize — đừng nhầm với load factor 0.75). Xác suất để một bucket có đúng 8 entry xấp xỉ 6 phần 100 triệu — block comment trong HashMap.java ghi "less than 1 in ten million", tức ít hơn 1 trên 10 triệu. Khi điều đó xảy ra, gần như chắc chắn hash function bị lạm dụng — hoặc hashCode() của key class cực tệ, hoặc attacker cố tình tạo collision.
Treeify vì thế là biện pháp defensive: giảm worst case từ O(n) xuống O(log n) mà không đụng happy path — map bình thường không bao giờ trả chi phí cho nó.
Vì sao không phải con số khác?
- Threshold = 4: treeify quá thường xuyên ngay cả với hash 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.
- Threshold = 16: attacker có window dài gấp đôi để exploit trước khi defense kích hoạt.
Còn UNTREEIFY_THRESHOLD = 6 (không phải 8): khi remove làm tree co xuống 6 entry, tree revert lại linked list. Khoảng đệm 8/6 là hysteresis — nếu hai ngưỡng bằng nhau, map dao động quanh 8 entry sẽ liên tục dựng cây rồi phá cây (thrashing).
3. treeifyBin — resize trước, treeify sau
Chain đạt 8 chưa chắc đã treeify. Trước khi đọc code, tự quyết đã:
Table đang có 16 bucket, một chain vừa chạm 8 entry. Bạn là JDK engineer: dựng red-black tree cho bucket đó ngay, hay còn nước đi nào rẻ hơn? Chốt lựa chọn của bạn trước khi đọc tiếp.
// treeifyBin -- OpenJDK 21
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 (spreads entries, shortens chain)
resize();
} else {
// Table is large enough: convert linked list to red-black tree
// ... (TreeNode conversion logic)
}
}
Logic quyết định:
- Table nhỏ (dưới 64 bucket): gọi
resize()thay vì treeify. Chain dài lúc này thường chỉ vì quá ít bucket — nhân đôi capacity phân tán key ra bucket mới, chain tự ngắn lại mà không cần overhead của cây. - Table từ 64 bucket trở lên: capacity đã đủ lớn mà chain vẫn dài 8 — resize không cứu được nữa (key cố tình cùng hash thì phân tán kiểu gì cũng về cùng bucket). Lúc này mới convert bucket thành red-black tree.
flowchart TD
A["put lam chain dat 8 entry"] --> B{"table.length duoi 64?"}
B -->|"Dung"| C["resize: nhan doi capacity<br/>key phan tan, chain tu ngan lai"]
B -->|"Sai"| D["treeifyBin: linked list -> red-black tree<br/>lookup O(log n)"]
D --> E{"remove khien tree con 6 entry?"}
E -->|"Dung"| F["untreeify: tro lai linked list"]Hằng số này chặn "false alarm": HashMap nhỏ (capacity 16-32) hay có chain dài chỉ vì quá ít bucket. Treeify ngay thì mỗi lần resize sau đó lại phải untreeify (entry phân tán ra nhiều bucket, cây tan) — round-trip lãng phí. Con số 64 đảm bảo treeify chỉ xảy ra khi resize không còn là giải pháp rẻ hơn.
4. Hash-collision DoS — tấn công và phòng thủ
Trước Java 8, attacker biết hash function của JVM (String.hashCode() là thuật toán cố định, public, không randomization) có thể precompute hàng nghìn String key cùng hashCode. Nhét chúng vào form, JSON key, hoặc URL parameter — server deserialize vào HashMap, tất cả rơi vào cùng bucket, mỗi put/get là O(n). Một request vài trăm KB tiêu tốn CPU tương đương hàng triệu operation — denial of service với chi phí attacker gần bằng 0.
Java 8 treeify giảm worst case xuống O(log n) — nhẹ thiệt hại nhưng không loại bỏ hẳn: O(log n) với n = 10.000 vẫn là ~13 bước so với 1 bước của happy path, và attacker vẫn ép map dựng cây liên tục.
Trước khi đọc tiếp: nếu bạn phụ trách endpoint này, hãy tự viết ra hai tầng phòng thủ bạn sẽ đặt TRƯỚC khi request chạm tới HashMap. Rồi so với thứ tự dưới.
Defense theo thứ tự hiệu quả — treeify chỉ là tầng cuối:
- Giới hạn input trước khi parse — validate số key tối đa trong JSON/form (Jackson
StreamReadConstraints, Springmax-request-size). Chặn từ cửa, rẻ nhất. - Rate limiting theo IP/user cho endpoint nhận key tùy ý từ client.
- Treeify (Java 8+) — lưới an toàn cuối, tự động, không config.
- Hash seed randomization — một số runtime cho phép randomize seed per-process để attacker không precompute được (Java hiện chỉ randomize cho một số cấu trúc nội bộ, không phải
String.hashCode()public).
Đây là defense-in-depth: không tầng nào một mình đủ, và không tầng nào thay được tầng đứng trước nó.
5. Deep Dive
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 lựa chọn threshold.
OpenJDK source:
HashMap.java(tag jdk-21+35) — đọctreeifyBin()và block comment đầu class: giải thích phân phối Poisson (tham số ~0.5) và vì sao chọn 8. Đọc comment trong source rất đáng — JDK engineer tự ghi lại từng quyết định.
Liên kết khoá học khác
- HashMap internals — bucket array, chaining, resize — cơ chế nền của bài này: happy path
O(1), put path, resize. - Hàm băm — khoá Thuật toán — uniform distribution và hashCode/equals contract, điều kiện để không bao giờ chạm ngưỡng treeify.
- ConcurrentHashMap nội thất — ConcurrentHashMap cũng treeify bucket theo cùng ngưỡng, nhưng phối hợp với CAS per-bucket.
6. Tóm tắt
- Treeify (Java 8, JEP 180): chain đạt 8 entry chuyển bucket thành red-black tree — lookup thoái hóa từ
O(n)xuốngO(log n). Đây là defense chống hash xấu, không phải optimization cho hash tốt. - TREEIFY_THRESHOLD = 8 theo phân phối Poisson (tham số ~0.5): hash đều gần như không bao giờ chạm (xác suất ~1/17 triệu) — chạm ngưỡng là tín hiệu hash bị lạm dụng.
- UNTREEIFY_THRESHOLD = 6 tạo hysteresis với ngưỡng 8 — tránh thrashing dựng/phá cây khi size dao động quanh ngưỡng.
- MIN_TREEIFY_CAPACITY = 64: table nhỏ thì resize thay vì treeify — phân tán key rẻ hơn dựng cây; chỉ treeify khi resize hết cứu được.
- Hash-collision DoS: attacker precompute key cùng hashCode, mọi operation thành
O(n). Treeify giảm cònO(log n)— giảm thiệt hại, không miễn nhiễm. - Defense-in-depth: giới hạn input + rate limiting đứng trước; treeify là lưới an toàn cuối.
7. 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.5 (trung bình ~0.5 entry mỗi bucket — đừng nhầm với load factor 0.75). Xác suất để một bucket có đúng 8 entry xấp xỉ 1 trên 17 triệu ("less than 1 in ten million" theo block comment của HashMap.java). 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) — round-trip lãng phí. 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.
Q3Vì sao UNTREEIFY_THRESHOLD = 6 chứ không bằng luôn 8?▸
Nếu hai ngưỡng bằng nhau (cùng là 8), một map có bucket dao động quanh 8 entry — put lên 8, remove xuống 7, put lại lên 8 — sẽ liên tục dựng cây rồi phá cây. Mỗi lần convert đều tốn công: dựng red-black tree phải rebalance, phá cây phải duyệt lại toàn bộ node.
Khoảng đệm 8/6 tạo hysteresis: đã dựng cây thì phải co hẳn xuống 6 mới quay về list, đã về list thì phải lên lại 8 mới dựng cây. Size dao động trong khoảng 6–8 không gây chuyển đổi nào. Đây là pattern chung khi thiết kế ngưỡng chuyển trạng thái hai chiều (thermostat cũng vậy: bật ở 18 độ, tắt ở 20 độ).
Q4Hash 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, 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 StreamReadConstraints, Spring max-request-size). (2) Rate limiting theo IP/user cho endpoint nhận arbitrary key. (3) Treeify (Java 8+) — giảm worst case xuống O(log n), không loại bỏ nhưng giảm impact. (4) Hash seed randomization — runtime randomize seed per-process để attacker không precompute được.
Q5Production: một endpoint CPU spike 100% sau vài request lớn, không exception, không timeout. Làm sao xác nhận đây là hash-collision DoS chứ không phải GC hay lock contention?▸
Ba triệu chứng này phân biệt được bằng thread dump / profiler (async-profiler, JFR):
Collision DoS: các thread xử lý request đều RUNNABLE và stack dồn vào chính code duyệt bucket — HashMap.getNode/putVal (hoặc TreeNode.find nếu đã treeify). CPU cháy trong code "có ích" nhưng mỗi operation tốn hàng nghìn bước so sánh. GC: CPU nằm ở GC thread, GC log hiện pause dài/liên tục, application thread dừng ở safepoint. Lock contention: thread BLOCKED/WAITING chờ monitor — CPU thường thấp chứ không phải 100%.
Xác nhận thêm bằng cách đối chiếu input: request nghi vấn chứa lượng key bất thường (JSON body nhiều field, form nhiều param) — replay payload đó vào môi trường test và đo lại. Nếu đúng collision DoS, defense đứng trước là giới hạn số key và rate limiting (§4).
Q6Vì sao HashMap không dùng red-black tree cho MỌI bucket ngay từ đầu, khỏi cần treeify?▸
Vì happy path sẽ trả giá cho worst case gần như không bao giờ xảy ra. Với hash đều, đa số bucket chứa 0–1 entry — linked list (thực chất là một Node đơn) rẻ hơn hẳn: node nhỏ hơn (TreeNode cần thêm con trỏ parent/left/right/prev và cờ màu — gấp đôi kích thước), insert không cần rebalance, và duyệt chain ngắn thân thiện với CPU cache hơn nhảy con trỏ trong cây.
Thiết kế của HashMap là trả chi phí theo nhu cầu: cấu trúc rẻ cho trường hợp phổ biến, tự nâng cấp lên cấu trúc đắt khi có bằng chứng hash bị lạm dụng (chain đạt 8). Đây cũng là lý do TreeMap tồn tại riêng: khi cần ordering thật sự thì dùng cây từ đầu, còn HashMap tối ưu cho tra cứu trung bình O(1).
Đọc thêm ở khoá Thuật toán: Open addressing — probing tuyến tính/bậc hai, robin hood — chiến lược xử lý va chạm còn lại, và vì sao Java chọn chaining chứ không chọn nó.
Bài tiếp theo: Mini-challenge — chẩn đoán P99 spike và collision DoS
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên