Nội dung bài viết
  1. Tổng quan về Java Collections Framework
  2. List: ArrayList và LinkedList
  3. Set: HashSet, LinkedHashSet và TreeSet
  4. Map: HashMap, LinkedHashMap và TreeMap
  5. Queue, Deque và các cấu trúc liên quan
  6. Ví dụ thực chiến 1: ArrayList và Iterator
  7. Ví dụ thực chiến 2: HashSet và HashMap
  8. Ví dụ thực chiến 3: Comparable và Comparator
  9. ConcurrentModificationException và an toàn khi duyệt
  10. Độ phức tạp thuật toán và lựa chọn Collection
  11. Best Practices trong dự án thực tế
  12. Bài tập thực hành và tổng kết

Java Collections Framework (JCF) là một trong những nền tảng quan trọng nhất của lập trình Java. Hầu hết ứng dụng Java thực tế đều phải quản lý dữ liệu dưới dạng danh sách người dùng, tập hợp quyền truy cập, bảng ánh xạ mã sản phẩm, hàng đợi xử lý công việc hoặc dữ liệu cần sắp xếp. Nếu chỉ biết gọi add(), remove() hay get(), bạn mới chỉ sử dụng bề mặt của JCF. Để viết phần mềm hiệu quả, bạn cần hiểu cấu trúc dữ liệu nằm bên dưới mỗi implementation, cách Java tìm kiếm phần tử, quản lý bộ nhớ, xử lý va chạm và lựa chọn thuật toán phù hợp.

Bài giảng này đi từ kiến trúc interface đến cơ chế triển khai bên trong, sau đó chuyển sang các chương trình Java có thể biên dịch và chạy trực tiếp. Các ví dụ sử dụng cú pháp Java hiện đại, phù hợp với Java 8 trở lên, đồng thời chỉ rõ những điểm khác biệt đáng chú ý trong OpenJDK.

Mục tiêu sau bài học:
1. Phân biệt được interface và implementation trong JCF.
2. Hiểu ArrayList, LinkedList, HashSet, TreeSet, HashMap và TreeMap hoạt động ra sao.
3. Biết phân tích độ phức tạp thời gian và chi phí bộ nhớ của các cấu trúc dữ liệu.
4. Tự viết, biên dịch và kiểm chứng các chương trình Java thực tế.
5. Tránh những lỗi phổ biến liên quan đến equals(), hashCode(), thứ tự duyệt và sửa đổi Collection.

1. Tổng quan về Java Collections Framework

Java Collections Framework là một hệ thống gồm các interface, lớp triển khai và thuật toán dùng để lưu trữ, truy xuất, tìm kiếm, sắp xếp và thao tác với nhóm đối tượng. JCF được cung cấp chủ yếu trong gói java.util; những cấu trúc phục vụ xử lý đồng thời thường nằm trong java.util.concurrent.

Hãy tưởng tượng một ứng dụng thương mại điện tử. Bạn cần danh sách sản phẩm trong giỏ hàng, tập hợp mã khuyến mãi không trùng lặp và bảng tra cứu sản phẩm theo mã. Ba bài toán này có bản chất khác nhau, nên Java cung cấp những cấu trúc dữ liệu khác nhau để giải quyết chúng.

1.1. Collection là gì? Tại sao cần JCF?

Trước khi có Collection, lập trình viên có thể sử dụng mảng để lưu trữ nhiều phần tử:

Java - Quản lý dữ liệu bằng mảng
public class Main {
    public static void main(String[] args) {
        String[] names = new String[3];

        names[0] = "An";
        names[1] = "Binh";
        names[2] = "Chi";

        for (String name : names) {
            System.out.println(name);
        }
    }
}

Mảng có ưu điểm là truy cập theo chỉ số nhanh, bộ nhớ tương đối gọn và kiểu dữ liệu rõ ràng. Tuy nhiên, kích thước mảng cố định sau khi tạo. Nếu mảng đã đầy, bạn phải tự tạo mảng mới và sao chép dữ liệu. Mảng cũng không cung cấp sẵn các thao tác như khử trùng lặp, sắp xếp theo comparator hoặc tìm kiếm theo khóa.

JCF cung cấp các lớp đã đóng gói những thao tác này. Ví dụ, ArrayList tự quản lý mảng bên trong, HashSet hỗ trợ kiểm tra phần tử trùng lặp theo quy tắc băm và HashMap cho phép ánh xạ khóa sang giá trị.

Tiêu chí Mảng (Array) Java Collection
Kích thước Cố định sau khi tạo Nhiều implementation tự tăng hoặc giảm số phần tử logic
Truy cập theo chỉ số O(1) Phụ thuộc implementation; ArrayList là O(1)
Khử trùng lặp Phải tự xử lý Set cung cấp ngữ nghĩa không trùng lặp
Tìm kiếm theo khóa Phải tự xây dựng Map cung cấp các thao tác tra cứu theo khóa
Sắp xếp Dùng Arrays.sort() Có thể dùng Collections.sort(), TreeSet, TreeMap...
Kiểu dữ liệu Có thể là kiểu nguyên thủy hoặc tham chiếu Lưu đối tượng; kiểu nguyên thủy được boxing thành wrapper

Lưu ý rằng Collection không phải lúc nào cũng tốt hơn mảng. Khi biết chính xác kích thước, cần lưu dữ liệu kiểu nguyên thủy với chi phí bộ nhớ thấp hoặc cần biểu diễn dữ liệu dạng ma trận, mảng vẫn là lựa chọn phù hợp. JCF hữu ích khi cần một API quản lý dữ liệu linh hoạt và giàu chức năng.

1.2. Sơ đồ phân cấp các interface

Điểm quan trọng đầu tiên là phân biệt interface mô tả hành vi với class triển khai cấu trúc dữ liệu. Interface cho biết đối tượng có thể làm gì; implementation quyết định dữ liệu được lưu trữ và xử lý như thế nào.

Sơ đồ kiến trúc Java Collections Framework
Iterable<E>
   |
   +-- Collection<E>
         |
         +-- List<E>        : Có thứ tự, cho phép phần tử trùng lặp
         |     |
         |     +-- ArrayList<E>
         |     +-- LinkedList<E>
         |     +-- Vector<E> (legacy)
         |
         +-- Set<E>         : Không chứa các phần tử trùng theo ngữ nghĩa Set
         |     |
         |     +-- HashSet<E>
         |     +-- LinkedHashSet<E>
         |     +-- SortedSet<E>
         |            |
         |            +-- NavigableSet<E>
         |                   |
         |                   +-- TreeSet<E>
         |
         +-- Queue<E>       : Hàng đợi xử lý phần tử
               |
               +-- Deque<E> : Thêm/xóa ở cả hai đầu
                     |
                     +-- ArrayDeque<E>
                     +-- LinkedList<E>

Map<K, V>  [Nhánh độc lập, không kế thừa Collection]
   |
   +-- HashMap<K, V>
   +-- LinkedHashMap<K, V>
   +-- SortedMap<K, V>
          |
          +-- NavigableMap<K, V>
                 |
                 +-- TreeMap<K, V>

Các lớp hỗ trợ xử lý đồng thời:
   +-- ConcurrentHashMap<K, V>
   +-- ConcurrentSkipListMap<K, V>
   +-- CopyOnWriteArrayList<E>
   +-- ConcurrentLinkedQueue<E>

Iterable cho phép một đối tượng cung cấp Iterator, nhờ đó có thể sử dụng vòng lặp nâng cao for-each. Collection mở rộng khả năng đó bằng các thao tác quản lý một nhóm phần tử như thêm, xóa và kiểm tra sự tồn tại.

List, Set và Queue đều thuộc nhánh Collection. Ngược lại, Map không kế thừa Collection vì nó quản lý các cặp khóa-giá trị, chứ không phải một tập phần tử theo cùng mô hình.

Lưu ý quan trọng: Không nên nói rằng mọi cấu trúc trong JCF đều là Collection. Map thuộc Java Collections Framework nhưng không triển khai interface Collection. Ngoài ra, Collections là lớp tiện ích, còn Collection là interface.

1.2.1. Phân biệt Collection, Collections và Map

Tên Loại Vai trò
Collection<E> Interface Mô tả các thao tác chung với nhóm phần tử
Collections Utility class Chứa thuật toán và wrapper như sort(), unmodifiableList(), synchronizedList()
Map<K,V> Interface độc lập Ánh xạ khóa sang giá trị
ArrayList<E> Class Triển khai List dựa trên mảng động

1.3. Generics, Iterator và các quy tắc chung

Generics cho phép xác định kiểu dữ liệu mà Collection quản lý. Ví dụ, List<String> biểu diễn danh sách chuỗi, còn Map<Long, User> biểu diễn ánh xạ từ mã số sang đối tượng User.

Java - Generics và kiểu dữ liệu
import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();

        names.add("An");
        names.add("Binh");

        String first = names.get(0);

        System.out.println(first);
        System.out.println(names.size());

        // names.add(100); // Lỗi biên dịch: sai kiểu dữ liệu
    }
}

Generics giúp trình biên dịch phát hiện nhiều lỗi kiểu dữ liệu trước khi chạy. Tuy nhiên, do type erasure, thông tin kiểu generic thông thường không được giữ nguyên đầy đủ ở runtime như một kiểu tham số hóa riêng biệt.

Một số quy tắc chung cần nhớ:

2. List: ArrayList và LinkedList

List là interface dành cho dữ liệu có thứ tự. Một List có thể chứa nhiều phần tử bằng nhau và thường cho phép truy cập theo chỉ số từ 0 đến size() - 1.

Hai implementation phổ biến nhất là ArrayList và LinkedList. Chúng cùng triển khai List nhưng sử dụng hai cấu trúc dữ liệu hoàn toàn khác nhau.

2.1. ArrayList dưới mui xe: Dynamic Array

ArrayList quản lý một mảng tham chiếu bên trong. Trong OpenJDK, mảng này được gọi là elementData. Có thể hình dung cấu trúc của nó như sau:

Minh họa cấu trúc mảng động
ArrayList: size = 4, capacity = 6

Index:        0        1        2        3        4        5
          +--------+--------+--------+--------+--------+--------+
elementData | "A"  | "B"    | "C"    | "D"    | null   | null   |
          +--------+--------+--------+--------+--------+--------+
              <----------- Phần tử đang sử dụng ---------->
              <---------------- Capacity ----------------->

size = số phần tử logic đang chứa
capacity = độ dài mảng bên trong hiện tại

Điểm mấu chốt là size và capacity không giống nhau. Size cho biết có bao nhiêu phần tử đang được lưu; capacity cho biết mảng nội bộ chứa được bao nhiêu tham chiếu trước khi cần mở rộng.

2.1.1. Khi thêm phần tử, ArrayList làm gì?

Khi gọi add(element), ArrayList kiểm tra xem mảng bên trong còn chỗ hay không. Nếu còn chỗ, phần tử được ghi vào vị trí tiếp theo và size tăng lên. Nếu mảng đã đầy, ArrayList phải cấp phát một mảng mới lớn hơn rồi sao chép các tham chiếu cũ sang mảng mới.

Quy trình mở rộng mảng
Bước 1: Mảng hiện tại đã đầy
[A] [B] [C] [D]
 size = capacity = 4

Bước 2: Yêu cầu thêm "E"
Cần thêm sức chứa

Bước 3: Tạo mảng mới lớn hơn
[A] [B] [C] [D] [ ] [ ]

Bước 4: Sao chép tham chiếu và ghi phần tử mới
[A] [B] [C] [D] [E] [ ]
 size = 5, capacity = 6

Trong các phiên bản OpenJDK hiện đại, chiến lược tăng capacity của ArrayList thường mở rộng lên khoảng 1,5 lần capacity cũ. Đây là chi tiết triển khai, không phải một bảo đảm của interface List. Nếu capacity cũ bằng 4, capacity mới có thể là 6; nếu bằng 10, có thể là 15.

Độ phức tạp của thao tác thêm phần tử vào cuối:

Vì sao vẫn gọi là O(1) trung bình? Mỗi lần mở rộng có thể tốn nhiều thời gian, nhưng capacity tăng theo cấp số nhân. Khi cộng tổng chi phí sao chép qua nhiều lần thêm, tổng số tham chiếu được sao chép vẫn tỷ lệ tuyến tính với số phần tử được thêm. Do đó, chi phí trung bình trên mỗi lần thêm là hằng số.

2.1.2. Chi phí của các thao tác phổ biến

Thao tác Độ phức tạp điển hình Giải thích
get(index) O(1) Tính trực tiếp vị trí trong mảng
set(index, value) O(1) Thay tham chiếu tại một vị trí
add(value) cuối danh sách Amortized O(1) Thỉnh thoảng phải mở rộng mảng
add(index, value) O(n) Có thể phải dịch chuyển các phần tử phía sau
remove(index) O(n) Có thể phải dịch chuyển phần đuôi sang trái
contains(value) O(n) Tìm kiếm tuyến tính theo equals()

Ví dụ, khi thêm phần tử vào đầu danh sách, ArrayList phải dịch chuyển các tham chiếu hiện có sang phải để tạo chỗ trống. Khi xóa phần tử ở giữa, các tham chiếu phía sau phải dịch chuyển sang trái.

Để hạn chế việc mở rộng nhiều lần khi đã biết trước quy mô dữ liệu, có thể khởi tạo với capacity phù hợp:

Java - Chủ động cấp capacity
import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        int expectedSize = 100_000;

        List<Integer> numbers =
                new ArrayList<>(expectedSize);

        for (int i = 0; i < expectedSize; i++) {
            numbers.add(i);
        }

        System.out.println(numbers.size());
    }
}

Khởi tạo new ArrayList<>(expectedSize) chỉ tạo sức chứa ban đầu; nó không tạo sẵn expectedSize phần tử. Vì vậy, gọi get(0) ngay sau khi tạo danh sách rỗng vẫn gây IndexOutOfBoundsException.

2.2. LinkedList: Cấu trúc Node kép

LinkedList triển khai danh sách liên kết kép (doubly linked list). Mỗi Node lưu tham chiếu đến phần tử và hai tham chiếu liên kết với Node trước, Node sau.

Cấu trúc Doubly Linked List
first                                      last
  |                                          |
  v                                          v
+------+------+------+    +------+------+------+    +------+------+------+
| prev | data | next | <-> | prev | data | next | <-> | prev | data | next |
+------+------+------+    +------+------+------+    +------+------+------+
       Node A                    Node B                    Node C

Node A.prev = null
Node A.next = Node B
Node B.prev = Node A
Node B.next = Node C
Node C.next = null

Trong OpenJDK, mỗi Node của LinkedList chứa một tham chiếu phần tử và hai tham chiếu liên kết. Ngoài các Node, LinkedList còn quản lý các thông tin như first, last và size. Điều này tạo ra chi phí bộ nhớ lớn hơn so với một mảng chỉ lưu các tham chiếu phần tử.

2.2.1. Thêm và xóa Node

Khi thêm phần tử vào đầu hoặc cuối, LinkedList có thể tạo một Node mới và điều chỉnh một số liên kết. Với Node cần xóa đã được xác định, việc tháo Node khỏi danh sách cũng chỉ cần cập nhật các liên kết lân cận.

Tuy nhiên, nếu chỉ có một giá trị và cần tìm vị trí chứa giá trị đó, LinkedList phải duyệt danh sách. Vì vậy, cần phân biệt chi phí thay đổi liên kết với chi phí tìm đến vị trí cần thay đổi.

Đừng nhầm rằng LinkedList luôn xóa phần tử ở giữa nhanh hơn ArrayList. Nếu bạn gọi remove(50000) trên danh sách có 100.000 phần tử, LinkedList vẫn phải tìm Node tương ứng trước khi xóa.

2.3. So sánh và lựa chọn List

Tiêu chí ArrayList LinkedList
Cấu trúc Mảng tham chiếu động Danh sách liên kết kép
get(index) O(1) O(n)
Thêm cuối Amortized O(1) O(1)
Thêm đầu O(n) O(1)
Xóa phần tử ở giữa O(n) do dịch chuyển O(n) nếu phải tìm Node theo index
Bộ nhớ Thường gọn hơn Tốn thêm bộ nhớ cho liên kết Node
Truy cập tuần tự Thường tận dụng cache CPU tốt Các Node có thể nằm rải rác trong heap
Trường hợp phù hợp Danh sách thông thường, truy cập theo index Thêm/xóa ở hai đầu, hoặc thao tác qua Iterator tại vị trí đã biết

Trong nhiều ứng dụng, ArrayList là lựa chọn mặc định hợp lý cho List. Mảng liên tục thường thân thiện với cache CPU hơn cấu trúc Node rải rác, đồng thời có chi phí bộ nhớ thấp hơn. LinkedList có ưu thế trong một số mô hình thao tác ở hai đầu hoặc khi đã có vị trí Iterator thích hợp, nhưng không nên chọn chỉ vì nghe nói xóa nhanh.

3. Set: HashSet, LinkedHashSet và TreeSet

Set biểu diễn tập hợp không chứa các phần tử trùng lặp theo quy tắc xác định phần tử bằng nhau của implementation. Khác với List, Set không cung cấp truy cập theo chỉ số.

Ba implementation phổ biến giải quyết ba yêu cầu khác nhau:

3.1. HashSet và cơ chế hashCode(), equals()

Một chi tiết thú vị là trong OpenJDK, HashSet được triển khai dựa trên HashMap. HashSet không cần tự xây dựng toàn bộ cơ chế băm và xử lý va chạm. Thay vào đó, nó sử dụng phần tử làm khóa trong một HashMap và dùng một đối tượng giả làm giá trị chung.

Cấu trúc HashSet bên trong
HashSet<String>
       |
       v
HashMap<String, Object>
       |
       +-- "Java"  -> PRESENT
       +-- "Spring" -> PRESENT
       +-- "SQL"   -> PRESENT

PRESENT là một đối tượng dùng chung làm giá trị giả.
HashSet chỉ quan tâm đến khóa, không cần lưu giá trị nghiệp vụ.

Để hiểu vì sao HashSet loại bỏ được phần tử trùng lặp, cần hiểu hai phương thức quan trọng của mọi đối tượng Java: hashCode() và equals().

hashCode() trả về một giá trị int dùng để hỗ trợ phân nhóm đối tượng. Giá trị này giúp cấu trúc băm xác định khu vực cần tìm kiếm. Nó không phải mã định danh duy nhất của đối tượng.

equals() xác định hai đối tượng có được xem là bằng nhau theo ngữ nghĩa của class hay không.

Hợp đồng bắt buộc là: nếu hai đối tượng bằng nhau theo equals(), chúng phải trả về cùng một giá trị hashCode(). Chiều ngược lại không bắt buộc. Hai đối tượng có cùng hashCode vẫn có thể khác nhau.

3.1.1. Quy trình kiểm tra phần tử trong HashSet

Luồng xử lý add(element)
HashSet.add(element)
        |
        v
HashMap.put(element, PRESENT)
        |
        v
Tính hashCode và xử lý hash
        |
        v
Xác định bucket
        |
        v
Bucket đang trống?
   | Có                    | Không
   v                       v
Thêm entry          So sánh hash và equals()
                           |
                    +------+------+
                    |             |
                 Bằng nhau      Khác nhau
                    |             |
                    v             v
              Không thêm       Tiếp tục tìm
              phần tử mới      hoặc thêm entry

add() trả về true nếu Set được thay đổi.
add() trả về false nếu phần tử đã tồn tại.

Về nguyên tắc, thao tác contains() và add() của HashSet thường có thời gian O(1) trung bình khi hàm băm phân bố tốt. Trong trường hợp va chạm bất lợi, chi phí có thể tăng lên. Hiệu năng không chỉ phụ thuộc số phần tử mà còn phụ thuộc chất lượng hashCode và cách các phần tử được phân bố trong bucket.

3.2. LinkedHashSet và TreeSet

3.2.1. LinkedHashSet: Băm kết hợp liên kết thứ tự

LinkedHashSet sử dụng cơ chế băm để quản lý phần tử và duy trì các liên kết phục vụ thứ tự duyệt. Trong implementation OpenJDK, cấu trúc này được xây dựng trên nền HashSet/LinkedHashMap với các entry có liên kết thứ tự.

Ví dụ, khi chèn các phần tử theo thứ tự "Java", "Python", "Go", LinkedHashSet giữ thứ tự duyệt tương ứng, miễn là không có thao tác nào làm thay đổi nội dung theo cách khác. Nếu thêm lại một phần tử đã tồn tại, phần tử đó không được nhân đôi.

3.2.2. TreeSet: Cây đỏ-đen (Red-Black Tree)

TreeSet duy trì các phần tử theo thứ tự sắp xếp. Trong OpenJDK, nó được triển khai dựa trên NavigableMap, thường sử dụng TreeMap và cây đỏ-đen (Red-Black Tree).

Cây đỏ-đen là một cây tìm kiếm nhị phân tự cân bằng. Mỗi Node mang thông tin màu đỏ hoặc đen, đồng thời tuân theo các quy tắc cân bằng để chiều cao cây được giữ ở mức O(log n). Khi thêm hoặc xóa phần tử, cây có thể thực hiện xoay cây (rotation) và đổi màu để duy trì các bất biến.

Minh họa cây tìm kiếm nhị phân
              20
             /  \
           10    30
          / \    / \
         5  15  25  35

Các giá trị nhỏ hơn Node nằm ở nhánh trái.
Các giá trị lớn hơn Node nằm ở nhánh phải.

Cây đỏ-đen duy trì thêm quy tắc màu và cân bằng.
Đây là sơ đồ tìm kiếm minh họa, không phải mô tả màu Node thực tế.

Các thao tác tìm kiếm, thêm và xóa của TreeSet thường có độ phức tạp O(log n). Bù lại, TreeSet phải duy trì thứ tự nên thường tốn chi phí hơn HashSet.

TreeSet xác định tính duy nhất dựa trên kết quả so sánh. Nếu comparator trả về 0 cho hai đối tượng, TreeSet xem chúng là cùng một phần tử về mặt thứ tự của Set, ngay cả khi equals() trả về false. Vì thế, comparator dùng cho Set cần nhất quán với equals nếu muốn tuân thủ đầy đủ hợp đồng của Set.

3.3. Bẫy khi định nghĩa đối tượng trong Set

Giả sử ta định nghĩa class Employee và đưa đối tượng vào HashSet. Nếu hai Employee có cùng mã nhân viên nhưng được tạo từ hai lần khác nhau, Java không tự hiểu rằng chúng giống nhau. Bạn phải định nghĩa rõ ngữ nghĩa bằng nhau bằng equals() và hashCode().

Java - equals() và hashCode()
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

class Employee {
    private final int id;
    private final String name;

    public Employee(int id, String name) {
        this.id = id;
        this.name = name;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (!(obj instanceof Employee other)) {
            return false;
        }
        return id == other.id;
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }

    @Override
    public String toString() {
        return id + ":" + name;
    }
}

public class Main {
    public static void main(String[] args) {
        Set<Employee> employees = new HashSet<>();

        employees.add(new Employee(1, "An"));
        employees.add(new Employee(1, "An Nguyen"));
        employees.add(new Employee(2, "Binh"));

        System.out.println(employees.size());
    }
}

Kết quả là 2 vì hai đối tượng có id bằng 1 được xem là bằng nhau, bất kể tên của chúng khác nhau.

Console Output
2

Ví dụ này sử dụng cú pháp pattern matching cho instanceof, phù hợp với Java 16 trở lên. Nếu sử dụng Java 8, hãy thay đoạn kiểm tra kiểu bằng cú pháp ép kiểu truyền thống.

Lỗi rất nguy hiểm: Không nên thay đổi những trường tham gia vào hashCode() của một đối tượng sau khi đã đưa đối tượng đó vào HashSet hoặc dùng nó làm khóa HashMap. Khi hash thay đổi, cấu trúc băm có thể không còn tìm thấy đối tượng tại bucket cũ. Ưu tiên các khóa bất biến (immutable).

4. Map: HashMap, LinkedHashMap và TreeMap

Map<K, V> lưu các cặp khóa-giá trị (key-value). Mỗi khóa ánh xạ đến tối đa một giá trị tại một thời điểm. Khi gọi put() với khóa đã tồn tại, giá trị cũ được thay bằng giá trị mới.

Map phù hợp với các bài toán như tra cứu người dùng bằng ID, đếm số lần xuất hiện của từ, lưu cấu hình theo tên thuộc tính hoặc ánh xạ mã sản phẩm đến đối tượng sản phẩm.

4.1. HashMap: Hashing, bucket và collision

HashMap thường sử dụng một mảng các bucket. Mỗi bucket có thể rỗng hoặc chứa một cấu trúc gồm một hay nhiều entry. Entry lưu hash, khóa, giá trị và liên kết đến entry tiếp theo khi cần.

Minh họa HashMap
HashMap<String, Integer>

Bucket 0: null
Bucket 1: [hash, "An", 10] -> [hash, "Binh", 20]
Bucket 2: null
Bucket 3: [hash, "Chi", 30]
Bucket 4: null
Bucket 5: [hash, "Dung", 40]

Mỗi bucket là một vị trí trong bảng băm.
Các entry có thể va chạm và cùng nằm trong một bucket.
Sơ đồ chỉ minh họa; vị trí thực tế phụ thuộc hash và capacity.

4.1.1. Từ khóa đến bucket

Khi thực hiện map.put(key, value), HashMap cần xác định vị trí phù hợp cho khóa. Trong OpenJDK, hash được xử lý thêm từ hashCode() để cải thiện việc phân bố các bit trước khi tính chỉ số bucket.

Với một bảng có độ dài là lũy thừa của hai, chỉ số bucket trong implementation thường được tính tương đương với:

Công thức xác định bucket
bucketIndex = (capacity - 1) & hash

Đây là chi tiết triển khai của HashMap trong OpenJDK, không phải công thức bắt buộc của interface Map. Nó tận dụng đặc tính capacity là lũy thừa của hai để xác định bucket bằng phép toán bit.

4.1.2. Collision là gì?

Collision (va chạm) xảy ra khi hai khóa khác nhau được ánh xạ đến cùng một bucket. Điều này hoàn toàn bình thường: số bucket hữu hạn, trong khi số khóa có thể lớn hơn nhiều.

Ví dụ, hai khóa có thể có hashCode khác nhau nhưng cùng được ánh xạ vào một bucket. Cũng có thể hai khóa khác nhau có hashCode bằng nhau. HashMap phải giải quyết cả hai tình huống.

Khi tìm kiếm một khóa trong bucket, HashMap có thể kiểm tra hash trước rồi so sánh khóa bằng equals(). Hash chỉ giúp thu hẹp phạm vi tìm kiếm; nó không thay thế phép so sánh bằng nhau.

4.2. Collision và cây đỏ-đen từ Java 8

Trong OpenJDK hiện đại, khi nhiều entry va chạm vào cùng một bucket, cấu trúc bên trong thường bắt đầu bằng danh sách liên kết. Nếu bucket trở nên quá dài và đáp ứng các điều kiện triển khai, các entry có thể được chuyển thành cây đỏ-đen (tree bin).

Với HashMap của OpenJDK 8 trở lên, các hằng số triển khai thường gặp là:

Các giá trị này là chi tiết nội bộ của một implementation cụ thể, không phải cam kết bất biến của API HashMap. Việc treeify còn phụ thuộc số lượng Node thực tế và trạng thái bảng.

Quy trình xử lý va chạm
put(key, value)
      |
      v
Tính hash và bucket index
      |
      v
Bucket trống?
   | Có                   | Không
   v                      v
Thêm Node             So sánh các Node hiện có
                            |
                            v
                    Khóa đã tồn tại?
                     | Có       | Không
                     v           v
                Cập nhật giá trị  Thêm Node
                                      |
                                      v
                             Bucket đủ điều kiện treeify?
                                | Có       | Chưa
                                v           v
                         Cây đỏ-đen     Danh sách liên kết
                         nếu bảng đủ lớn

Trong điều kiện thông thường, HashMap cung cấp hiệu năng trung bình gần O(1) cho get() và put(). Khi bucket chuyển thành cây, việc tìm kiếm trong cấu trúc đó có thể đạt O(log n) với các điều kiện phù hợp. Tuy nhiên, không nên diễn giải điều này thành bảo đảm rằng mọi tình huống tìm kiếm HashMap luôn có độ phức tạp O(log n).

4.3. Initial capacity, load factor và resize

Hai tham số thường được nhắc đến khi tối ưu HashMap là initialCapacity và loadFactor.

Với HashMap mặc định, load factor thường là 0.75. Nếu capacity hiện tại là 16, threshold thường bằng:

Ví dụ tính threshold
capacity = 16
loadFactor = 0.75

threshold = capacity * loadFactor
          = 16 * 0.75
          = 12

Khi số entry vượt ngưỡng, HashMap có thể mở rộng bảng, thường tăng capacity lên gấp đôi trong OpenJDK. Các entry sau đó được phân bố lại theo bảng mới. Quá trình này được gọi là resize hoặc rehash theo nghĩa rộng, mặc dù implementation có thể tái sử dụng thông tin hash đã lưu thay vì gọi lại hashCode() cho mọi khóa.

4.3.1. Load factor thấp hay cao tốt hơn?

Load factor Ưu điểm Nhược điểm
Thấp hơn, ví dụ 0.50 Ít va chạm hơn trong nhiều tình huống Tiêu tốn nhiều bộ nhớ bảng hơn
0.75 mặc định Cân bằng giữa bộ nhớ và hiệu năng trong nhiều trường hợp Vẫn có thể resize hoặc va chạm
Cao hơn, ví dụ 1.00 Có thể giảm mức sử dụng bộ nhớ cho bảng Có thể tăng mật độ va chạm và chi phí tra cứu

Load factor không phải phần trăm dung lượng bộ nhớ heap đang sử dụng. Nó là tỷ lệ giữa số entry và capacity của bảng băm theo cơ chế ngưỡng của implementation.

4.3.2. Khi biết trước số phần tử

Java - Khởi tạo HashMap có dự kiến dung lượng
import java.util.HashMap;
import java.util.Map;

public class Main {
    public static void main(String[] args) {
        int expectedEntries = 10_000;

        Map<Integer, String> map =
                new HashMap<>(expectedEntries);

        for (int i = 0; i < expectedEntries; i++) {
            map.put(i, "Value-" + i);
        }

        System.out.println(map.size());
        System.out.println(map.get(500));
    }
}

Trong OpenJDK hiện đại, constructor HashMap nhận initial capacity và load factor mặc định 0.75. Khi dự kiến số entry, cần lưu ý rằng initial capacity không nhất thiết bằng chính xác độ dài bảng nội bộ cuối cùng: implementation có thể làm tròn capacity lên lũy thừa của hai và còn áp dụng ngưỡng resize.

Nếu cần tính initial capacity theo số entry dự kiến để giảm nguy cơ resize, một ước lượng lý thuyết là lấy số entry chia cho load factor, rồi làm tròn lên và tính đến giới hạn triển khai. Trong code sản xuất, không nên tự áp dụng công thức mà bỏ qua giới hạn capacity, tràn số nguyên và khác biệt giữa các phiên bản Java.

4.4. HashMap, LinkedHashMap và TreeMap

Tiêu chí HashMap LinkedHashMap TreeMap
Cấu trúc chính Bảng băm Bảng băm kết hợp liên kết thứ tự Cây tìm kiếm đỏ-đen
get/put O(1) trung bình O(1) trung bình O(log n)
Thứ tự duyệt Không bảo đảm Thứ tự chèn mặc định Theo thứ tự khóa
Khóa null Cho phép một khóa null Cho phép một khóa null Thông thường không cho phép với natural ordering
Giá trị null Cho phép Cho phép Cho phép
Truy vấn theo khoảng Không có API sắp xếp theo khoảng Không có API sắp xếp theo khoảng Hỗ trợ firstKey(), ceilingKey(), subMap()...
Khi nào dùng? Tra cứu thông thường Cần thứ tự duyệt ổn định Cần khóa được sắp xếp và truy vấn theo khoảng

LinkedHashMap còn có chế độ duyệt theo thứ tự truy cập (access order). Đây là nền tảng để xây dựng những cấu trúc kiểu LRU cache đơn giản bằng cách kết hợp constructor phù hợp và cơ chế loại bỏ entry cũ.

TreeMap hữu ích khi cần lấy khóa nhỏ nhất/lớn nhất, tìm khóa gần nhất với một giá trị hoặc duyệt các khóa trong khoảng. Chẳng hạn, subMap(100, true, 200, false) có thể lấy vùng khóa từ 100 đến nhỏ hơn 200 theo thứ tự so sánh của Map.

Lưu ý quan trọng: HashMap không đảm bảo thứ tự duyệt. Không nên dựa vào việc các khóa số nguyên tình cờ xuất hiện theo thứ tự tăng dần trong một lần chạy. Nếu nghiệp vụ yêu cầu thứ tự, hãy sử dụng LinkedHashMap, TreeMap hoặc sắp xếp dữ liệu rõ ràng.

5. Queue, Deque và các cấu trúc liên quan

Queue mô hình hóa hàng đợi. Trong mô hình FIFO (First In, First Out), phần tử được thêm trước sẽ được xử lý trước. Deque (Double-Ended Queue) mở rộng ý tưởng này bằng cách cho phép thêm và xóa ở cả hai đầu.

5.1. Queue: offer(), poll() và peek()

Java cung cấp một số cặp phương thức có hành vi khác nhau khi hàng đợi không thể thêm hoặc không còn phần tử:

Thao tác Phương thức ưu tiên Hành vi khi thất bại hoặc rỗng
Thêm offer(e) Trả về false nếu không thể thêm
Thêm add(e) Có thể ném exception nếu không thể thêm
Lấy và xóa đầu hàng đợi poll() Trả về null nếu rỗng
Lấy và xóa đầu hàng đợi remove() Ném exception nếu rỗng
Xem phần tử đầu peek() Trả về null nếu rỗng
Xem phần tử đầu element() Ném exception nếu rỗng

5.2. ArrayDeque và PriorityQueue

ArrayDeque là lựa chọn thường phù hợp cho hàng đợi hai đầu trong môi trường không yêu cầu đồng bộ hóa. Nó sử dụng cấu trúc mảng vòng và không cho phép phần tử null. Có thể sử dụng ArrayDeque để triển khai stack bằng push(), pop() và peek().

PriorityQueue xử lý phần tử theo thứ tự ưu tiên, không nhất thiết theo thứ tự chèn. Implementation phổ biến sử dụng binary heap. Phần tử ưu tiên nhất được lấy ra trước theo natural ordering hoặc Comparator.

Java - ArrayDeque và PriorityQueue
import java.util.ArrayDeque;
import java.util.PriorityQueue;
import java.util.Queue;
import java.util.Deque;

public class Main {
    public static void main(String[] args) {
        Queue<String> tasks = new ArrayDeque<>();

        tasks.offer("Task A");
        tasks.offer("Task B");
        tasks.offer("Task C");

        System.out.println(tasks.poll());
        System.out.println(tasks.poll());

        PriorityQueue<Integer> priorities =
                new PriorityQueue<>();

        priorities.offer(30);
        priorities.offer(10);
        priorities.offer(20);

        System.out.println(priorities.poll());
        System.out.println(priorities.poll());

        Deque<String> stack = new ArrayDeque<>();
        stack.push("A");
        stack.push("B");

        System.out.println(stack.pop());
    }
}
Console Output
Task A
Task B
10
20
B

Độ phức tạp điển hình của PriorityQueue là O(log n) cho thêm và lấy phần tử ưu tiên nhất; xem phần tử ưu tiên nhất bằng peek() thường là O(1). Duyệt toàn bộ PriorityQueue không bảo đảm trả về các phần tử theo thứ tự ưu tiên. Muốn lấy ra theo thứ tự, hãy poll() lặp lại hoặc tạo bản sao rồi xử lý.

6. Ví dụ thực chiến 1: ArrayList và Iterator

Ví dụ đầu tiên mô phỏng danh sách công việc. Chúng ta sẽ thêm phần tử, sửa giá trị theo index, xóa bằng Iterator và duyệt danh sách sau khi cập nhật.

Main.java - ArrayListDemo
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> tasks = new ArrayList<>();

        // 1. Thêm dữ liệu
        tasks.add("Hoc Java");
        tasks.add("Hoc SQL");
        tasks.add("Lam bai tap");
        tasks.add("On tap Java");

        System.out.println("Ban dau: " + tasks);

        // 2. Sửa phần tử tại index 1
        String oldValue = tasks.set(1, "Hoc PostgreSQL");
        System.out.println("Gia tri cu: " + oldValue);
        System.out.println("Sau khi sua: " + tasks);

        // 3. Xóa phần tử bằng Iterator
        Iterator<String> iterator = tasks.iterator();

        while (iterator.hasNext()) {
            String task = iterator.next();

            if (task.equals("Lam bai tap")) {
                iterator.remove();
            }
        }

        System.out.println("Sau khi xoa: " + tasks);

        // 4. Duyệt danh sách
        System.out.println("Danh sach cuoi cung:");

        for (String task : tasks) {
            System.out.println("- " + task);
        }

        System.out.println("So phan tu: " + tasks.size());
    }
}

Giải thích:

Hãy chú ý rằng set() khác add(index, value). set() thay thế phần tử hiện có; add(index, value) chèn phần tử mới và dịch chuyển các phần tử phía sau.

Compile và chạy
javac Main.java
java Main
Console Output
Ban dau: [Hoc Java, Hoc SQL, Lam bai tap, On tap Java]
Gia tri cu: Hoc SQL
Sau khi sua: [Hoc Java, Hoc PostgreSQL, Lam bai tap, On tap Java]
Sau khi xoa: [Hoc Java, Hoc PostgreSQL, On tap Java]
Danh sach cuoi cung:
- Hoc Java
- Hoc PostgreSQL
- On tap Java
So phan tu: 3

Đây là chương trình độc lập, có thể lưu nguyên nội dung vào file Main.java và chạy bằng JDK đã cài đặt.

7. Ví dụ thực chiến 2: HashSet và HashMap

Trong ví dụ này, chúng ta giải quyết hai bài toán thường gặp: loại bỏ tên trùng lặp và đếm số lần xuất hiện của từng từ. HashSet phù hợp cho việc kiểm tra phần tử duy nhất, còn HashMap phù hợp cho việc lưu kết quả đếm theo khóa.

Main.java - SetAndMapDemo
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

public class Main {
    public static void main(String[] args) {
        // Phần 1: Khử trùng lặp
        List<String> inputNames = Arrays.asList(
                "An", "Binh", "An", "Chi", "Binh", "Dung"
        );

        Set<String> uniqueNames = new HashSet<>(inputNames);

        System.out.println("So ten ban dau: " + inputNames.size());
        System.out.println("So ten duy nhat: " + uniqueNames.size());
        System.out.println("Co ten An? " + uniqueNames.contains("An"));

        // Phần 2: Đếm tần suất từ
        List<String> words = Arrays.asList(
                "java", "sql", "java",
                "spring", "sql", "java"
        );

        Map<String, Integer> frequency = new HashMap<>();

        for (String word : words) {
            frequency.merge(word, 1, Integer::sum);
        }

        System.out.println("Tan suat java: " + frequency.get("java"));
        System.out.println("Tan suat sql: " + frequency.get("sql"));
        System.out.println("Tan suat spring: " + frequency.get("spring"));
    }
}

new HashSet<>(inputNames) khởi tạo tập hợp từ một Collection có sẵn. Những tên được xem là bằng nhau theo equals() chỉ được giữ một lần.

Phương thức Map.merge(key, value, remappingFunction) giúp cập nhật số đếm gọn hơn. Nếu từ chưa tồn tại, giá trị ban đầu là 1. Nếu đã tồn tại, hàm Integer::sum cộng giá trị mới vào số đếm cũ.

Trong ví dụ này, HashSet không bảo đảm thứ tự hiển thị. Chương trình chỉ in kích thước và kết quả tra cứu, không phụ thuộc vào thứ tự các tên trong Set.

Compile và chạy
javac Main.java
java Main
Console Output
So ten ban dau: 6
So ten duy nhat: 4
Co ten An? true
Tan suat java: 3
Tan suat sql: 2
Tan suat spring: 1

7.1. Tối ưu việc đếm bằng Map

Với n từ đầu vào và k từ khác nhau, cách đếm bằng HashMap thường có độ phức tạp O(n) trung bình, với giả định các thao tác băm có chi phí trung bình gần O(1). Bộ nhớ sử dụng tỷ lệ với số từ khác nhau, tức khoảng O(k), không phải luôn O(n).

Trong thực tế, kỹ thuật này còn được dùng để đếm số đơn hàng theo trạng thái, số request theo endpoint, số lần xuất hiện của mã lỗi hoặc thống kê từ trong văn bản. Khi dữ liệu lớn, cần tính thêm chi phí bộ nhớ của đối tượng String, Integer và entry, chứ không chỉ đếm số phần tử logic.

8. Ví dụ thực chiến 3: Comparable và Comparator

Khi làm việc với đối tượng nghiệp vụ, đôi khi cần sắp xếp theo nhiều tiêu chí. Ví dụ, danh sách nhân viên có thể được sắp xếp theo lương giảm dần, sau đó theo tên tăng dần.

Java cung cấp hai cơ chế chính:

8.1. Phân biệt Comparable và Comparator

Tiêu chí Comparable Comparator
Phương thức compareTo(other) compare(a, b)
Nơi định nghĩa Trong class cần so sánh Ở bên ngoài class hoặc bằng lambda
Số quy tắc Thường biểu diễn thứ tự tự nhiên Có thể có nhiều comparator khác nhau
Trường hợp sử dụng Đối tượng có một thứ tự mặc định hợp lý Sắp xếp theo từng nghiệp vụ cụ thể

8.2. Chương trình sắp xếp nhân viên

Main.java - CustomSortingDemo
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

class Employee implements Comparable<Employee> {
    private final int id;
    private final String name;
    private final int salary;

    public Employee(int id, String name, int salary) {
        this.id = id;
        this.name = name;
        this.salary = salary;
    }

    public int getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public int getSalary() {
        return salary;
    }

    // Thứ tự tự nhiên: ID tăng dần
    @Override
    public int compareTo(Employee other) {
        return Integer.compare(this.id, other.id);
    }

    @Override
    public String toString() {
        return id + " - " + name + " - " + salary;
    }
}

public class Main {
    public static void main(String[] args) {
        List<Employee> employees = new ArrayList<>();

        employees.add(new Employee(3, "Chi", 1800));
        employees.add(new Employee(1, "An", 2500));
        employees.add(new Employee(2, "Binh", 2500));
        employees.add(new Employee(4, "Dung", 1500));

        // Cách 1: Sắp xếp theo Comparable
        employees.sort(null);

        System.out.println("Theo ID:");
        employees.forEach(System.out::println);

        // Cách 2: Lương giảm dần, tên tăng dần
        Comparator<Employee> bySalaryThenName =
                Comparator.comparingInt(Employee::getSalary)
                        .reversed()
                        .thenComparing(Employee::getName);

        employees.sort(bySalaryThenName);

        System.out.println("Theo luong giam dan, ten tang dan:");
        employees.forEach(System.out::println);
    }
}

Trong compareTo(), chúng ta dùng Integer.compare() thay vì phép trừ this.id - other.id. Phép trừ có thể tràn số nguyên khi các giá trị nằm gần giới hạn int; Integer.compare() tránh rủi ro đó.

Comparator thứ hai ghép nhiều tiêu chí. comparingInt() sắp xếp theo lương tăng dần; reversed() đảo thứ tự; thenComparing() dùng tên làm tiêu chí tiếp theo khi lương bằng nhau.

Compile và chạy
javac Main.java
java Main
Console Output
Theo ID:
1 - An - 2500
2 - Binh - 2500
3 - Chi - 1800
4 - Dung - 1500
Theo luong giam dan, ten tang dan:
1 - An - 2500
2 - Binh - 2500
3 - Chi - 1800
4 - Dung - 1500

8.3. Dùng TreeSet để duy trì thứ tự tự động

Nếu chỉ cần sắp xếp danh sách một lần, ArrayList kết hợp sort() là lựa chọn tự nhiên. Nếu cần tập hợp luôn được duy trì theo thứ tự sau mỗi lần thêm hoặc xóa, TreeSet có thể phù hợp hơn.

Java - TreeSet với Comparator
import java.util.Comparator;
import java.util.Set;
import java.util.TreeSet;

public class Main {
    public static void main(String[] args) {
        Set<String> names = new TreeSet<>(
                Comparator.comparingInt(String::length)
                        .thenComparing(Comparator.naturalOrder())
        );

        names.add("Java");
        names.add("Go");
        names.add("Python");
        names.add("SQL");
        names.add("Java");

        System.out.println(names);
    }
}
Console Output
[Go, SQL, Java, Python]

Comparator trong ví dụ sắp xếp theo độ dài chuỗi, sau đó theo thứ tự từ điển. Hai chuỗi chỉ được xem là cùng một phần tử theo quy tắc của TreeSet khi comparator trả về 0. Nếu comparator chỉ so sánh độ dài, các chuỗi khác nhau nhưng cùng độ dài có thể bị xem là trùng. Vì vậy, việc thêm tiêu chí phụ là rất quan trọng.

Nguyên tắc thiết kế: Comparable và Comparator phải tuân thủ tính nhất quán và các quy tắc so sánh. Tránh viết comparator có kết quả thay đổi tùy thời điểm hoặc phụ thuộc vào trường dữ liệu đang bị sửa đổi trong lúc phần tử nằm trong TreeSet.

9. ConcurrentModificationException và an toàn khi duyệt

Một lỗi rất phổ biến khi mới làm việc với Collection là xóa phần tử trực tiếp trong vòng lặp for-each. Điều này có thể dẫn đến ConcurrentModificationException.

9.1. Ví dụ gây lỗi

Java - Ví dụ không an toàn
import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<Integer> numbers =
                new ArrayList<>(
                        List.of(1, 2, 3, 4, 5)
                );

        for (Integer number : numbers) {
            if (number % 2 == 0) {
                numbers.remove(number);
            }
        }

        System.out.println(numbers);
    }
}

Đoạn code trên có thể ném ConcurrentModificationException khi Iterator phát hiện cấu trúc danh sách đã bị sửa đổi ngoài cơ chế mà Iterator cho phép. Vì exception xảy ra trong quá trình thực thi, chương trình không bảo đảm in ra danh sách kết quả.

Với ArrayList, vòng lặp for-each sử dụng Iterator ở bên dưới. Khi gọi numbers.remove(), bạn đang thay đổi danh sách trực tiếp trong khi Iterator vẫn quản lý trạng thái duyệt của riêng nó.

9.2. Cách khắc phục 1: Iterator.remove()

Java - Xóa an toàn bằng Iterator
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<Integer> numbers =
                new ArrayList<>(
                        List.of(1, 2, 3, 4, 5)
                );

        Iterator<Integer> iterator = numbers.iterator();

        while (iterator.hasNext()) {
            Integer number = iterator.next();

            if (number % 2 == 0) {
                iterator.remove();
            }
        }

        System.out.println(numbers);
    }
}
Console Output
[1, 3, 5]

Iterator.remove() cập nhật danh sách thông qua cơ chế xóa được Iterator hỗ trợ. Phương thức này chỉ được gọi sau một lần next() hợp lệ và không được gọi lặp lại cho cùng một phần tử nếu chưa có lần next() mới.

9.3. Cách khắc phục 2: removeIf()

Trong Java 8 trở lên, nếu chỉ cần loại bỏ phần tử theo điều kiện, removeIf() thường là cách viết ngắn gọn và dễ đọc hơn.

Java - removeIf()
import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<Integer> numbers =
                new ArrayList<>(
                        List.of(1, 2, 3, 4, 5)
                );

        numbers.removeIf(number -> number % 2 == 0);

        System.out.println(numbers);
    }
}
Console Output
[1, 3, 5]

9.4. Cách khắc phục 3: Dùng Collection hỗ trợ đồng thời

ConcurrentModificationException không nhất thiết có nghĩa là chương trình có nhiều thread. Nó có thể xảy ra ngay trong một thread khi cấu trúc dữ liệu bị sửa đổi ngoài cơ chế được Iterator hỗ trợ.

Nhiều Collection thông thường sử dụng fail-fast iterator: Iterator có thể phát hiện một số thay đổi cấu trúc ngoài dự kiến và ném exception. Tuy nhiên, fail-fast chỉ mang tính phát hiện lỗi theo cơ chế best-effort; không được dựa vào nó như một công cụ đồng bộ hóa hay một bảo đảm phát hiện mọi sửa đổi.

Nếu nhiều thread cần cùng truy cập và cập nhật dữ liệu, phải chọn chiến lược đồng bộ hóa thích hợp. Ví dụ, ConcurrentHashMap hỗ trợ thao tác Map đồng thời, trong khi CopyOnWriteArrayList phù hợp hơn với danh sách có nhiều lần đọc và ít lần ghi.

Java - ConcurrentHashMap
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

public class Main {
    public static void main(String[] args) {
        ConcurrentMap<String, Integer> counts =
                new ConcurrentHashMap<>();

        counts.put("Java", 10);
        counts.put("SQL", 5);

        counts.merge("Java", 1, Integer::sum);

        counts.forEach((key, value) ->
                System.out.println(key + ": " + value)
        );
    }
}

Ví dụ trên không minh họa việc xóa phần tử khỏi Map trong lúc duyệt. Nó cho thấy cách sử dụng ConcurrentHashMap để thực hiện thao tác cập nhật số đếm thông qua merge(). Kết quả thứ tự in ra không được bảo đảm.

Một kết quả Console Output có thể gặp
Java: 11
SQL: 5

ConcurrentHashMap hỗ trợ Iterator có tính weakly consistent: Iterator có thể phản ánh một phần các cập nhật xảy ra trong quá trình duyệt và không ném ConcurrentModificationException chỉ vì những cập nhật đồng thời đó. Tuy nhiên, điều này không đồng nghĩa với snapshot nhất quán tuyệt đối.

Best practice: Không chọn ConcurrentHashMap chỉ để né ConcurrentModificationException. Nếu chương trình chạy một thread và cần xóa phần tử khi duyệt, hãy ưu tiên Iterator.remove() hoặc removeIf(). Nếu có nhiều thread, hãy thiết kế rõ yêu cầu về tính nhất quán, đồng bộ hóa và thứ tự cập nhật.

10. Độ phức tạp thuật toán và lựa chọn Collection

Big O mô tả cách chi phí thời gian hoặc bộ nhớ tăng lên khi kích thước dữ liệu tăng. Đây là công cụ hữu ích để chọn cấu trúc dữ liệu, nhưng không thể thay thế việc đo hiệu năng thực tế. Hằng số ẩn, cách bố trí bộ nhớ, cache CPU, chất lượng dữ liệu và chi phí tạo đối tượng đều có ảnh hưởng.

10.1. Bảng độ phức tạp tham khảo

Bảng dưới đây mô tả các trường hợp điển hình, không phải cam kết tuyệt đối cho mọi đầu vào hay mọi phiên bản JDK.

Implementation Truy cập / tra cứu Thêm Xóa Ghi chú
ArrayList get(index): O(1), contains: O(n) Cuối: amortized O(1) Theo index: O(n) Phù hợp cho truy cập tuần tự và theo index
LinkedList get(index): O(n) Đầu/cuối: O(1) Đầu/cuối: O(1) Không phù hợp với truy cập ngẫu nhiên thường xuyên
HashSet contains: O(1) trung bình O(1) trung bình O(1) trung bình Phụ thuộc chất lượng hashCode và va chạm
TreeSet O(log n) O(log n) O(log n) Giữ phần tử theo thứ tự
HashMap get: O(1) trung bình put: O(1) trung bình remove: O(1) trung bình Không bảo đảm thứ tự
LinkedHashMap O(1) trung bình O(1) trung bình O(1) trung bình Giữ thứ tự duyệt theo cấu hình
TreeMap O(log n) O(log n) O(log n) Hỗ trợ tìm kiếm theo khoảng khóa
ArrayDeque Đầu/cuối: O(1) Đầu/cuối: amortized O(1) Đầu/cuối: amortized O(1) Phù hợp cho stack và queue thông thường
PriorityQueue peek: O(1) O(log n) poll: O(log n) Ưu tiên phần tử nhỏ nhất theo comparator mặc định

10.2. Chọn cấu trúc theo bài toán thực tế

Bài toán Lựa chọn thường phù hợp Lý do
Danh sách sản phẩm cần duyệt và truy cập theo index ArrayList Truy cập ngẫu nhiên nhanh, ít overhead
Loại bỏ mã đơn hàng trùng lặp HashSet Kiểm tra tồn tại nhanh trung bình
Lưu danh sách duy nhất theo thứ tự chèn LinkedHashSet Khử trùng lặp nhưng giữ thứ tự duyệt
Lưu các khóa đã sắp xếp TreeSet Tự duy trì thứ tự và hỗ trợ truy vấn theo khoảng
Tra cứu tài khoản theo ID HashMap Tra cứu theo khóa nhanh trung bình
Lưu cấu hình và giữ thứ tự khai báo LinkedHashMap Thứ tự duyệt ổn định theo thứ tự chèn
Tra cứu bản ghi theo khoảng thời gian hoặc khóa có thứ tự TreeMap Hỗ trợ ceilingKey(), floorKey(), subMap()
Hàng đợi xử lý tác vụ FIFO ArrayDeque hoặc BlockingQueue phù hợp ArrayDeque cho một thread; BlockingQueue cho mô hình producer-consumer đồng thời
Stack thao tác gần đây ArrayDeque push()/pop() rõ ràng, tránh dùng Stack legacy
Danh sách đọc nhiều, cập nhật ít giữa các thread CopyOnWriteArrayList Iterator đọc trên snapshot; ghi tốn chi phí sao chép mảng

10.3. Vì sao O(1) chưa chắc luôn nhanh hơn O(log n)?

Big O tập trung vào tốc độ tăng trưởng khi n lớn, không phản ánh đầy đủ hằng số chi phí. Với một tập hợp rất nhỏ, cấu trúc đơn giản có thể hoạt động tốt dù thuật toán tìm kiếm tuyến tính là O(n). Ngược lại, khi số phần tử tăng lên hàng trăm nghìn hoặc hàng triệu, ưu điểm của tra cứu theo băm có thể trở nên rõ rệt.

ArrayList cũng có lợi thế do mảng liên tục trong bộ nhớ thường tận dụng cache CPU tốt. LinkedList phải lần theo các tham chiếu Node, khiến việc truy cập tuần tự có thể tốn nhiều cache miss hơn.

Với ứng dụng thực tế, nếu hiệu năng là yêu cầu quan trọng, hãy đo bằng dữ liệu đại diện. Đối với benchmark Java nghiêm túc, nên cân nhắc JMH (Java Microbenchmark Harness), tránh kết luận từ một vòng lặp đo thời gian đơn giản vì JIT compilation, warm-up, garbage collection và các yếu tố môi trường có thể làm kết quả sai lệch.

11. Best Practices trong dự án thực tế

11.1. Khai báo biến theo interface

Thay vì gắn chặt code với một implementation khi không cần thiết, hãy ưu tiên khai báo theo interface:

Java - Program to interface
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

public class Main {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        Set<String> roles = new HashSet<>();
        Map<Long, String> users = new HashMap<>();

        names.add("An");
        roles.add("ADMIN");
        users.put(1001L, "An");

        System.out.println(names);
        System.out.println(roles);
        System.out.println(users);
    }
}

Cách khai báo này làm giảm sự phụ thuộc vào implementation cụ thể, giúp việc thay đổi cấu trúc dữ liệu dễ dàng hơn. Tuy nhiên, nếu nghiệp vụ phụ thuộc vào tính năng riêng của implementation, hãy thể hiện yêu cầu đó rõ ràng thay vì cố ép mọi thứ về một interface quá chung.

11.2. Tránh sử dụng sai equals() và hashCode()

11.3. Dùng removeIf(), computeIfAbsent() và merge()

Java cung cấp các phương thức giúp thao tác với Collection và Map an toàn, rõ ràng hơn.

Java - Các phương thức tiện ích hiện đại
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class Main {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>(
                List.of("An", "", "Binh", "", "Chi")
        );

        names.removeIf(String::isEmpty);

        Map<String, List<String>> groups =
                new HashMap<>();

        groups.computeIfAbsent("Backend",
                key -> new ArrayList<>()).add("Java");

        groups.computeIfAbsent("Backend",
                key -> new ArrayList<>()).add("Spring");

        Map<String, Integer> counts = new HashMap<>();
        counts.merge("Java", 1, Integer::sum);
        counts.merge("Java", 1, Integer::sum);

        System.out.println(names);
        System.out.println(groups);
        System.out.println(counts);
    }
}
Một kết quả Console Output có thể gặp
[An, Binh, Chi]
{Backend=[Java, Spring]}
{Java=2}

computeIfAbsent() phù hợp khi cần khởi tạo giá trị cho khóa chưa tồn tại. merge() phù hợp với các bài toán tích lũy hoặc kết hợp giá trị. Với Map đồng thời, hãy xem xét hợp đồng đồng thời của implementation đang dùng và tránh giả định rằng một chuỗi nhiều lời gọi riêng lẻ luôn tạo thành một giao dịch nguyên tử.

11.4. Phân biệt unmodifiable và immutable

Một danh sách không cho phép sửa qua một tham chiếu không đồng nghĩa với việc mọi đối tượng bên trong đều bất biến. Ví dụ, Collections.unmodifiableList(list) tạo một wrapper không cho phép sửa danh sách thông qua wrapper đó. Nếu code khác vẫn sửa danh sách gốc, wrapper có thể phản ánh những thay đổi ấy.

Trong Java 9 trở lên, các phương thức như List.of(), Set.of() và Map.of() tạo các collection không cho phép thêm, xóa hoặc thay thế phần tử thông qua API thông thường. Chúng không biến các đối tượng có thể thay đổi bên trong thành đối tượng bất biến sâu.

11.5. Tránh null không cần thiết

Khả năng chấp nhận null khác nhau giữa các implementation. Chẳng hạn, HashMap cho phép một khóa null, còn ConcurrentHashMap không cho phép khóa hoặc giá trị null. TreeMap với natural ordering thông thường không chấp nhận khóa null.

Nếu null mang ý nghĩa nghiệp vụ rõ ràng, hãy xác định cách xử lý thống nhất. Nếu không, cân nhắc loại bỏ null khỏi dữ liệu đầu vào để giảm lỗi phát sinh trong các bước xử lý tiếp theo.

11.6. Chọn Collection đồng thời đúng nhu cầu

Không có một cấu trúc đồng thời nào phù hợp với mọi tình huống. Cần xem xét số lượng thread, tỷ lệ đọc/ghi, yêu cầu nhất quán và khả năng chấp nhận độ trễ.

Implementation Đặc điểm Phù hợp khi
ConcurrentHashMap Map đồng thời, thao tác nguyên tử ở các API hỗ trợ Nhiều thread tra cứu và cập nhật theo khóa
CopyOnWriteArrayList Thay đổi tạo bản sao mảng mới Đọc nhiều, ghi ít
ConcurrentLinkedQueue Hàng đợi liên kết không chặn Trao đổi phần tử giữa các thread khi không cần cơ chế blocking
ArrayBlockingQueue Hàng đợi giới hạn dung lượng, hỗ trợ blocking Producer-consumer có backpressure
BlockingQueue Interface cho hàng đợi có các thao tác chờ Pipeline xử lý công việc đa luồng

12. Bài tập thực hành và tổng kết

12.1. Bài tập 1: Khử trùng lặp nhưng giữ thứ tự

Cho danh sách các mã sản phẩm: SP01, SP03, SP01, SP02, SP03, SP04. Hãy tạo một cấu trúc chỉ giữ mã duy nhất nhưng vẫn giữ thứ tự xuất hiện lần đầu.

Gợi ý: sử dụng LinkedHashSet. Kết quả mong đợi:

Expected Output
[SP01, SP03, SP02, SP04]

12.2. Bài tập 2: Đếm tần suất từ

Cho một danh sách các từ trong câu. Hãy xây dựng Map để đếm số lần mỗi từ xuất hiện. Mở rộng chương trình để chuẩn hóa chữ hoa/chữ thường bằng toLowerCase() và xử lý dấu câu trước khi đếm.

Yêu cầu: giải thích vì sao dùng HashMap phù hợp hơn ArrayList trong việc tra cứu và cập nhật số đếm theo từ.

12.3. Bài tập 3: Quản lý sinh viên

Tạo class Student gồm mã sinh viên, họ tên và điểm trung bình. Sau đó thực hiện các yêu cầu:

12.4. Bài tập 4: Mô phỏng hàng đợi công việc

Sử dụng ArrayDeque để mô phỏng hàng đợi FIFO. Thêm năm tác vụ, lấy ra hai tác vụ đầu tiên và in trạng thái hàng đợi sau mỗi thao tác. Tiếp theo, thay Queue bằng PriorityQueue để so sánh sự khác nhau giữa thứ tự xử lý FIFO và thứ tự ưu tiên.

12.5. Checklist tự đánh giá

Hoàn thành bài học khi bạn có thể giải thích:
✓ Vì sao Map không kế thừa Collection.
✓ Vì sao ArrayList có truy cập theo index O(1) nhưng chèn giữa thường là O(n).
✓ Vì sao LinkedList có thể xóa Node trong O(1) nhưng remove(index) vẫn là O(n).
✓ Vì sao HashSet cần hashCode() và equals().
✓ Vì sao HashMap có thể xảy ra collision và resize.
✓ Vì sao TreeSet có thể xem hai đối tượng là trùng nhau dựa trên Comparator.
✓ Khi nào nên dùng LinkedHashMap, TreeMap, ArrayDeque và PriorityQueue.
✓ Vì sao ConcurrentModificationException có thể xảy ra trong một thread.
✓ Cách chọn Collection dựa trên yêu cầu truy cập, thứ tự, tính duy nhất và đồng thời.

12.6. Tổng kết kiến trúc JCF

Để lựa chọn Collection đúng, hãy bắt đầu từ yêu cầu nghiệp vụ thay vì chọn lớp quen thuộc nhất. Nếu cần danh sách có thứ tự và truy cập nhanh theo index, hãy nghĩ đến ArrayList. Nếu cần khử trùng lặp, hãy cân nhắc HashSet; nếu cần duy trì thứ tự chèn, dùng LinkedHashSet; nếu cần thứ tự sắp xếp, dùng TreeSet.

Nếu cần tra cứu theo khóa, HashMap thường là lựa chọn mặc định. LinkedHashMap phù hợp khi thứ tự duyệt quan trọng, còn TreeMap phù hợp khi khóa cần được sắp xếp và cần truy vấn theo khoảng. Với hàng đợi, ArrayDeque phù hợp cho các thao tác hai đầu thông thường, trong khi PriorityQueue xử lý theo độ ưu tiên.

Sơ đồ quyết định nhanh
Bạn cần lưu dữ liệu như thế nào?

1. Danh sách có thứ tự và cho phép trùng?
   -> ArrayList là lựa chọn mặc định hợp lý.

2. Tập hợp không trùng?
   -> HashSet
   -> LinkedHashSet nếu cần thứ tự chèn
   -> TreeSet nếu cần thứ tự sắp xếp

3. Tra cứu theo khóa?
   -> HashMap
   -> LinkedHashMap nếu cần thứ tự duyệt
   -> TreeMap nếu cần khóa có thứ tự

4. Hàng đợi?
   -> ArrayDeque cho FIFO hoặc thao tác hai đầu
   -> PriorityQueue cho xử lý theo độ ưu tiên
   -> BlockingQueue cho producer-consumer đồng thời

5. Nhiều thread cùng truy cập?
   -> Chọn cấu trúc đồng thời theo yêu cầu nhất quán,
      tỷ lệ đọc/ghi và cách phối hợp thread.

Java Collections Framework không đơn thuần là một tập hợp các API tiện dụng. Nó là sự kết hợp giữa thiết kế hướng interface, các cấu trúc dữ liệu kinh điển, thuật toán và những quy tắc nhất quán về đối tượng. Khi hiểu cơ chế bên dưới, bạn sẽ biết tại sao một thao tác nhanh ở cấu trúc này lại chậm ở cấu trúc khác, khi nào một thay đổi có thể gây lỗi và cách chọn cấu trúc phù hợp với quy mô dữ liệu thực tế.

Điều quan trọng nhất cần ghi nhớ: hãy chọn cấu trúc dữ liệu theo cách bạn cần truy cập, cập nhật và tổ chức dữ liệu. Khi lựa chọn đó đúng, code thường đơn giản hơn, hiệu năng tốt hơn và dễ bảo trì hơn ngay từ đầu.