Mở đầu chặng Collections — mười bốn bài, và đây là chặng có tác động trực tiếp nhất tới mã bạn viết hằng ngày.

Bài này là tấm bản đồ. Không đi sâu vào cái nào, mà trả lời câu hỏi bạn gặp mười lần mỗi ngày: cần một chỗ chứa dữ liệu, chọn cái gì?

Bản đồ Collections Framework

Hai nhánh, không phải một

Điều đầu tiên cần nhớ: Map không phải Collection.

Collection là "một đám phần tử". Map là "các cặp khoá – giá trị". Chúng không kế thừa nhau, và đó là lý do Map không có add(), không duyệt trực tiếp bằng for-each được.

Muốn duyệt Map thì đi qua ba khung nhìn của nó: keySet(), values(), entrySet() — cái thứ ba là cái bạn nên dùng nhất, vì nó cho cả khoá lẫn giá trị trong một lượt.

Nhánh Collection chia làm ba:

List — có thứ tự, cho phép trùng lặp, truy cập theo chỉ số.

Set — không trùng lặp, và "trùng" nghĩa là gì thì do equals/hashCode hoặc comparator quyết định — đúng cái bẫy BigDecimal ở bài Comparable.

QueueDeque — thêm và lấy ra theo quy tắc, thường ở hai đầu.

Ai cài đặt cái gì

Chạy thật để xem, thay vì tin bảng vẽ:

  ArrayList        -> List RandomAccess
  LinkedList       -> List Deque
  ArrayDeque       -> Deque
  HashSet          -> Set
  LinkedHashSet    -> SequencedSet
  TreeSet          -> NavigableSet
  HashMap          -> Map
  LinkedHashMap    -> SequencedMap
  TreeMap          -> NavigableMap

Ba điều đáng chú ý trong danh sách này.

ArrayList cài thêm RandomAccess — một giao diện đánh dấu, không có phương thức nào. Nó nói với các thuật toán rằng truy cập theo chỉ số ở đây là rẻ. Collections.binarySearch đọc dấu này để chọn chiến lược khác nhau cho ArrayListLinkedList.

LinkedList vừa là List vừa là Deque — nên nó dùng được như ngăn xếp hoặc hàng đợi. Nhưng ArrayDeque gần như luôn nhanh hơn cho việc đó, chuyện của bài về Deque.

LinkedHashSetLinkedHashMap cài SequencedSetSequencedMap — hai giao diện mới toanh.

Sequenced Collections: bổ sung của Java 21

Trước Java 21, lấy phần tử cuối của một LinkedHashSet phải duyệt hết cả tập. Không có API nào cho việc đơn giản đó, dù thứ tự rõ ràng là có.

Java 21 thêm ba giao diện — SequencedCollection, SequencedSet, SequencedMap — với các phương thức thống nhất:

seq.getFirst();      seq.getLast();
seq.addFirst(x);     seq.addLast(x);
seq.removeFirst();   seq.removeLast();
seq.reversed();      // khung nhìn đảo ngược, không sao chép

Nhờ đó List, Deque, LinkedHashSet, LinkedHashMap, SortedSet giờ có chung một bộ từ vựng. Nếu bạn từng viết list.get(list.size() - 1) thì giờ có list.getLast().

reversed()khung nhìn, không sao chép — sửa bản đảo thì bản gốc cũng đổi.

Thứ tự duyệt: ba loại Set cho ba kết quả

var vao = List.of("chuoi", "banh", "an", "dua");
  HashSet       : [dua, banh, an, chuoi]
  LinkedHashSet : [chuoi, banh, an, dua]
  TreeSet       : [an, banh, chuoi, dua]

HashSet — thứ tự không xác định, phụ thuộc mã băm. Chú ý nó không phải ngẫu nhiên: cùng dữ liệu thì cùng thứ tự, nên rất dễ viết một test dựa vào thứ tự đó rồi hỏng khi dữ liệu đổi. Đừng bao giờ phụ thuộc vào thứ tự của HashSet.

LinkedHashSet — theo thứ tự chèn. Trả về đúng cái người dùng đưa vào, hợp cho mọi thứ hiển thị ra giao diện.

TreeSet — theo thứ tự sắp xếp.

Cùng quy luật đó áp dụng cho HashMap, LinkedHashMap, TreeMap.

Cái nào chứa được null

Đây là bảng tôi phải tra lại nhiều lần trước khi thuộc, nên đo hẳn cho chắc:

  HashMap khoá null    -> được
  TreeMap khoá null    -> NullPointerException
  HashSet phần tử null -> được
  ArrayList null       -> được
  List.of null         -> NullPointerException

Quy luật: các bản cài đặt cũ (HashMap, ArrayList, HashSet) cho phép null; các bản có sắp xếp (TreeMap, TreeSet) và các bản bất biến mới (List.of, Map.of, Set.of) thì không.

TreeMap từ chối null vì nó phải so sánh khoá, mà null.compareTo(...) là NPE.

Các API bất biến từ chối null vì đó là lựa chọn thiết kế có chủ ý — nhóm phát triển JDK cho rằng null trong collection gây nhiều lỗi hơn là tiện lợi. Đây cũng là lý do Map.of(...) an toàn hơn khi trả dữ liệu ra ngoài.

"Bất biến" cũng có nhiều mức

  List.of().add        -> UnsupportedOperationException
  Arrays.asList().add  -> UnsupportedOperationException
  Arrays.asList().set  -> được

Ba dòng này làm rõ một chỗ rất hay nhầm:

List.of(...)bất biến hoàn toàn. Không thêm, không xoá, không sửa.

Arrays.asList(...)kích thước cố định, nhưng phần tử sửa được. Nó là một khung nhìn gắn với mảng gốc, nên set được, và sửa mảng thì list cũng đổi theo.

Collections.unmodifiableList(ds)khung nhìn chỉ đọc lên một danh sách sửa được. Người cầm khung nhìn không sửa được, nhưng ai còn giữ ds gốc thì vẫn sửa được, và người kia thấy thay đổi đó.

Với sao chép phòng vệ như bài hôm qua, List.copyOf(...) là lựa chọn đúng — nó sao chép thật và bất biến thật.

Bảng chọn nhanh

Đây là phần đáng in ra dán cạnh màn hình:

Cần gì Dùng
Danh sách thường, truy cập theo chỉ số ArrayList
Danh sách chỉ đọc, cố định List.of(...)
Bỏ trùng lặp, không cần thứ tự HashSet
Bỏ trùng lặp, giữ thứ tự chèn LinkedHashSet
Bỏ trùng lặp, cần sắp xếp TreeSet
Tra cứu theo khoá HashMap
Tra cứu, giữ thứ tự chèn LinkedHashMap
Tra cứu, cần sắp xếp hoặc truy vấn theo khoảng TreeMap
Ngăn xếp hoặc hàng đợi ArrayDeque
Lấy ra theo mức ưu tiên PriorityQueue
Nhiều luồng cùng ghi ConcurrentHashMap

Vài lựa chọn không nên dùng, dù bạn sẽ gặp trong mã cũ:

VectorHashtable — đồng bộ hoá trên mọi phương thức, chậm và vẫn không an toàn cho thao tác ghép đôi. Thay bằng ArrayList/HashMap, hoặc ConcurrentHashMap nếu thật sự cần nhiều luồng.

Stack — kế thừa Vector, và tệ hơn: nó đánh chỉ số ngược đời, push thêm vào cuối nhưng get(0) lại là phần tử dưới đáy. Dùng ArrayDeque.

LinkedList — hiếm khi thắng ArrayList, kể cả ở những chỗ lý thuyết bảo nó nhanh hơn. Bài về ArrayListLinkedList sẽ đo cụ thể.

Khai kiểu ở mức giao diện

Một thói quen nhỏ mà lợi lâu dài:

List<String> ds = new ArrayList<>();          // tốt
ArrayList<String> ds = new ArrayList<>();     // ràng buộc không cần thiết

Khai bằng giao diện thì đổi bản cài đặt sau này chỉ sửa một chỗ. Tương tự với tham số và kiểu trả về: nhận Collection<String> nếu chỉ cần duyệt, nhận List<String> nếu cần thứ tự.

Ngoại lệ: khi bạn cố tình cần đặc tính của một bản cài đặt cụ thể — ví dụ khai ArrayDeque vì mã sau đó dùng push/pop — thì khai kiểu cụ thể lại rõ ràng hơn.

Thử ba mươi giây

Chạy đoạn ba loại Set ở trên với dữ liệu của bạn. Rồi đổi thứ tự các phần tử đầu vào và chạy lại.

LinkedHashSetTreeSet cho kết quả đoán trước được. HashSet thì đổi — và nếu trong dự án của bạn có test nào đang so sánh chuỗi kết quả duyệt một HashSet, nó là một test sẽ hỏng vào một ngày không báo trước.

Ngày mai: ArrayListLinkedList — chi phí thật của thêm, xoá, truy cập, đo bằng JMH để thấy vì sao LinkedList hiếm khi thắng.