Java OO & Functional/Immutable Collections — List.of, Map.copyOf và cạm bẫy unmodifiableList
24/38
Bài 24 / 38~20 phútGenerics & CollectionsMiễn phí lượt xem

Immutable Collections — List.of, Map.copyOf và cạm bẫy unmodifiableList

Ba cách tạo immutable collection trong Java 9+, shallow vs deep immutability, defensive copy pattern, và vì sao Collections.unmodifiableList không phải immutable thực sự.

TL;DR: Java có 3 cách chính tạo collection "bất biến": List.of(...) (true immutable, Java 9, JEP 269), List.copyOf(existing) (defensive copy rồi immutable), và Collections.unmodifiableList(arr) (chỉ là view chỉ-đọc trên list gốc — list gốc thay đổi, view thấy ngay). Sự khác biệt quan trọng: hai cách đầu bảo vệ bạn hoàn toàn sau khi tạo, cách thứ ba là bẫy kinh điển trong production. Bài này đi sâu vào cơ chế, bộ nhớ, và pattern defensive copy để bảo vệ state nội bộ class.

1. Scenario — bug "bất biến" nhưng vẫn thay đổi

Một developer viết class OrderSummary trả về danh sách item đã được "bảo vệ":

public class OrderSummary {
    private List<String> items;

    public OrderSummary(List<String> items) {
        this.items = items;
    }

    public List<String> getItems() {
        return Collections.unmodifiableList(this.items);
    }
}

Code review đồng ý: getItems() trả về unmodifiable view, caller không thể add hay remove. Test pass. Nhưng trong production:

List<String> items = new ArrayList<>(List.of("item1", "item2", "item3"));
OrderSummary summary = new OrderSummary(items);

System.out.println(summary.getItems());  // [item1, item2, item3]

items.add("item4");  // caller sua list goc SAU khi truyen vao

System.out.println(summary.getItems());  // [item1, item2, item3, item4] -- thay doi!

OrderSummary không bao giờ sửa items. Nhưng caller vẫn giữ tham chiếu đến list gốc và sửa được. unmodifiableList chỉ ngăn caller sửa qua wrapper — không ngăn ai có reference gốc sửa trực tiếp.

Đây là aliasing bug — 2 tham chiếu cùng trỏ 1 object, thay đổi qua tham chiếu nào cũng thấy qua tham chiếu kia.

2. Collections.unmodifiableList — view, không phải immutable

Collections.unmodifiableList(List<T> list) (Java 1.2) trả về một view chỉ-đọc (read-only wrapper) bao quanh list gốc. Mọi thao tác write (add, remove, set, clear) ném UnsupportedOperationException. Nhưng nó không copy dữ liệu — nó chỉ là lớp bọc mỏng.

flowchart LR
    A["caller reference<br/>(ArrayList)"] -- "add/remove OK" --> B["ArrayList backing data"]
    C["unmodifiable wrapper"] -- "throw UOE on write" --> C
    C -- "read-through" --> B
    B -- "shared state" --> B

Hệ quả: nếu list gốc bị thay đổi bởi bất kỳ ai còn giữ tham chiếu gốc, wrapper "bất biến" sẽ phản ánh thay đổi đó ngay lập tức. Đây KHÔNG phải immutable — chỉ là "tôi không được phép sửa, nhưng người khác vẫn sửa được".

3. List.of — true immutable (Java 9, JEP 269)

JEP 269 (Java 9, "Convenience Factory Methods for Collections") giới thiệu các factory method List.of(...), Set.of(...), Map.of(...) tạo collection thực sự bất biến:

List<String> names = List.of("An", "Binh", "Cuong");

names.add("Dung");     // UnsupportedOperationException
names.set(0, "X");     // UnsupportedOperationException
names.remove(0);       // UnsupportedOperationException
names.clear();         // UnsupportedOperationException

Đặc điểm của List.of:

  • Không cho phép phần tử null — ném NullPointerException ngay khi tạo.
  • Thứ tự phần tử được bảo toàn.
  • Bên trong JDK dùng array nhỏ, compact representation (không phải ArrayList).
  • Immutable thực sự — không có backing mutable collection nào bên dưới.
  • Thread-safe cho read (nhiều thread đọc đồng thời an toàn).
// Khong cho phep null
List<String> list = List.of("a", null, "b");  // NullPointerException!

// Set va Map tuong tu
Set<Integer> nums = Set.of(1, 2, 3);
Map<String, Integer> scores = Map.of("An", 95, "Binh", 87);
Compact representation bên trong

List.of(1) tạo instance List12 (tối ưu cho 1 phần tử). List.of(1, 2) tạo List12 khác. List.of(1, 2, 3, ...) tạo ListN dùng array cố định. JDK tối ưu memory bằng cách không tạo ArrayList wrapper thừa. Từ Java 11, các class này có tên như ImmutableCollections$List12, có thể xem bằng list.getClass().getName().

4. List.copyOf — defensive copy + immutable

List.copyOf(Collection<? extends E> coll) (Java 10) tạo bản copy độc lập rồi làm immutable:

List<String> mutable = new ArrayList<>(List.of("a", "b", "c"));
List<String> immutable = List.copyOf(mutable);

mutable.add("d");  // sua list goc

System.out.println(mutable);    // [a, b, c, d]
System.out.println(immutable);  // [a, b, c] -- KHONG thay doi

immutable là bản copy độc lập tại thời điểm gọi copyOf — không còn liên kết với mutable.

Tối ưu thông minh: nếu đầu vào đã là immutable collection (được tạo bởi các factory method như List.of(), List.copyOf(), v.v.), List.copyOf KHÔNG copy mà trả về chính xác tham chiếu của đối tượng đó.

Cơ chế hoạt động bên dưới của JDK:

  • JDK thực hiện dynamic type checking (kiểm tra kiểu động) ở thời điểm runtime.
  • Nó kiểm tra xem collection truyền vào có phải instance của một abstract class nội bộ của JDK hay không (cụ thể là java.util.ImmutableCollections.AbstractImmutableList — kế thừa AbstractImmutableCollection; mọi list immutable tạo từ factory đều extends class này).
  • Nếu đối tượng truyền vào thỏa mãn điều kiện (tức là đã bất biến sẵn), JDK sẽ cast kiểu trực tiếp và trả về chính tham chiếu đó (return (List<E>) coll;) với độ phức tạp thời gian là O(1) mà không cần tốn bất kỳ chi phí phân bổ bộ nhớ (memory allocation) hay sao chép mảng (array copying) nào.
  • Nếu không thỏa mãn, JDK mới thực hiện defensive copy thông thường bằng cách copy các phần tử sang một mảng mới.
List<String> original = List.of("a", "b");
List<String> copy = List.copyOf(original);

System.out.println(original == copy);  // true -- same object, no copy done (O(1) complexity!)

5. So sánh 3 cách

List.of(...)List.copyOf(existing)Collections.unmodifiableList(arr)
Immutable thực sự?Không (view)
Copy data?Không (tạo mới từ args)Có (defensive copy)Không (wrap existing)
Ảnh hưởng từ list gốc?N/A (không có list gốc)Không (đã copy)Có (thấy thay đổi)
Cho phép null?KhôngKhôngCó (nếu gốc có null)
Khi nào dùngTạo constant list/set/map trong codeNhận input từ ngoài, giữ snapshotCần expose read-only view (vẫn sync với gốc)
Memory overheadThấp (compact internal)O(n) copyGần như 0 (wrapper only)
Thread-safetySafe (immutable)Safe (immutable)Không safe nếu gốc bị sửa từ thread khác

6. Pitfall 1 — Shallow immutability: list immutable nhưng object bên trong vẫn mutable

List.ofList.copyOf ngăn thay đổi cấu trúc của list (thêm/xoá phần tử). Nhưng nếu phần tử bên trong là mutable object, nội dung của chúng vẫn thay đổi được:

List<int[]> list = List.of(new int[]{1, 2, 3}, new int[]{4, 5, 6});

list.add(new int[]{7});   // UnsupportedOperationException -- cau truc bi bao ve
list.get(0)[0] = 99;      // OK -- mang int ben trong van mutable!

System.out.println(list.get(0)[0]);  // 99 -- da bi thay doi

Đây là shallow immutability — chỉ immutable ở tầng cấu trúc list, không đảm bảo immutability của object bên trong.

Deep immutability yêu cầu tất cả object bên trong cũng phải immutable. Ví dụ List<String> với String là immutable → truly deep immutable. List<LocalDate> cũng vậy. Nhưng List<List<String>> inner lists vẫn mutable nếu không dùng List.of:

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

nested.add(new ArrayList<>());  // UOE -- outer list bao ve
nested.get(0).add("c");         // OK -- inner list van mutable

Fix: wrap inner list cũng bằng List.of hoặc List.copyOf:

List<List<String>> nested = List.of(List.copyOf(inner));
nested.get(0).add("c");  // UOE -- inner list cung immutable

7. Pitfall 2 — Defensive copy khi nhận input

Pattern lỗi phổ biến nhất trong Java là nhận collection từ caller mà không copy:

// SAI -- caller van co the sua items sau khi truyen vao
public class ShoppingCart {
    private final List<String> items;

    public ShoppingCart(List<String> items) {
        this.items = items;           // alias bug
    }

    public List<String> getItems() {
        return Collections.unmodifiableList(items);  // khong du
    }
}

Defensive copy là kỹ thuật tạo bản sao tại điểm nhận (constructor hoặc setter) để đứt quan hệ với caller:

// DUNG -- defensive copy + immutable
public class ShoppingCart {
    private final List<String> items;

    public ShoppingCart(List<String> items) {
        this.items = List.copyOf(items);  // copy + immutable ngay tai day
    }

    public List<String> getItems() {
        return items;  // da immutable, expose truc tiep la an toan
    }
}

Với List.copyOf trong constructor:

  • Bản copy độc lập tạo ra ngay lập tức — caller thay đổi list gốc về sau không ảnh hưởng.
  • List đã immutable — không cần thêm unmodifiableList khi trả về.
  • Nếu caller truyền vào List.of(...) (đã immutable), copyOf thông minh không copy lại.
Effective Java item 50: Lỗ hổng TOCTOU & Nguyên tắc Copy trước, Validate sau

Effective Java item 50 (Joshua Bloch, 3rd edition): "Make defensive copies when needed".

Trong môi trường đa luồng (multi-threaded), nếu bạn thực hiện kiểm tra tính hợp lệ (validate) trước rồi mới tiến hành copy (hoặc chỉ gán reference dùng Collections.unmodifiableList), bạn sẽ mở ra một lỗ hổng bảo mật nghiêm trọng gọi là TOCTOU (Time-of-check Time-of-use).

Sơ đồ chuỗi hành động TOCTOU (T1 -> T2 -> T3)

Dưới đây là kịch bản lỗi đồng thời xảy ra khi Thread 1 (Constructor) và Thread 2 (Caller hoặc thread khác) cùng truy cập vào danh sách gốc:

sequenceDiagram
    autonumber
    actor T1 as Thread 1 (Constructor / Owner)
    actor T2 as Thread 2 (Caller / Concurrent Thread)
    Note over T1: Nhận Mutable List gốc từ caller
    T1->>T1: [T1 - Time of Check]: Validate list<br/>(Kiểm tra không chứa phần tử null - OK!)
    Note over T2: Đang nắm giữ reference tới List gốc
    T2->>T2: [T2 - Mutate State]: Sửa đổi dữ liệu list gốc<br/>(Chèn thêm null hoặc dữ liệu độc hại)
    T1->>T1: [T3 - Time of Use]: Lưu/Gán reference<br/>(this.items = list hoặc wrapper unmodifiableList)
    Note over T1: HỆ QUẢ:<br/>Object bên trong Constructor chứa phần tử bẩn (null)<br/>dù đã kiểm tra hợp lệ thành công trước đó!

Tại sao giải pháp phải là "Copy trước, Validate sau"?

Để ngăn chặn hoàn toàn lỗ hổng TOCTOU, quy trình bắt buộc phải đảo ngược:

  1. Copy trước: Ngay khi nhận được tham chiếu từ bên ngoài, lập tức thực hiện copy dữ liệu (List.copyOf hoặc tạo bản copy mới) sang một vùng nhớ độc lập mà chỉ đối tượng của bạn nắm giữ.
  2. Validate sau: Thực hiện mọi kiểm tra tính hợp lệ trên bản sao mới này chứ không phải trên collection gốc do caller truyền vào.

Vì bản sao này đã được cô lập hoàn toàn, không có thread bên ngoài nào có thể thay đổi dữ liệu của nó trong khoảng thời gian giữa "check" và "use", đảm bảo tuyệt đối an toàn và tính toàn vẹn dữ liệu.

8. Map.of, Map.entry, và giới hạn 10 cặp

Map.of có các overload cụ thể cho tối đa 10 cặp key-value. Tại sao JDK lại thiết kế nạp chồng 10 method cụ thể thay vì chỉ dùng một method varargs?

Lý do thiết kế (Design Decisions):

  1. Tránh Array Allocation Overhead (Quá tải phân bổ mảng): Trong Java, cơ chế varargs (Type... args) bản chất hoạt động bằng cách yêu cầu compiler tự động khởi tạo một mảng mới (new Type[]) để bọc các đối số truyền vào tại thời điểm gọi phương thức. Đối với các Map nhỏ (từ 1 đến 10 phần tử - chiếm phần lớn trường hợp tạo map trực tiếp trong code), việc gọi liên tục varargs sẽ liên tục phân bổ các mảng tạm thời trên vùng nhớ Heap, làm tăng áp lực dọn rác đáng kể cho Garbage Collector (GC). Overload cụ thể từ 1 đến 10 tham số giúp tránh hoàn toàn việc phân bổ mảng vô tội vạ này.
  2. Tránh Lỗi/Cảnh báo Varargs kiểu Generic (Heap Pollution): Java generics bị xóa kiểu (type erasure) khi runtime. Nếu dùng varargs với kiểu generic (ví dụ: Map.Entry<K, V>...), compiler sẽ không thể đảm bảo an toàn kiểu dữ liệu tuyệt đối ở mức runtime khi khởi tạo mảng generic, dẫn đến hiện tượng Heap Pollution (ô nhiễm vùng nhớ heap). Điều này ép lập trình viên phải sử dụng annotation @SafeVarargs hoặc chịu đựng các cảnh báo unchecked phiền phức. Việc nạp chồng các tham số cụ thể loại bỏ hoàn toàn rủi ro này.
// Den 10 cap -- dung Map.of (cuc ky type-safe va toi uu memory)
Map<String, Integer> scores = Map.of(
    "An", 95,
    "Binh", 87,
    "Cuong", 72
);

// Qua 10 cap -- dung Map.ofEntries + Map.entry
Map<String, Integer> bigMap = Map.ofEntries(
    Map.entry("An", 95),
    Map.entry("Binh", 87),
    Map.entry("Cuong", 72),
    Map.entry("Dung", 88),
    Map.entry("Em", 90),
    Map.entry("Phong", 76),
    Map.entry("Giang", 83),
    Map.entry("Hung", 91),
    Map.entry("Lan", 79),
    Map.entry("Minh", 85),
    Map.entry("Nam", 94)   // phan tu thu 11 -- ok voi ofEntries
);

Lưu ý quan trọng với Map.of:

  • Không cho phép key trùng nhau — ném IllegalArgumentException ngay khi tạo (không phải lúc lookup).
  • Thứ tự iteration của Map.ofkhông xác định (và cố tình random hóa giữa các JVM run để phát hiện code phụ thuộc thứ tự).
  • Map.copyOf(existingMap) tương tự List.copyOf — tạo bản copy độc lập + immutable.
Map<String, Integer> mutable = new HashMap<>();
mutable.put("a", 1);
mutable.put("b", 2);

Map<String, Integer> immutable = Map.copyOf(mutable);
mutable.put("c", 3);  // sua map goc

System.out.println(immutable.containsKey("c"));  // false -- khong bi anh huong

9. Set.of và thứ tự không đảm bảo

Set<String> set = Set.of("banana", "apple", "cherry");

set.add("grape");    // UnsupportedOperationException
set.contains("apple"); // true

// Khong cho phep phan tu trung
Set<String> dup = Set.of("a", "b", "a");  // IllegalArgumentException!

Set.of cũng không cho phép null và ném IllegalArgumentException ngay khi tạo nếu có phần tử trùng.

10. Khi nào dùng unmodifiableList thay vì List.copyOf?

Mặc dù unmodifiableList có nguy cơ aliasing, nó vẫn có use case hợp lệ:

Dùng unmodifiableList khi:

  • Bạn cần expose view read-only của một collection đang sống — caller cần thấy thay đổi mới nhất khi gốc cập nhật (ví dụ: cache nội bộ expose ra ngoài nhưng vẫn tự cập nhật theo giờ).
  • Copy O(n) là quá tốn cho collection lớn và bạn chấp nhận view semantics.

Dùng List.copyOf khi:

  • Bạn muốn snapshot tại thời điểm cụ thể — không bị ảnh hưởng bởi thay đổi về sau.
  • Truyền collection ra ngoài class mà không muốn caller hay source thay đổi ảnh hưởng lẫn nhau.

Dùng List.of khi:

  • Tạo constant/literal collection trong code — giá trị cố định không bao giờ thay đổi.

11. 📚 Deep Dive

  • JEP 269 — "Convenience Factory Methods for Collections" (Java 9) — openjdk.org/jeps/269 — motivation, design decisions, và lý do giới hạn 10 cặp cho Map.of.
  • List.of Javadoc SE 21docs.oracle.com/.../List.html#of(E...) — spec đầy đủ về null prohibition, immutability, serialization.
  • Collections.unmodifiableList Javadocdocs.oracle.com/.../Collections.html#unmodifiableList — quote rõ "changes to the returned list 'write through' to the specified list".
  • Effective Java item 17 (Joshua Bloch) — "Minimize mutability" — lý do tại sao immutable objects là mặc định tốt hơn trong OOP.
  • Effective Java item 50 — "Make defensive copies when needed" — pattern, timing (copy trước validate), và TOCTOU vulnerability.

12. Self-check

Tự kiểm tra
Q1
Vì sao `Collections.unmodifiableList` không đủ để bảo vệ state nội bộ của class?
`Collections.unmodifiableList` trả về wrapper ngăn sửa đổi qua wrapper đó. Nhưng list gốc bên dưới vẫn có thể bị sửa bởi bất kỳ ai còn giữ tham chiếu đến nó. Trong constructor `this.items = items`, `items` là tham chiếu đến list của caller — caller vẫn có biến gốc, vẫn có thể gọi `add`/`remove` sau đó. `unmodifiableList` chỉ ngăn người nhận wrapper sửa, không ngăn người có gốc sửa. Để bảo vệ thực sự: dùng `List.copyOf(items)` trong constructor để tạo bản copy độc lập ngay lập tức.
Q2
`List.of("a", "b", "c")` và `List.copyOf(List.of("a", "b", "c"))` — kết quả có phải cùng object không? Vì sao?
Có thể là cùng object. `List.copyOf` tối ưu: nếu collection đầu vào đã là immutable collection (instance của `ImmutableCollections`), nó trả về nguyên đối tượng đó thay vì copy. `List.of("a", "b", "c")` tạo immutable collection → `List.copyOf` nhận ra → trả về cùng reference → `original == copy` là `true`. Tối ưu này an toàn vì collection đã immutable: không thể có aliasing bug. Trong code, `List.copyOf` luôn đúng để gọi khi nhận input không rõ nguồn gốc — nếu input đã immutable thì không tốn thêm chi phí.
Q3
Đoạn code sau có vấn đề gì?
List<List<String>> data = List.of(new ArrayList<>(List.of("a","b"))); data.get(0).add("c");
`List.of` chỉ ngăn thay đổi **cấu trúc outer list** (thêm/xoá phần tử ở level ngoài). Inner `ArrayList` vẫn là mutable object — `data.get(0).add("c")` gọi `add` trên `ArrayList`, không phải trên outer `List.of`, nên không ném `UnsupportedOperationException`. Kết quả: `data.get(0)` là `["a", "b", "c"]`. Đây là shallow immutability. Để deep immutable: `List.of(List.of("a", "b"))` hoặc `List.of(List.copyOf(inner))`.
Q4
`Map.of("key1", 1, "key1", 2)` — compile error hay runtime exception? Vì sao?
Runtime exception (`IllegalArgumentException`) tại thời điểm tạo map. Compiler không phát hiện duplicate key vì các key là runtime values (dù có thể là string literal — Java không có compile-time uniqueness check cho method arguments). JDK implement `Map.of` với internal validation: khi tạo immutable map, nó kiểm tra tất cả key có unique không. Nếu có duplicate, ném `IllegalArgumentException` ngay tại `Map.of(...)` call, trước khi map được gán cho variable. Giống `Set.of("a", "a")` — cũng ném lúc tạo chứ không phải lúc dùng.
Q5
Bạn nhận `List<Order>` từ caller trong constructor. Khi nào nên validate input (kiểm tra null, size...) — trước hay sau khi `List.copyOf`? Vì sao?
Validate **sau** khi `List.copyOf`. Lý do: trong môi trường multi-thread, nếu validate trước rồi copy, caller có thể sửa list trong khoảng thời gian giữa validate và copy (TOCTOU — Time-of-check Time-of-use). Ví dụ: validate pass vì list không có null, caller thêm null vào, rồi copy — bạn nhận list có null mặc dù đã check. Copy trước → snapshot tại thời điểm bạn kiểm soát → validate trên snapshot → không bị race. Effective Java item 50 ghi rõ pattern này. Với single-thread, thứ tự ít quan trọng hơn nhưng chuẩn mực vẫn là copy-then-validate.

Bài tiếp theo

Bài 09 sẽ đi sâu bounded type parameters và generic invariance — vì sao List<Integer> không phải subtype của List<Number> mặc dù Integer extends Number, và cách dùng wildcard để làm việc với hierarchy kiểu.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên