Java có ba cách nói "danh sách này không sửa được", và cả ba đều ném UnsupportedOperationException khi bạn gọi add:

  unmodifiableList.add -> UnsupportedOperationException
  List.copyOf.add      -> UnsupportedOperationException
  List.of.add          -> UnsupportedOperationException

Nhìn giống hệt nhau. Nhưng một trong ba vẫn đổi nội dung sau lưng bạn.

Khung nhìn không phải bản sao

List<String> goc = new ArrayList<>(List.of("a", "b"));

List<String> khungNhin = Collections.unmodifiableList(goc);
List<String> banSao    = List.copyOf(goc);

goc.add("c");     // sửa bản gốc
  gốc              : [a, b, c]
  unmodifiableList : [a, b, c]   <- ĐỔI THEO!
  List.copyOf      : [a, b]      <- độc lập

Collections.unmodifiableList chỉ là một lớp bọc. Nó chặn add từ phía bạn, nhưng vẫn trỏ vào danh sách gốc — và ai còn giữ danh sách gốc thì vẫn sửa được, còn bạn thì thấy thay đổi đó.

Điều này khiến nó không an toàn để trả dữ liệu ra ngoài:

public List<Mon> getMonHang() {
    return Collections.unmodifiableList(danhSachMon);   // vẫn rò rỉ
}

Người gọi không sửa được trực tiếp, nhưng họ nhận một khung nhìn thay đổi theo trạng thái bên trong đối tượng của bạn — thứ mà họ không hề ngờ. Nếu họ giữ nó lại và đọc sau, dữ liệu đã khác.

Với sao chép phòng vệ ở bài lập trình phòng thủ, List.copyOf mới là câu trả lời đúng.

Vậy khi nào dùng unmodifiableList? Khi bạn muốn người nhận thấy thay đổi — ví dụ một khung nhìn chỉ đọc lên trạng thái đang cập nhật liên tục — và điều đó được ghi rõ trong tài liệu.

copyOf đủ thông minh để không sao chép thừa

  a == b ? true             (List.copyOf trên thứ đã bất biến trả về chính nó)
  copyOf(ArrayList) == a ?  false  (bản sao mới)

List.copyOf kiểm tra: nếu đầu vào đã là collection bất biến của JDK thì nó trả về chính đối tượng đó, không cấp phát gì thêm.

Nghĩa là bạn có thể gọi List.copyOf một cách thoải mái ở mọi ranh giới mà không lo chi phí — trường hợp thường gặp nhất (dữ liệu đã bất biến) là miễn phí.

Ba mức, chọn thế nào

Cách viết Sửa được? Độc lập với gốc? Nhận null?
new ArrayList<>(ds)
Collections.unmodifiableList(ds) không không
List.copyOf(ds) không không
List.of(a, b) không không
Arrays.asList(a, b) chỉ set không

Quy tắc của tôi:

Trả dữ liệu ra ngoàiList.copyOf.

Hằng số trong mãList.of.

Cần một khung nhìn sống, có chủ đíchCollections.unmodifiableList, kèm ghi chú.

Cần sửanew ArrayList<>.

Chuyện null

  List.of(null)         -> NullPointerException
  List.copyOf có null   -> NullPointerException
  unmodifiableList null -> được
  Arrays.asList(null)   -> được

Các API bất biến từ chối null hoàn toàn — cả lúc tạo lẫn khi gọi contains(null).

Đây là lựa chọn thiết kế có chủ ý của nhóm JDK: null trong collection gây nhiều lỗi hơn là tiện. Nó cũng có nghĩa là bạn không thể List.copyOf một danh sách đến từ CSDL nếu cột đó cho phép null — lọc trước, hoặc thay bằng giá trị mặc định.

Map.ofSet.of còn chặt hơn:

  Map.of trùng khoá    -> IllegalArgumentException
  Set.of trùng phần tử -> IllegalArgumentException

new HashSet<>(List.of("a","a")) thì âm thầm bỏ bớt, còn Set.of("a","a") thì ném lỗi ngay. Với dữ liệu cố định viết trong mã, trùng lặp gần như luôn là lỗi gõ nhầm — nên báo lỗi là đúng.

Một hạn chế cần biết: Map.of nhận tối đa 10 cặp. Nhiều hơn thì dùng Map.ofEntries:

Map.ofEntries(Map.entry("a", 1), Map.entry("b", 2), ...);

Set.of duyệt ra thứ tự khác nhau mỗi lần chạy

Đây là chi tiết bất ngờ nhất trong bài. Cùng một dòng System.out.println(Set.of(1,2,3,4,5)), chạy tám lần bằng tám tiến trình JVM riêng:

  [1, 2, 3, 4, 5]
  [2, 1, 5, 4, 3]
  [2, 3, 4, 5, 1]
  [2, 1, 5, 4, 3]
  [5, 4, 3, 2, 1]
  [1, 2, 3, 4, 5]
  [1, 2, 3, 4, 5]
  [1, 5, 4, 3, 2]

Set.ofMap.of cố tình xáo trộn thứ tự duyệt, dựa trên một giá trị ngẫu nhiên sinh ra lúc JVM khởi động.

Vì sao lại làm vậy? Để ngăn bạn phụ thuộc vào thứ tự. HashSet cũng không hứa thứ tự, nhưng nó ổn định trong thực tế, nên người ta viết mã (và test) dựa vào đó rồi hỏng về sau. Các collection bất biến mới chọn cách phá vỡ giả định đó ngay từ đầu.

Chi tiết này đáng nhớ khi gỡ lỗi: nếu một test lúc xanh lúc đỏ mà dữ liệu không đổi, hãy kiểm xem có chỗ nào đang so sánh chuỗi kết quả duyệt một Set.of hay Map.of không.

Cũng là lý do tôi phải chạy tới tám lần mới thấy — ba lần đầu tình cờ ra cùng kết quả.

toList() khác collect(toList())

  stream().toList().add  -> UnsupportedOperationException
  collect(toList()).add  -> được

Từ Java 16, Stream có phương thức toList() trả về danh sách bất biến. Còn collect(Collectors.toList()) — cách viết cũ — trả về một ArrayList sửa được.

Hai dòng trông gần giống nhau, hành vi khác hẳn. Với mã mới, stream().toList() vừa ngắn hơn vừa an toàn hơn. Cần sửa thì dùng collect(Collectors.toCollection(ArrayList::new)) cho rõ ý.

Lưu ý nhỏ: stream().toList() cho phép null trong luồng, khác với List.of. Đây là ngoại lệ trong họ API bất biến, và nó có lý do tương thích.

Các lớp tiện ích còn lại

Collections có vài thứ vẫn hữu ích:

Collections.emptyList()          // danh sách rỗng dùng chung, không cấp phát
Collections.singletonList(x)     // một phần tử, nhẹ hơn List.of một chút
Collections.nCopies(5, "x")      // 5 bản sao, không tốn bộ nhớ cho từng cái
Collections.frequency(ds, x)     // đếm số lần xuất hiện
Collections.disjoint(a, b)       // hai tập có giao nhau không
Collections.swap(ds, i, j)
Collections.shuffle(ds)

Collections.emptyList()List.of() tương đương nhau; cái đầu có từ Java 1.5, cái sau ngắn hơn.

Còn Collections.synchronizedList(...) thì nên tránh — nó khoá từng phương thức nên vẫn không an toàn cho thao tác ghép đôi (kiểm tra rồi mới thêm), và chậm. Cần nhiều luồng thì dùng đúng lớp trong java.util.concurrent.

Thử ba mươi giây

Tìm trong dự án của bạn một getter trả về Collections.unmodifiableList(...).

Rồi thử: lấy giá trị trả về ra một biến, gọi một phương thức làm thay đổi trạng thái đối tượng, rồi in lại biến đó. Nếu nội dung đổi, bạn vừa tìm ra một chỗ rò rỉ mà kiểu trả về đang che giấu.

Ngày mai ta bắt đầu phần Generics: tham số kiểu, biên trên, và cách đọc thông báo lỗi generics mà không hoảng.