HashMap là một trong những cấu trúc dữ liệu được sử dụng phổ biến nhất trong lập trình Java để lưu trữ cặp khóa–giá trị (key–value). Từ phiên bản JDK 1.8, triển khai của HashMap đã được cải tiến đáng kể — đặc biệt là việc tích hợp cây đỏ–đen nhằm giảm độ phức tạp thời gian trong các trường hợp xung đột băm nghiêm trọng. Bài viết này phân tích sâu về kiến trúc bên trong, cơ chế băm, quy trình mở rộng dung lượng (resize), và sự khác biệt then chốt giữa JDK 1.7 và JDK 1.8 — không dựa trên mã nguồn gốc mà thông qua logic thiết kế và hành vi thực thi.
Cấu trúc lưu trữ tổng quan
HashMap kết hợp ba thành phần: mảng băm (hash table), danh sách liên kết (linked list) và cây đỏ–đen (red-black tree). Trong JDK 1.8, khi độ dài chuỗi liên kết tại một vị trí mảng vượt ngưỡng TREEIFY_THRESHOLD = 8, hệ thống tự động chuyển đổi sang cây đỏ–đen để đảm bảo hiệu năng tìm kiếm ở mức O(log n) thay vì O(n).
Mảng chính — gọi là Node[] buckets — luôn có kích thước là lũy thừa của 2 (ví dụ: 16, 32, 64…). Đây là lựa chọn chủ ý nhằm tối ưu hóa phép tính chỉ số bằng toán tử bitwise AND thay vì phép chia modulo:
int index = hash & (buckets.length - 1); // nhanh hơn hash % buckets.length
Hàm băm và phân bố chỉ số
Để giảm thiểu va chạm (collision), JDK 1.8 áp dụng hàm băm hai bước:
- Lấy giá trị
hashCode()từ khóa. - Thực hiện phép XOR giữa 16 bit cao và 16 bit thấp của giá trị hash — giúp khuếch tán ảnh hưởng của các bit ít thay đổi (như trong chuỗi ngắn hoặc số nguyên nhỏ).
Mã minh họa (được viết lại với tên biến rõ nghĩa và logic tương đương):
static final int computeHash(Object key) {
if (key == null) return 0;
int rawHash = key.hashCode();
// Kết hợp bit cao và thấp để tăng tính phân tán
return rawHash ^ (rawHash >>> 16);
}
Kết quả sau khi áp dụng computeHash() được dùng trực tiếp để xác định vị trí trong mảng — không cần phép chia tốn kém.
Quy trình chèn phần tử (put)
Khi gọi put(key, value), HashMap thực hiện tuần tự các bước sau:
- Nếu bảng chưa được khởi tạo hoặc rỗng → gọi
resize()để cấp phát mảng ban đầu (mặc định 16 phần tử). - Tính chỉ số mảng dựa trên hash đã xử lý.
- Nếu ô tại chỉ số đó trống → tạo node mới và đặt vào.
- Nếu ô đã tồn tại phần tử:
- Nếu khóa trùng khớp (cùng hash và
equals()) → ghi đè giá trị. - Nếu node là
TreeNode→ chèn vào cây đỏ–đen. - Nếu là danh sách liên kết → duyệt toàn bộ chuỗi; nếu gặp khóa trùng → ghi đè; nếu độ dài đạt 8 → chuyển sang cây đỏ–đen trước khi chèn.
- Nếu khóa trùng khớp (cùng hash và
- Sau khi chèn thành công, kiểm tra điều kiện mở rộng: nếu
size > threshold→ gọiresize().
Cơ chế mở rộng dung lượng (resize)
Khi số lượng phần tử vượt ngưỡng threshold = capacity × loadFactor (mặc định 0.75), HashMap sẽ nhân đôi kích thước mảng hiện tại. Điểm nổi bật trong JDK 1.8 là thuật toán di chuyển phần tử:
Thay vì tính lại toàn bộ hash cho từng phần tử như JDK 1.7, JDK 1.8 tận dụng đặc tính "kích thước mới = 2 × kích thước cũ" để suy ra vị trí mới chỉ dựa trên một bit duy nhất:
- Nếu bit thứ
log₂(oldCapacity)trong giá trị hash là0→ phần tử giữ nguyên chỉ số. - Nếu bit đó là
1→ chỉ số mới = chỉ số cũ +oldCapacity.
Mã giả mô tả luồng xử lý:
Node<K,V>[] newBuckets = new Node[newCapacity];
for (Node<K,V> current : oldBuckets) {
if (current == null) continue;
Node<K,V> lowHead = null, lowTail = null; // nhóm giữ nguyên chỉ số
Node<K,V> highHead = null, highTail = null; // nhóm dịch sang chỉ số mới
do {
Node<K,V> next = current.next;
if ((current.hash & oldCapacity) == 0) {
if (lowTail == null) lowHead = current;
else lowTail.next = current;
lowTail = current;
} else {
if (highTail == null) highHead = current;
else highTail.next = current;
highTail = current;
}
current = next;
} while (current != null);
if (lowHead != null) {
lowTail.next = null;
newBuckets[originalIndex] = lowHead;
}
if (highHead != null) {
highTail.next = null;
newBuckets[originalIndex + oldCapacity] = highHead;
}
}
Cách tiếp cận này không chỉ tăng tốc độ resize mà còn giúp phân tán đều các phần tử bị xung đột trong mảng cũ sang hai vị trí riêng biệt trong mảng mới — làm giảm khả năng hình thành chuỗi dài.
An toàn đa luồng
HashMap không đồng bộ hóa nội bộ. Trong môi trường đa luồng, việc đồng thời gọi put() từ nhiều luồng có thể dẫn đến:
- Mất dữ liệu do ghi đè không kiểm soát.
- Vòng lặp vô hạn trong quá trình duyệt danh sách (do trạng thái mảng bị phá vỡ trong lúc resize).
ConcurrentHashMap hoặc bao bọc HashMap bằng Collections.synchronizedMap().
So sánh hiệu năng JDK 1.7 vs JDK 1.8
Trong điều kiện băm phân bố đều, cả hai phiên bản đều đạt hiệu năng gần O(1) cho thao tác get(). Tuy nhiên, khi xảy ra xung đột nặng (ví dụ: tất cả khóa trả về cùng giá trị hash), JDK 1.8 thể hiện lợi thế rõ rệt nhờ cơ chế tự cân bằng của cây đỏ–đen:
- JDK 1.7: Độ phức tạp tìm kiếm trong chuỗi dài là O(n).
- JDK 1.8: Sau khi chuyển đổi sang cây đỏ–đen, độ phức tạp giảm xuống O(log n).
Kết quả đo thực nghiệm trên tập dữ liệu 10 triệu phần tử cho thấy JDK 1.8 cải thiện thời gian truy vấn trung bình tới 100% trong các trường hợp xung đột cực đoan — khẳng định giá trị của tối ưu hóa cấu trúc dữ liệu.