Chương 08 · Map Framework

Map và hợp đồng equals/hashCode

HashMap là cấu trúc dữ liệu được dùng nhiều nhất trong lập trình Java, và cũng là câu hỏi phỏng vấn phổ biến nhất. Chương này không dừng ở "cách dùng" — chúng ta sẽ mở nắp ra xem bên trong nó hoạt động thế nào: bảng bucket, hàm băm, xử lý va chạm, và cơ chế chuyển sang cây từ Java 8. Hiểu phần đó là hiểu vì sao equals và hashCode phải đi cùng nhau — và vì sao vi phạm hợp đồng đó khiến dữ liệu "biến mất" một cách bí ẩn.

Độ dài: ~1600 dòng Bài tập: 5 Quiz: 8 Prerequisites: Chương 1-7
🎯 Mục tiêu chương
  • 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/hashCode và hậu quả cụ thể khi vi phạm.
  • Chọn đúng giữa HashMap, LinkedHashMap, TreeMap; biết vì sao tránh Hashtable.
  • Duyệt Map đúng cách qua entrySet().
  • 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<K,V> │ ├── HashMap ⭐ bảng băm — mặc định, nhanh nhất │ └── LinkedHashMap + liên kết đôi → giữ thứ tự │ ├── SortedMap → NavigableMap │ └── TreeMap cây đỏ đen — luôn sắp theo khoá │ ├── Hashtable ⚠️ Java 1.0, đồng bộ — đừng dùng │ └── Properties đọc/ghi tệp .properties │ ├── EnumMap khoá là enum — cực nhanh, dùng mảng ├── IdentityHashMap so khoá bằng == thay vì equals ├── WeakHashMap khoá bị GC thu hồi được — dùng làm cache └── ConcurrentHashMap an toàn luồng (Chương 12)
MapCoBan.java
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.

1. Khoá
"An"
2. hashCode()
65759
3. Trộn bit
h ^ (h>>>16)
4. Chỉ số
h & (n−1)
→ bucket 15
5. So khoá
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.
Cấu trúc bên trong
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

ThuatToan.java (giản lược từ mã nguồn JDK)
// ---------- 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ì n luô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 == h trước — phép so hai số nguyên rẻ hơn nhiều so với gọi equals(). Chỉ khi mã băm khớp mới tốn công gọi equals.

2.2. Va chạm và cơ chế treeify

Va chạm
// 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 HashMapGiá trịÝ nghĩa
DEFAULT_INITIAL_CAPACITY16Số bucket ban đầu
DEFAULT_LOAD_FACTOR0.75Đầy 75% thì nới rộng
TREEIFY_THRESHOLD8Chuỗi dài hơn 8 → chuyển sang cây
UNTREEIFY_THRESHOLD6Cây nhỏ hơn 6 → quay lại liên kết
MIN_TREEIFY_CAPACITY64Bảng nhỏ hơn 64 thì nới rộng thay vì treeify
Resize.java — nới rộng bảng
// 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

Hợp đồng: nếu 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 đó.
ViPham.java — hậu quả cụ thể
// ❌ 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) { }
⚠️ Khoá phải BẤT BIẾN — nếu không, dữ liệu "biến mất"
KhoaThayDoi.java
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íHashMapLinkedHashMapTreeMapHashtable
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
⚠️ Vì sao Hashtable đã lỗi thời
  • Đồng bộ hoá thô — mọi phương thức đều synchronized trê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ề Enumeration thay 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

DuyetMap.java
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+

ApiHienDai.java
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ầuDùngVí dụ
Đếm / cộng dồn sốmergem.merge(k, 1, Integer::sum)
Gộp chuỗimergem.merge(k, s, (a,b) -> a + ", " + b)
Nhóm vào collectioncomputeIfAbsentm.computeIfAbsent(k, x -> new ArrayList<>()).add(v)
Bộ nhớ đệm lườicomputeIfAbsentcache.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

LinkedHashMap.java
// 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.java
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ế

UngDung.java
// ---------- ① Đế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.
🕳️ Không gọi đệ quy trong computeIfAbsent
DeQuyBay.java
// ❌ 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ỏiTrả 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ốngCách viết
Khai báo chuẩnMap<K,V> m = new HashMap<>();
Đọc an toàn (tránh NPE)m.getOrDefault(k, macDinh)
Đếm tần suấtm.merge(k, 1, Integer::sum)
Nhóm vào listm.computeIfAbsent(k, x -> new ArrayList<>()).add(v)
Cache lườicache.computeIfAbsent(k, this::tinhToan)
Chỉ thêm nếu chưa cóm.putIfAbsent(k, v)
Duyệtfor (var e : m.entrySet()) hoặc m.forEach((k,v) -> …)
Xoá theo điều kiệnm.entrySet().removeIf(e -> …)
Map bất biếnMap.of(k1,v1, k2,v2) · nhiều hơn 10 cặp: Map.ofEntries(…)
Cần thứ tự thêm vàoLinkedHashMap
Cần sắp theo khoáTreeMap
Tra bảng theo khoảngtreeMap.floorEntry(x)
Cache LRULinkedHashMap(n, .75f, true) + override removeEldestEntry
Khoá là enumEnumMap — nhanh hơn và gọn hơn HashMap
Đa luồngConcurrentHashMap — 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ượngnew 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:

  1. Không override gì cả → đưa hai Diem(1,2) vào HashMap làm khoá. size() bằng bao nhiêu? get() trả gì?
  2. Chỉ override equals → lặp lại.
  3. Override cả hai → lặp lại.
  4. Override hashCode() trả về hằng số (ví dụ luôn trả 1) → Map cò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:

API
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 hashCode tốt và hashCode tệ (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:

  1. Số dòng theo từng cấp độ (merge).
  2. Với mỗi module, danh sách các cấp độ đã xuất hiện (computeIfAbsent + Set).
  3. Top 5 module có nhiều lỗi nhất, sắp giảm dần.
  4. Số lỗi theo từng giờ, in theo thứ tự thời gian (TreeMap).
  5. Module nào có lỗi trong mọi giờ của ngày?
  6. Với mỗi cặp (module, cấp độ), thông điệp gần nhất (Map<Khoa, String> với record 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á ttlMillis thì 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:

  1. put n cặp.
  2. get n lần (khoá tồn tại).
  3. get n lần (khoá không tồn tại).
  4. Duyệt toàn bộ bằng entrySet().
  5. Ướ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

Q1

HashMap hoạt động bên trong thế nào?

Xem đáp án
✓ Đáp án

Nó là một bảng băm: mảng Node[] table gọi là bucket. Khi put(k, v):

  1. Tính h = k.hashCode(), rồi trộn bit: h ^ (h >>> 16).
  2. 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.
  3. Duyệt chuỗi node trong bucket đó: nếu tìm thấy khoá equals thì ghi đè giá trị; nếu không thì thêm node mới.
  4. Nếu soLuong > capacity × 0.75 thì 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.

Q2

Override equals mà quên hashCode thì chuyện gì xảy ra?

Xem đáp án
✓ Đá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:

Hau qua
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.

Q3

Vì sao khoá của Map phải bất biến?

Xem đáp án
✓ Đá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.

Ket.java
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.

Q4

Vì sao dung lượng HashMap luôn là luỹ thừa của 2?

Xem đáp án
✓ Đá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.

ViDu.java
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ộ.

Q5

HashMap vs LinkedHashMap vs TreeMap — chọn cái nào?

Xem đáp án
✓ Đá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=true biế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.

Q6

Vì sao không nên dùng Hashtable? Thay bằng gì?

Xem đáp án
✓ Đá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 synchronized trê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ả Enumeration thay 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ơn Hashtable hà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.

Q7

merge, computeIfAbsent, putIfAbsent khác nhau ra sao?

Xem đáp án
✓ Đáp án
SoSanh.java
// 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.

Q8

Cách duyệt Map nào hiệu quả nhất? Vì sao?

Xem đáp án
✓ Đáp án

entrySet() — một lần duyệt lấy được cả khoá lẫn giá trị.

Duyet.java
// ✅ 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/hashCode và 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: HashMap mặc định, LinkedHashMap giữ thứ tự, TreeMap sắp xếp, Hashtable đã lỗi thời.
  • Duyệt qua entrySet() — không dùng keySet() + get().
  • API Java 8+: getOrDefault, merge, computeIfAbsent, putIfAbsent, forEach.
  • Cache LRU bằng LinkedHashMap chế độ thứ tự truy cập.
  • Truy vấn thứ tự của TreeMap: floorEntry, subMap, tra bảng theo khoảng.
  • EnumMap khi 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/record là điều kiện tiên quyết để dùng Map đúng.
  • Chương 7 (Collections) — HashSet thực chất là HashMap vớ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, counting thay thế phần lớn mẫu trong mục 9.
  • Chương 12 (Đa luồng) — ConcurrentHashMap và cơ chế khoá theo bucket.
  • Chương 13 (JDBC) — ánh xạ ResultSet thành Map hoặ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: dict giữ thứ tự thêm vào theo đặc tả từ Python 3.7 (khác HashMap), và dùng bảng băm nhỏ gọn hai tầng.