- Nắm interface
Map<K,V>và toàn bộ API cần thiết. - Hiểu bên trong
HashMap: bảng bucket, hàm băm, va chạm, hệ số tải, rehash, treeify. - Nắm hợp đồng
equals/hashCodevà hậu quả cụ thể khi vi phạm. - Chọn đúng giữa
HashMap,LinkedHashMap,TreeMap; biết vì sao tránhHashtable. - Duyệt
Mapđúng cách quaentrySet(). - Dùng thành thạo API Java 8+:
getOrDefault,computeIfAbsent,merge,putIfAbsent,forEach. - Hiểu vì sao khoá phải bất biến — và điều gì xảy ra khi không.
- Dùng
LinkedHashMapđể làm bộ nhớ đệm LRU chỉ với một dòng. - Nắm các truy vấn thứ tự của
TreeMap. - Trả lời được các câu hỏi phỏng vấn kinh điển về
HashMap.
1. Map là gì
Map<K, V> lưu dữ liệu dưới dạng cặp khoá – giá trị. Mỗi khoá là
duy nhất và ánh xạ tới đúng một giá trị. Nó nằm trong java.util nhưng
không kế thừa Collection (lý do đã nêu ở chương 7).
Map<String, Integer> diem = new HashMap<>();
// --- Thêm / cập nhật ---
diem.put("An", 8); // trả về null (khoá mới)
diem.put("Bình", 9);
Integer cu = diem.put("An", 10); // trả về 8 — GIÁ TRỊ CŨ, và ghi đè
diem.putIfAbsent("An", 5); // không làm gì vì "An" đã có
// --- Đọc ---
diem.get("An"); // 10
diem.get("Chi"); // null ⚠️ — không ném lỗi
diem.getOrDefault("Chi", 0); // 0 ⭐ an toàn hơn nhiều
// ⚠️ Bẫy NullPointerException khi unboxing
// int d = diem.get("Chi"); // NPE — null không unbox thành int được
int d = diem.getOrDefault("Chi", 0); // ✅
// --- Kiểm tra / xoá ---
diem.containsKey("An"); // true — O(1) ⚡
diem.containsValue(10); // true — O(n) ⚠️ phải duyệt hết
diem.remove("An"); // trả về 10
diem.remove("Bình", 5); // chỉ xoá nếu giá trị khớp — trả false
diem.size();
diem.isEmpty();
diem.clear();
// --- Map bất biến (Java 9+) ---
Map<String, Integer> batBien = Map.of("a", 1, "b", 2); // tối đa 10 cặp
Map<String, Integer> nhieu = Map.ofEntries(
Map.entry("a", 1), Map.entry("b", 2) // không giới hạn
);
2. HashMap hoạt động bên trong
Đây là phần quan trọng nhất chương — và là câu hỏi phỏng vấn Java được hỏi nhiều nhất.
"An"65759h ^ (h>>>16)h & (n−1)→ bucket 15
equals()trong bucket
hashCode() chọn ngăn, equals() tìm đúng phần
tử trong ngăn đó. Đây là lý do hai phương thức phải nhất quán với nhau.
HashMap
│
└── Node[] table // mảng "bucket", kích thước LUÔN là luỹ thừa của 2
│
├── [0] → null
├── [1] → Node("An", 10) → null
├── [2] → null
├── [3] → Node("Bình", 9) → Node("Chi", 7) → null // VA CHẠM: liên kết
├── ...
└── [15] → Node("Dũng", 8) → null
// Mỗi Node lưu 4 thứ:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // mã băm đã trộn — cache lại để không tính lại
final K key;
V value;
Node<K,V> next; // node kế trong cùng bucket (chuỗi va chạm)
}
2.1. Thuật toán put và get
// ---------- Bước trộn bit ----------
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// Vì sao XOR với 16 bit cao? Xem callout bên dưới.
// ---------- put ----------
public V put(K key, V value) {
int h = hash(key);
int i = h & (table.length - 1); // ① tìm bucket
for (Node<K,V> e = table[i]; e != null; e = e.next) { // ② duyệt chuỗi
if (e.hash == h && (e.key == key || key.equals(e.key))) {
V cu = e.value;
e.value = value; // ③ khoá đã có → ghi đè
return cu;
}
}
table[i] = new Node<>(h, key, value, table[i]); // ④ thêm vào đầu chuỗi
if (++soLuong > nguong) resize(); // ⑤ đầy quá → nới rộng
return null;
}
// ---------- get ----------
public V get(Object key) {
int h = hash(key);
for (Node<K,V> e = table[h & (table.length-1)]; e != null; e = e.next) {
if (e.hash == h && (e.key == key || key.equals(e.key)))
return e.value;
}
return null;
}
Ba chi tiết thiết kế thông minh
-
h & (n-1)thay vìh % n— vìnluôn là luỹ thừa của 2, phép AND bit cho kết quả giống hệt phép chia lấy dư nhưng nhanh hơn nhiều lần. Đây là lý do kích thước bảng luôn là 16, 32, 64… -
h ^ (h >>> 16)— trộn 16 bit cao xuống 16 bit thấp. Vìh & (n-1)chỉ dùng vài bit thấp nhất, nếu hai mã băm khác nhau chỉ ở bit cao thì chúng sẽ rơi vào cùng bucket. Bước trộn khuếch tán sự khác biệt xuống dưới. -
So sánh
e.hash == htrước — phép so hai số nguyên rẻ hơn nhiều so với gọiequals(). Chỉ khi mã băm khớp mới tốn công gọiequals.
2.2. Va chạm và cơ chế treeify
// Hai khoá khác nhau có thể cho cùng chỉ số bucket → VA CHẠM
// Trước Java 8: luôn dùng danh sách liên kết
[3] → Node(k1) → Node(k2) → Node(k3) → ... → Node(k100)
// Trường hợp xấu nhất: mọi khoá cùng bucket → get() thành O(n)
// → lỗ hổng "hash collision DoS": kẻ tấn công gửi các khoá cố ý trùng hash
// Từ Java 8: chuỗi dài quá 8 phần tử VÀ bảng ≥ 64 → chuyển thành CÂY ĐỎ ĐEN
[3] → TreeNode
╱ ╲
TreeNode TreeNode // tra cứu O(log n) thay vì O(n)
╱ ╲ ╱ ╲
// Khi chuỗi rút xuống ≤ 6 phần tử (do xoá hoặc resize) → quay lại liên kết
// Hai ngưỡng 8 và 6 khác nhau để tránh chuyển đổi qua lại liên tục (hysteresis)
Hằng số trong HashMap | Giá trị | Ý nghĩa |
|---|---|---|
DEFAULT_INITIAL_CAPACITY | 16 | Số bucket ban đầu |
DEFAULT_LOAD_FACTOR | 0.75 | Đầy 75% thì nới rộng |
TREEIFY_THRESHOLD | 8 | Chuỗi dài hơn 8 → chuyển sang cây |
UNTREEIFY_THRESHOLD | 6 | Cây nhỏ hơn 6 → quay lại liên kết |
MIN_TREEIFY_CAPACITY | 64 | Bảng nhỏ hơn 64 thì nới rộng thay vì treeify |
// Khi soLuong > capacity × loadFactor (mặc định 16 × 0.75 = 12):
// 1. Tạo bảng mới GẤP ĐÔI (16 → 32)
// 2. Phân bổ lại MỌI node vào bucket mới
// → Thao tác này TỐN KÉM: O(n)
// ❌ Thêm 1 triệu phần tử vào map mặc định
Map<String, Integer> m = new HashMap<>();
// → khoảng 17 lần resize, mỗi lần phân bổ lại toàn bộ
// ✅ Biết trước số lượng thì đặt dung lượng ban đầu
Map<String, Integer> m2 = new HashMap<>(1_400_000);
// Công thức: soPhanTu / 0.75 + 1, làm tròn lên luỹ thừa của 2
// ✅ Java 19+: có hàm tiện ích làm sẵn việc đó
Map<String, Integer> m3 = HashMap.newHashMap(1_000_000);
3. Hợp đồng equals/hashCode
a.equals(b) thì bắt buộc
a.hashCode() == b.hashCode().Chiều ngược lại không bắt buộc — hai object khác nhau được phép trùng mã băm (gọi là va chạm), và
HashMap xử lý được điều đó.
// ❌ Chỉ override equals, QUÊN hashCode
class SanPham {
String ma;
SanPham(String ma) { this.ma = ma; }
@Override
public boolean equals(Object o) {
return o instanceof SanPham s && ma.equals(s.ma);
}
// KHÔNG có hashCode() → dùng bản mặc định của Object (dựa trên địa chỉ)
}
Map<SanPham, Integer> kho = new HashMap<>();
kho.put(new SanPham("SP01"), 100);
System.out.println(kho.get(new SanPham("SP01"))); // null ⚠️⚠️
System.out.println(kho.containsKey(new SanPham("SP01"))); // false ⚠️
// Vì sao? Hai object có mã băm KHÁC NHAU (địa chỉ khác nhau)
// → rơi vào hai bucket khác nhau → equals() KHÔNG BAO GIỜ được gọi
// ✅ Override CẢ HAI
class SanPham {
final String ma;
@Override
public boolean equals(Object o) {
if (this == o) return true;
return o instanceof SanPham s && Objects.equals(ma, s.ma);
}
@Override
public int hashCode() {
return Objects.hash(ma); // ĐÚNG những trường như equals
}
}
// ✅✅ Tốt nhất: dùng record — sinh sẵn cả hai đúng chuẩn
record SanPham(String ma) { }
class Khoa {
String ten; // ⚠️ KHÔNG final — sửa được
Khoa(String ten) { this.ten = ten; }
@Override public int hashCode() { return ten.hashCode(); }
@Override public boolean equals(Object o) {
return o instanceof Khoa k && ten.equals(k.ten);
}
}
Map<Khoa, String> m = new HashMap<>();
Khoa k = new Khoa("A");
m.put(k, "giá trị"); // lưu vào bucket ứng với hash("A")
k.ten = "B"; // 💥 đổi trường tham gia tính hash
System.out.println(m.get(k)); // null ⚠️ tìm ở bucket hash("B") — không có gì
System.out.println(m.size()); // 1 — nó VẪN Ở TRONG map!
System.out.println(m.remove(k)); // null — xoá cũng không được
// → Entry bị "kẹt" vĩnh viễn: không tìm được, không xoá được, không giải phóng được.
Quy tắc: khoá của Map (và phần tử của Set) phải
bất biến, hoặc ít nhất các trường tham gia hashCode phải
final. Đây là lý do String, Integer, record,
enum là những lựa chọn khoá lý tưởng — và cũng là lý do String được thiết kế
bất biến ngay từ đầu (chương 2).
4. Bảng so sánh các cài đặt Map
| Tiêu chí | HashMap | LinkedHashMap | TreeMap | Hashtable |
|---|---|---|---|---|
| Thứ tự | Không xác định | Thứ tự thêm vào (hoặc thứ tự truy cập) |
Sắp theo khoá (tự nhiên hoặc Comparator) |
Không xác định |
Khoá null |
✅ Một cái | ✅ Một cái | ❌ NPE | ❌ NPE |
Giá trị null |
✅ | ✅ | ✅ | ❌ NPE |
| An toàn đa luồng | ❌ Không | ❌ Không | ❌ Không | ✅ Có (đồng bộ) |
| Hiệu năng | O(1) — nhanh nhất | O(1) — chậm hơn chút | O(log n) | Chậm (khoá toàn bộ) |
| Cấu trúc bên dưới | Bảng băm + liên kết/cây | Bảng băm + liên kết đôi | Cây đỏ đen | Bảng băm + liên kết |
| Bộ nhớ | Ít nhất | Nhiều hơn (~1,3×) | Trung bình | Ít |
| Từ phiên bản | Java 1.2 | Java 1.4 | Java 1.2 | Java 1.0 ⚠️ |
| Dùng khi | Mặc định | Cần thứ tự ổn định, cache LRU | Cần sắp xếp, truy vấn khoảng | Không bao giờ — dùng ConcurrentHashMap |
Hashtable đã lỗi thời- Đồng bộ hoá thô — mọi phương thức đều
synchronizedtrên toàn bộ object. Hai luồng đọc hai khoá khác nhau vẫn phải xếp hàng chờ nhau. - Chậm khi một luồng — bạn trả giá cho đồng bộ hoá dù không cần.
- Không cho
null— cả khoá lẫn giá trị, gây bất tiện không cần thiết. - API cũ — còn giữ
elements(),keys()trả vềEnumerationthay vìIterator.
Cần an toàn đa luồng? Dùng ConcurrentHashMap — nó khoá theo từng đoạn
(segment) chứ không khoá toàn bộ, nên nhanh hơn Hashtable hàng chục lần khi có nhiều luồng.
Chương 12 sẽ nói kỹ.
5. Duyệt Map
Map<String, Integer> diem = Map.of("An", 8, "Bình", 9, "Chi", 7);
// ⭐ CÁCH TỐT NHẤT: entrySet() — một lần duyệt lấy cả khoá lẫn giá trị
for (Map.Entry<String, Integer> e : diem.entrySet()) {
System.out.println(e.getKey() + " = " + e.getValue());
}
for (var e : diem.entrySet()) { ... } // gọn hơn với var
// ❌ TỆ: keySet() rồi get() — mỗi vòng lặp là MỘT lần tra cứu thừa
for (String k : diem.keySet()) {
System.out.println(k + " = " + diem.get(k)); // tra cứu lại — chậm gấp đôi
}
// ✅ Chỉ cần khoá / chỉ cần giá trị
for (String k : diem.keySet()) { ... }
for (Integer v : diem.values()) { ... }
// ⭐ forEach (Java 8+) — ngắn gọn nhất
diem.forEach((k, v) -> System.out.println(k + " = " + v));
// Duyệt theo thứ tự sắp xếp mà không đổi kiểu map
diem.entrySet().stream()
.sorted(Map.Entry.comparingByValue(Comparator.reverseOrder()))
.forEach(e -> System.out.println(e));
// ⚠️ Xoá khi đang duyệt — phải qua iterator hoặc removeIf
diem2.entrySet().removeIf(e -> e.getValue() < 5); // ✅
diem2.values().removeIf(v -> v < 5); // ✅ tương đương
keySet, values, entrySet là khung nhìn
Ba phương thức này không tạo bản sao — chúng trả về khung nhìn (view) gắn liền với map
gốc. Hệ quả: xoá phần tử trong keySet() là xoá luôn khỏi map; sửa map là thấy ngay trong
view. Điều này khiến chúng rất rẻ (không tốn bộ nhớ) nhưng cũng dễ gây bất ngờ nếu bạn tưởng đó là bản
sao. Muốn bản sao thật: new HashSet<>(map.keySet()).
6. API hiện đại — Java 8+
Map<String, Integer> dem = new HashMap<>();
// ---------- getOrDefault: tránh null ----------
int n = dem.getOrDefault("x", 0);
// ---------- ĐẾM TẦN SUẤT — ba cách ----------
String[] tu = {"java", "python", "java", "go", "java"};
// ❌ Cách cũ, dài dòng
for (String t : tu) {
if (dem.containsKey(t)) dem.put(t, dem.get(t) + 1);
else dem.put(t, 1);
}
// ✅ getOrDefault
for (String t : tu) dem.put(t, dem.getOrDefault(t, 0) + 1);
// ✅✅ merge — ngắn nhất và diễn đạt đúng ý nhất
for (String t : tu) dem.merge(t, 1, Integer::sum);
// Ý nghĩa: chưa có khoá → đặt 1; đã có → áp dụng Integer::sum(cũ, 1)
System.out.println(dem); // {java=3, python=1, go=1}
// ---------- computeIfAbsent: nhóm phần tử ----------
Map<String, List<String>> nhom = new HashMap<>();
// ❌ Cách cũ
for (String t : tu) {
String chuDau = t.substring(0, 1);
if (!nhom.containsKey(chuDau)) nhom.put(chuDau, new ArrayList<>());
nhom.get(chuDau).add(t);
}
// ✅ computeIfAbsent — một dòng
for (String t : tu) {
nhom.computeIfAbsent(t.substring(0, 1), k -> new ArrayList<>()).add(t);
}
// {j=[java, java, java], p=[python], g=[go]}
// ---------- Toàn bộ họ compute* ----------
dem.computeIfAbsent("rust", k -> 0); // chỉ khi CHƯA có khoá
dem.computeIfPresent("java", (k, v) -> v * 2); // chỉ khi ĐÃ có
dem.compute("go", (k, v) -> v == null ? 1 : v + 1); // cả hai trường hợp
// ⚠️ Hàm trả về null → XOÁ khoá khỏi map
dem.putIfAbsent("kotlin", 1); // khác computeIfAbsent: giá trị đã tính sẵn
dem.replaceAll((k, v) -> v * 10); // đổi mọi giá trị
dem.replace("java", 100); // chỉ đổi nếu khoá tồn tại
dem.remove("go", 1); // chỉ xoá nếu giá trị khớp
merge vs computeIfAbsent — chọn cái nào| Nhu cầu | Dùng | Ví dụ |
|---|---|---|
| Đếm / cộng dồn số | merge | m.merge(k, 1, Integer::sum) |
| Gộp chuỗi | merge | m.merge(k, s, (a,b) -> a + ", " + b) |
| Nhóm vào collection | computeIfAbsent | m.computeIfAbsent(k, x -> new ArrayList<>()).add(v) |
| Bộ nhớ đệm lười | computeIfAbsent | cache.computeIfAbsent(k, this::tinhToanNang) |
Điểm khác then chốt: computeIfAbsent chỉ chạy hàm khi khoá chưa có (nên rất hợp với
phép tính tốn kém), còn merge luôn cần một giá trị mặc định và một hàm gộp.
7. LinkedHashMap và bộ nhớ đệm LRU
// Chế độ mặc định: giữ thứ tự THÊM VÀO
Map<String, Integer> m = new LinkedHashMap<>();
m.put("c", 3); m.put("a", 1); m.put("b", 2);
System.out.println(m); // {c=3, a=1, b=2} — đúng thứ tự thêm
// ⭐ Chế độ THỨ TỰ TRUY CẬP — tham số thứ ba = true
Map<String, Integer> lru = new LinkedHashMap<>(16, 0.75f, true);
lru.put("a", 1); lru.put("b", 2); lru.put("c", 3);
lru.get("a"); // truy cập "a" → đưa nó xuống cuối
System.out.println(lru); // {b=2, c=3, a=1}
// ⭐⭐ Bộ nhớ đệm LRU hoàn chỉnh — chỉ cần override MỘT method
class LruCache<K, V> extends LinkedHashMap<K, V> {
private final int sucChua;
LruCache(int sucChua) {
super(sucChua, 0.75f, true); // true = thứ tự truy cập
this.sucChua = sucChua;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> cuNhat) {
return size() > sucChua; // đầy thì tự loại phần tử ít dùng nhất
}
}
LruCache<String, String> cache = new LruCache<>(3);
cache.put("a", "1"); cache.put("b", "2"); cache.put("c", "3");
cache.get("a"); // "a" thành mới nhất
cache.put("d", "4"); // đầy → loại "b" (ít dùng nhất)
System.out.println(cache); // {c=3, a=1, d=4}
8. TreeMap — truy vấn theo thứ tự
TreeMap<Integer, String> t = new TreeMap<>();
t.put(30, "ba mươi");
t.put(10, "mười");
t.put(20, "hai mươi");
System.out.println(t); // {10=mười, 20=hai mươi, 30=ba mươi} — LUÔN sắp xếp
// --- Truy vấn hai đầu ---
t.firstKey(); // 10
t.lastKey(); // 30
t.firstEntry(); // 10=mười
t.pollFirstEntry(); // lấy VÀ xoá
// --- Truy vấn lân cận ---
t.floorKey(25); // 20 — khoá lớn nhất ≤ 25
t.ceilingKey(25); // 30 — khoá nhỏ nhất ≥ 25
t.lowerKey(20); // 10 — nhỏ hơn hẳn
t.higherKey(20); // 30 — lớn hơn hẳn
// --- Khung nhìn khoảng ---
t.headMap(20); // {10=mười} — nhỏ hơn 20
t.tailMap(20); // {20=..., 30=...} — từ 20 trở lên
t.subMap(10, true, 20, true); // [10, 20] — kiểm soát cả hai biên
t.descendingMap(); // đảo ngược thứ tự
// --- Sắp theo tiêu chí riêng ---
TreeMap<String, Integer> khongPhanBiet =
new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
// --- Ứng dụng thật: tra bảng theo khoảng ---
TreeMap<Integer, String> xepLoai = new TreeMap<>();
xepLoai.put(0, "Yếu");
xepLoai.put(50, "Trung bình");
xepLoai.put(70, "Khá");
xepLoai.put(90, "Giỏi");
int diem = 75;
System.out.println(xepLoai.floorEntry(diem).getValue()); // "Khá"
// ⭐ Tra bảng theo khoảng chỉ với MỘT lời gọi — HashMap không làm được
9. Các mẫu ứng dụng thực tế
// ---------- ① Đếm tần suất ----------
Map<String, Integer> tanSuat = new HashMap<>();
for (String t : danhSachTu) tanSuat.merge(t, 1, Integer::sum);
// Top 5 từ xuất hiện nhiều nhất
tanSuat.entrySet().stream()
.sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
.limit(5)
.forEach(e -> System.out.printf("%-15s %d%n", e.getKey(), e.getValue()));
// ---------- ② Nhóm theo tiêu chí ----------
Map<String, List<SinhVien>> theoLop = new HashMap<>();
for (SinhVien sv : danhSach) {
theoLop.computeIfAbsent(sv.lop(), k -> new ArrayList<>()).add(sv);
}
// (Chương 10 sẽ làm việc này bằng Collectors.groupingBy — một dòng)
// ---------- ③ Chỉ mục tra cứu nhanh ----------
Map<String, SinhVien> chiMuc = new HashMap<>();
for (SinhVien sv : danhSach) chiMuc.put(sv.ma(), sv);
SinhVien tim = chiMuc.get("SV001"); // O(1) thay vì duyệt O(n)
// ---------- ④ Bộ nhớ đệm cho phép tính tốn kém ----------
private final Map<Integer, Long> cacheFib = new HashMap<>();
long fib(int n) {
if (n <= 1) return n;
return cacheFib.computeIfAbsent(n, k -> fib(k-1) + fib(k-2));
// ⚠️ CẢNH BÁO: đệ quy trong computeIfAbsent gây ConcurrentModificationException
// từ Java 9. Xem callout bên dưới.
}
// ---------- ⑤ EnumMap: nhanh nhất khi khoá là enum ----------
enum Thu { HAI, BA, TU, NAM, SAU, BAY, CN }
Map<Thu, String> lich = new EnumMap<>(Thu.class);
lich.put(Thu.HAI, "Họp nhóm");
// Bên trong chỉ là một MẢNG đánh chỉ số theo ordinal() → nhanh hơn HashMap,
// tốn ít bộ nhớ hơn, và luôn duyệt theo thứ tự khai báo enum.
computeIfAbsent// ❌ Từ Java 9: ném ConcurrentModificationException
cache.computeIfAbsent(n, k -> fib(k-1) + fib(k-2));
// Hàm bên trong lại sửa chính map đang được computeIfAbsent xử lý.
// Java 8 im lặng làm hỏng cấu trúc bên trong; Java 9+ phát hiện và báo lỗi.
// ✅ Tách rõ hai bước
long fib(int n) {
if (n <= 1) return n;
Long daCo = cache.get(n);
if (daCo != null) return daCo;
long kq = fib(n-1) + fib(n-2); // tính XONG rồi mới đụng map
cache.put(n, kq);
return kq;
}
Quy tắc chung: hàm truyền vào computeIfAbsent, merge, compute
không được sửa chính map đó — dù trực tiếp hay gián tiếp.
10. Góc phỏng vấn
| Câu hỏi | Trả lời ngắn gọn |
|---|---|
HashMap hoạt động thế nào? | Bảng bucket; hashCode() chọn bucket qua h & (n−1), equals() tìm entry trong bucket; va chạm xử lý bằng liên kết, chuỗi dài >8 và bảng ≥64 thì chuyển thành cây đỏ đen |
| Vì sao dung lượng là luỹ thừa của 2? | Để dùng h & (n−1) thay h % n — nhanh hơn nhiều |
Vì sao trộn h ^ (h>>>16)? | Chỉ số chỉ dùng bit thấp; trộn để bit cao cũng ảnh hưởng, giảm va chạm |
| Hệ số tải 0.75 nghĩa là gì? | Đầy 75% thì nới rộng gấp đôi và phân bổ lại — cân bằng giữa bộ nhớ và va chạm |
| Điều gì xảy ra khi hai khoá trùng hash? | Cùng bucket, nối thành chuỗi; equals() phân biệt chúng |
| Vì sao khoá nên bất biến? | Đổi trường tham gia hash khiến entry nằm sai bucket → không tìm, không xoá được |
HashMap vs Hashtable? | Không đồng bộ vs đồng bộ; cho null vs không; nhanh vs chậm — luôn chọn HashMap |
HashMap vs ConcurrentHashMap? | Cái sau an toàn luồng nhờ khoá theo đoạn, không khoá toàn bộ như Hashtable |
Tại sao HashMap không an toàn đa luồng? | Resize đồng thời có thể làm hỏng cấu trúc; trước Java 8 còn gây vòng lặp vô hạn |
equals mà quên hashCode thì sao? | get() trả null, contains() trả false, Set chứa phần tử trùng |
11. Cheat sheet chương 8
| Tình huống | Cách viết |
|---|---|
| Khai báo chuẩn | Map<K,V> m = new HashMap<>(); |
| Đọc an toàn (tránh NPE) | m.getOrDefault(k, macDinh) |
| Đếm tần suất | m.merge(k, 1, Integer::sum) |
| Nhóm vào list | m.computeIfAbsent(k, x -> new ArrayList<>()).add(v) |
| Cache lười | cache.computeIfAbsent(k, this::tinhToan) |
| Chỉ thêm nếu chưa có | m.putIfAbsent(k, v) |
| Duyệt | for (var e : m.entrySet()) hoặc m.forEach((k,v) -> …) |
| Xoá theo điều kiện | m.entrySet().removeIf(e -> …) |
| Map bất biến | Map.of(k1,v1, k2,v2) · nhiều hơn 10 cặp: Map.ofEntries(…) |
| Cần thứ tự thêm vào | LinkedHashMap |
| Cần sắp theo khoá | TreeMap |
| Tra bảng theo khoảng | treeMap.floorEntry(x) |
| Cache LRU | LinkedHashMap(n, .75f, true) + override removeEldestEntry |
| Khoá là enum | EnumMap — nhanh hơn và gọn hơn HashMap |
| Đa luồng | ConcurrentHashMap — không Hashtable |
| Lớp làm khoá | Override cả equals và hashCode; tốt nhất dùng record |
| Biết trước số lượng | new HashMap<>(n / 0.75f + 1) hoặc HashMap.newHashMap(n) |
Bài tập
Chứng minh hợp đồng equals/hashCode
Tạo lớp Diem với hai trường int x, y và làm bốn thí nghiệm, ghi lại kết quả mỗi lần:
- Không override gì cả → đưa hai
Diem(1,2)vàoHashMaplàm khoá.size()bằng bao nhiêu?get()trả gì? - Chỉ override
equals→ lặp lại. - Override cả hai → lặp lại.
- Override
hashCode()trả về hằng số (ví dụ luôn trả1) →Mapcòn chạy đúng không? Đo tốc độ với 100.000 khoá và so với bản bình thường.
Gợi ý
Thí nghiệm 4 là quan trọng nhất: hashCode hằng số vẫn đúng về mặt hợp đồng
(bằng nhau thì hash bằng nhau), nhưng mọi khoá rơi vào một bucket, biến
HashMap thành danh sách liên kết O(n) — hoặc cây
O(log n) từ Java 8 nhờ treeify. Bạn sẽ thấy chậm hơn hàng trăm
lần. Bài học: hợp đồng là điều kiện cần, phân tán tốt là điều kiện đủ.
Tự cài MyHashMap
Cài đặt bảng băm từ đầu, chỉ dùng mảng:
public class MyHashMap<K, V> {
private static class Node<K,V> { final int hash; final K key; V value; Node<K,V> next; }
private Node<K,V>[] bang;
private int soLuong;
public V put(K k, V v);
public V get(Object k);
public V remove(Object k);
public boolean containsKey(Object k);
public int size();
public Set<K> keySet();
}
- Dung lượng ban đầu 16, luôn là luỹ thừa của 2; dùng
h & (n-1). - Trộn hash bằng
h ^ (h >>> 16). - Xử lý va chạm bằng liên kết; hỗ trợ khoá
null(hash = 0). - Tự nới rộng khi
soLuong > bang.length * 0.75. - Thêm phương thức
thongKe()in ra: số bucket rỗng, độ dài chuỗi dài nhất, độ dài trung bình. - Thử với
hashCodetốt vàhashCodetệ (hằng số), so sánh thống kê.
Gợi ý
Tạo mảng generic: @SuppressWarnings("unchecked") Node<K,V>[] t = new Node[n];
— chương 9 giải thích vì sao phải làm vậy.
Khi resize, bạn phải tính lại chỉ số cho mọi node. JDK có một tối ưu hay: vì dung lượng nhân
đôi, node hoặc ở nguyên chỉ số cũ, hoặc chuyển sang cũ + dungLượngCũ — quyết định
bằng một phép AND bit duy nhất. Thử tự tìm ra quy luật đó.
Phân tích tệp nhật ký
Cho tệp log mỗi dòng dạng timestamp|level|module|message, viết chương trình báo cáo:
- Số dòng theo từng cấp độ (
merge). - Với mỗi module, danh sách các cấp độ đã xuất hiện (
computeIfAbsent+Set). - Top 5 module có nhiều lỗi nhất, sắp giảm dần.
- Số lỗi theo từng giờ, in theo thứ tự thời gian (
TreeMap). - Module nào có lỗi trong mọi giờ của ngày?
- Với mỗi cặp (module, cấp độ), thông điệp gần nhất (
Map<Khoa, String>vớirecord Khoa).
Yêu cầu: chỉ dùng Collections, chưa dùng Stream (sẽ làm lại bằng Stream ở chương 10 để so sánh số dòng code).
Gợi ý
(6) minh hoạ vì sao record là khoá lý tưởng: record Khoa(String module,
String level) { } — sinh sẵn equals/hashCode đúng chuẩn và bất
biến. Nếu tự viết class, bạn phải nhớ override cả hai và để trường final.
(4) dùng TreeMap<Integer, Integer> với khoá là giờ — nó tự sắp xếp nên in ra
đã đúng thứ tự, không cần sort.
Cache LRU có thống kê
Xây dựng Cache<K,V> dựa trên LinkedHashMap với:
- Sức chứa tối đa, tự loại phần tử ít dùng nhất.
- Thống kê: số lần trúng (hit), số lần trượt (miss), tỉ lệ trúng, số lần bị loại.
- Phương thức
V get(K k, Function<K,V> tinhToan)— trượt thì tính rồi lưu. - Hết hạn theo thời gian: mỗi entry lưu thời điểm tạo, quá
ttlMillisthì coi như trượt. - Phương thức
String baoCao()in bảng thống kê đẹp.
Test: mô phỏng 10.000 lượt truy cập theo phân phối lệch (20% khoá chiếm 80% lượt truy cập) và đo tỉ lệ trúng với các sức chứa khác nhau. Vẽ bảng sức chứa → tỉ lệ trúng.
Gợi ý
Để lưu thời điểm tạo, giá trị trong map nên là record Entry<V>(V giaTri, long
taoLuc) thay vì V trần.
Phân phối lệch: sinh chỉ số bằng (int)(Math.pow(random.nextDouble(), 2) * soKhoa)
— bình phương làm giá trị dồn về đầu dải.
Bạn sẽ thấy tỉ lệ trúng tăng nhanh khi sức chứa còn nhỏ rồi bão hoà — đúng như lý thuyết cache.
So sánh hiệu năng bốn loại Map
Benchmark HashMap, LinkedHashMap, TreeMap, Hashtable với n = 100.000 / 1.000.000:
putn cặp.getn lần (khoá tồn tại).getn lần (khoá không tồn tại).- Duyệt toàn bộ bằng
entrySet(). - Ước lượng bộ nhớ chiếm dụng.
Sau đó lặp lại phần put với hai cách khởi tạo: mặc định và có đặt dung lượng ban đầu. Chênh lệch bao nhiêu? Giải thích bằng số lần resize.
Gợi ý
Đo bộ nhớ thô: Runtime.getRuntime() với totalMemory() - freeMemory(),
gọi System.gc() trước mỗi lần đo (không đảm bảo nhưng đủ để so sánh tương đối).
Bạn sẽ thấy: TreeMap chậm hơn khoảng 2-3 lần nhưng cho thứ tự;
LinkedHashMap chậm hơn HashMap khoảng 10-20% khi ghi nhưng
nhanh hơn khi duyệt (đi theo liên kết thay vì quét cả bảng bucket);
Hashtable chậm hơn HashMap ngay cả khi chỉ có một luồng.
Quiz
HashMap hoạt động bên trong thế nào?
Xem đáp án
Nó là một bảng băm: mảng Node[] table gọi là bucket. Khi put(k, v):
- Tính
h = k.hashCode(), rồi trộn bit:h ^ (h >>> 16). - Tìm chỉ số bucket:
i = h & (table.length - 1)— tương đương chia lấy dư nhưng nhanh hơn, và đây là lý do dung lượng luôn là luỹ thừa của 2. - Duyệt chuỗi node trong bucket đó: nếu tìm thấy khoá
equalsthì ghi đè giá trị; nếu không thì thêm node mới. - Nếu
soLuong > capacity × 0.75thì nới rộng gấp đôi và phân bổ lại toàn bộ.
Va chạm (hai khoá cùng bucket) được xử lý bằng danh sách liên kết. Từ Java 8, nếu chuỗi dài hơn 8 và bảng có ít nhất 64 bucket, nó chuyển thành cây đỏ đen — đưa trường hợp xấu nhất từ O(n) xuống O(log n), đồng thời chặn lỗ hổng tấn công va chạm băm.
Điểm mấu chốt: hashCode() chọn bucket, equals() tìm đúng
entry trong bucket.
Override equals mà quên hashCode thì chuyện gì xảy ra?
Xem đáp án
HashMap và HashSet hoạt động sai hoàn toàn — nhưng
im lặng, không có lỗi nào được ném ra:
map.put(new SanPham("SP01"), 100);
map.get(new SanPham("SP01")); // null ⚠️
map.containsKey(new SanPham("SP01")); // false ⚠️
set.add(a); set.add(b); // size = 2 dù a.equals(b) ⚠️
Nguyên nhân: không override hashCode thì Java dùng bản mặc định của
Object — dựa trên địa chỉ bộ nhớ. Hai object có nội dung giống hệt nhưng địa chỉ khác
nhau cho hai mã băm khác nhau, rơi vào hai bucket khác nhau, nên equals()
không bao giờ được gọi.
Sửa: Objects.hash(cacTruong) với đúng những trường dùng trong
equals. Hoặc đơn giản nhất — dùng record, nó sinh sẵn cả hai đúng chuẩn.
Vì sao khoá của Map phải bất biến?
Xem đáp án
Vì vị trí lưu trữ của entry được quyết định bởi hashCode() tại thời điểm
put. Nếu sau đó bạn đổi một trường tham gia tính hash, mã băm thay đổi nhưng
entry vẫn nằm ở bucket cũ — và HashMap không hề biết.
Khoa k = new Khoa("A");
m.put(k, "giá trị"); // lưu ở bucket của hash("A")
k.ten = "B"; // đổi trường tham gia hash
m.get(k); // null — tìm ở bucket hash("B"), không có gì
m.size(); // 1 — nó VẪN trong map
m.remove(k); // null — xoá cũng không được
Entry bị kẹt vĩnh viễn: không tìm được, không xoá được, không giải phóng bộ nhớ
được (rò rỉ). Duyệt entrySet() thì vẫn thấy nó — càng khó hiểu.
Quy tắc: khoá phải bất biến, hoặc ít nhất mọi trường tham gia hashCode
phải final. Đây là lý do String, Integer,
record, enum là khoá lý tưởng — và cũng là một lý do quan trọng khiến
String được thiết kế bất biến.
Vì sao dung lượng HashMap luôn là luỹ thừa của 2?
Xem đáp án
Để tính chỉ số bucket bằng phép AND bit thay vì phép chia lấy dư. Khi
n là luỹ thừa của 2, h & (n-1) cho kết quả giống hệt
h % n (với h không âm) nhưng nhanh hơn nhiều lần trên CPU.
n = 16 → n-1 = 15 = 0b1111
h = 73 = 0b1001001
h & 15 = 0b0001001 = 9 // = 73 % 16 ✅ nhưng nhanh hơn
Điều này cũng giải thích vì sao cần bước trộn h ^ (h >>> 16): phép AND chỉ dùng
vài bit thấp nhất của mã băm. Nếu hai khoá chỉ khác nhau ở bit cao, chúng sẽ rơi vào cùng
bucket. Trộn bit cao xuống thấp làm mọi bit đều ảnh hưởng tới chỉ số, giảm va chạm đáng kể.
Hệ quả nữa: khi resize (nhân đôi), mỗi node hoặc ở nguyên chỉ số cũ, hoặc chuyển sang
cũ + dungLuongCu — xác định chỉ bằng một phép AND, không cần tính lại toàn bộ.
HashMap vs LinkedHashMap vs TreeMap — chọn cái nào?
Xem đáp án
-
HashMap— mặc định. Nhanh nhất (O(1)), tốn ít bộ nhớ nhất. Không đảm bảo thứ tự nào, và thứ tự có thể đổi giữa các lần chạy hoặc phiên bản Java. -
LinkedHashMap— vẫn O(1), thêm danh sách liên kết đôi để giữ thứ tự thêm vào. Chọn khi cần kết quả ổn định để hiển thị hoặc để test khỏi bị phập phù. Chế độaccessOrder=truebiến nó thành nền tảng cho cache LRU chỉ với một method override. -
TreeMap— O(log n), luôn sắp theo khoá. Chọn khi cần thứ tự hoặc — quan trọng hơn — các truy vấn mà hai loại kia không làm được:floorKey,ceilingKey,subMap,firstEntry. Không cho khoánull.
Trường hợp đặc biệt: khoá là enum → dùng EnumMap. Nó chỉ
là một mảng đánh chỉ số theo ordinal(), nên nhanh hơn và tốn ít bộ nhớ hơn cả ba.
Vì sao không nên dùng Hashtable? Thay bằng gì?
Xem đáp án
Hashtable có từ Java 1.0 và mắc bốn vấn đề:
- Đồng bộ hoá thô — mọi phương thức
synchronizedtrên toàn bộ object. Hai luồng đọc hai khoá hoàn toàn khác nhau vẫn phải xếp hàng chờ nhau. - Chậm khi một luồng — bạn trả giá cho đồng bộ hoá mà không dùng tới.
- Không cho
null— cả khoá lẫn giá trị đều ném NPE. - API cũ —
elements(),keys()trảEnumerationthay vìIterator.
Thay thế:
- Một luồng →
HashMap. - Đa luồng →
ConcurrentHashMap. Nó khoá theo từng đoạn (và từ Java 8 là theo từng bucket với CAS), nên nhiều luồng ghi vào các khoá khác nhau chạy song song được — nhanh hơnHashtablehàng chục lần khi tải cao.
Đừng dùng Collections.synchronizedMap(new HashMap<>()) nữa — nó cũng khoá toàn
bộ như Hashtable.
merge, computeIfAbsent, putIfAbsent khác nhau ra sao?
Xem đáp án
// putIfAbsent — giá trị TÍNH SẴN, chỉ đặt nếu khoá chưa có
m.putIfAbsent(k, new ArrayList<>());
// ⚠️ ArrayList LUÔN được tạo, kể cả khi khoá đã tồn tại → lãng phí
// computeIfAbsent — hàm CHỈ chạy khi khoá chưa có, và TRẢ VỀ giá trị
m.computeIfAbsent(k, x -> new ArrayList<>()).add(v);
// ✅ không tạo thừa, và nối chuỗi .add() được ngay
// merge — có giá trị mặc định + hàm GỘP khi khoá đã có
m.merge(k, 1, Integer::sum);
// chưa có → đặt 1; đã có → đặt sum(cũ, 1)
Quy tắc chọn:
- Đếm / cộng dồn →
merge. - Nhóm vào collection hoặc cache lười (giá trị tốn công tạo) →
computeIfAbsent. - Giá trị đơn giản đã có sẵn →
putIfAbsent.
Lưu ý chung: hàm truyền vào ba phương thức này không được sửa chính map đó — Java 9+
phát hiện và ném ConcurrentModificationException.
Cách duyệt Map nào hiệu quả nhất? Vì sao?
Xem đáp án
entrySet() — một lần duyệt lấy được cả khoá lẫn giá trị.
// ✅ TỐT NHẤT
for (var e : m.entrySet()) {
System.out.println(e.getKey() + " = " + e.getValue());
}
// ❌ TỆ — mỗi vòng lặp thêm MỘT lần tra cứu băm thừa
for (String k : m.keySet()) {
System.out.println(k + " = " + m.get(k)); // chậm gấp đôi
}
// ✅ Cũng tốt, ngắn gọn hơn (Java 8+)
m.forEach((k, v) -> System.out.println(k + " = " + v));
Cách keySet() + get() phải tính lại mã băm và duyệt lại bucket cho
mỗi khoá — làm gấp đôi công việc một cách vô ích. Chỉ dùng keySet() khi bạn
thực sự chỉ cần khoá, và values() khi chỉ cần giá trị.
Lưu ý: cả ba đều là khung nhìn chứ không phải bản sao — xoá trong keySet() là
xoá luôn khỏi map. Muốn xoá theo điều kiện khi đang duyệt:
m.entrySet().removeIf(e -> …).
Tổng kết
Sau chương 8, bạn đã nắm:
- Bên trong
HashMap: bảng bucket,h & (n-1), trộn bit, va chạm, hệ số tải 0.75, resize, treeify ở ngưỡng 8/64. - Hợp đồng
equals/hashCodevà hậu quả im lặng khi vi phạm. - Khoá phải bất biến — nếu không, entry bị kẹt vĩnh viễn.
- Bốn cài đặt:
HashMapmặc định,LinkedHashMapgiữ thứ tự,TreeMapsắp xếp,Hashtableđã lỗi thời. - Duyệt qua
entrySet()— không dùngkeySet()+get(). - API Java 8+:
getOrDefault,merge,computeIfAbsent,putIfAbsent,forEach. - Cache LRU bằng
LinkedHashMapchế độ thứ tự truy cập. - Truy vấn thứ tự của
TreeMap:floorEntry,subMap, tra bảng theo khoảng. EnumMapkhi khoá là enum — chỉ là mảng, nhanh nhất.- Câu hỏi phỏng vấn kinh điển và câu trả lời gọn.
Kết nối
- Chương 4-5 (OOP) —
equals/hashCode/recordlà điều kiện tiên quyết để dùngMapđúng. - Chương 7 (Collections) —
HashSetthực chất làHashMapvới giá trị giả; mọi kiến thức chương này áp dụng nguyên vẹn. - Chương 9 (Generics) —
Map<K,V>, wildcard, và vì sao mảng generic phải dùng@SuppressWarnings. - Chương 10 (Stream) —
Collectors.groupingBy,toMap,countingthay thế phần lớn mẫu trong mục 9. - Chương 12 (Đa luồng) —
ConcurrentHashMapvà cơ chế khoá theo bucket. - Chương 13 (JDBC) — ánh xạ
ResultSetthànhMaphoặc danh sách entity. - Trụ cột DSA — bảng băm, cây đỏ đen, phân tích khấu hao; đây là ví dụ cài đặt thực tế của cả ba.
- Python Chương 3 — đối chiếu:
dictgiữ thứ tự thêm vào theo đặc tả từ Python 3.7 (khácHashMap), và dùng bảng băm nhỏ gọn hai tầng.