Immutable collections trong Java: List.of, copyOf và unmodifiable

1. Tổng quan

Java có nhiều cách tạo một collection “không sửa được”: List.of, List.copyOf, Collections.unmodifiableList, Stream.toList()… Nhìn qua thì giống nhau, nhưng chúng khác nhau ở ba điểm: có sao chép dữ liệu không, có chấp nhận null không, và có phản ánh thay đổi của collection gốc không.

Bài này đi qua từng cách với ví dụ cụ thể. Đọc xong, bạn sẽ biết chọn đúng API, tránh NullPointerException bất ngờ và viết được defensive copy đúng chuẩn.

2. Vì sao cần immutable collection

Xem lớp Order sau:

import java.util.List;

public class Order {
    private final List<String> items;

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

    public List<String> getItems() {
        return items;
    }
}

Từ khóa final chỉ khóa tham chiếu, không khóa nội dung của list. Bên ngoài vẫn sửa được:

List<String> items = new ArrayList<>(List.of("Laptop"));
Order order = new Order(items);

items.add("Mouse");              // sửa list gốc
order.getItems().add("Keyboard"); // sửa qua getter

System.out.println(order.getItems());
[Laptop, Mouse, Keyboard]

Order không còn kiểm soát được trạng thái của chính nó. Khi object được chia sẻ giữa nhiều thread, lỗi kiểu này còn khó tìm hơn nữa. Immutable collection giải quyết vấn đề đó: không ai sửa được, nên bạn có thể chia sẻ thoải mái mà không cần đồng bộ.

3. List.of, Set.of và Map.of

Từ Java 9, các factory method of tạo collection không sửa được chỉ trong một dòng:

List<String> statuses = List.of("NEW", "PAID", "SHIPPED");
Set<String> currencies = Set.of("VND", "USD", "EUR");
Map<String, Integer> stock = Map.of("Laptop", 5, "Mouse", 20);

statuses.add("CANCELLED");
Exception in thread "main" java.lang.UnsupportedOperationException

Mọi phương thức thay đổi dữ liệu như add, remove, set, clear, sort hay put đều ném UnsupportedOperationException. Ngoại lệ được ném luôn luôn, kể cả khi thao tác thực ra không làm thay đổi gì, ví dụ remove một phần tử không tồn tại.

Map.of chỉ có overload tới 10 cặp key–value. Nếu cần nhiều hơn, bạn dùng Map.ofEntries:

Map<String, Integer> prices = Map.ofEntries(
        Map.entry("Laptop", 25_000_000),
        Map.entry("Mouse", 300_000)
);

3.1. Không chấp nhận null

Các collection tạo bằng of từ chối null ngay lúc khởi tạo:

List<String> names = List.of("An", null);
Exception in thread "main" java.lang.NullPointerException

Có một điểm dễ vấp hơn: ngay cả việc truy vấn null cũng ném lỗi.

List<String> names = List.of("An", "Bình");
System.out.println(names.contains(null));
Exception in thread "main" java.lang.NullPointerException

Với ArrayList, lời gọi trên trả về false. Nếu code cũ của bạn có kiểu list.contains(input) mà input có thể là null, việc đổi sang List.of sẽ làm code này vỡ.

3.2. Set và Map: trùng lặp và thứ tự

Set.of và Map.of không âm thầm bỏ qua phần tử trùng, mà ném lỗi:

Set<String> tags = Set.of("java", "spring", "java");
Exception in thread "main" java.lang.IllegalArgumentException: duplicate element: java

Thứ tự duyệt của Set.of và Map.of không được đảm bảo. Trong OpenJDK, thứ tự này còn thay đổi giữa các lần chạy JVM. Vì vậy test so sánh chuỗi toString() của một Set.of có thể lúc pass lúc fail. Nếu cần giữ thứ tự, bạn dùng LinkedHashSet hoặc LinkedHashMap rồi bọc lại, như ở mục 5.

4. List.copyOf: bản sao không sửa được

List.of phù hợp khi bạn biết trước phần tử. Còn khi đã có sẵn một collection, ví dụ tham số truyền vào constructor, bạn dùng List.copyOf (có từ Java 10):

List<String> source = new ArrayList<>(List.of("Laptop", "Mouse"));
List<String> copy = List.copyOf(source);

source.add("Keyboard");

System.out.println(source);
System.out.println(copy);
[Laptop, Mouse, Keyboard]
[Laptop, Mouse]

copy là một bản sao độc lập. Thay đổi trên source không ảnh hưởng tới nó. Giống List.of, copyOf cũng ném NullPointerException nếu collection nguồn chứa null.

Một chi tiết hay: nếu đầu vào đã là immutable list tạo bởi List.of hoặc List.copyOf, OpenJDK trả về luôn instance đó mà không sao chép lại:

List<String> a = List.of("x", "y");
System.out.println(List.copyOf(a) == a);
true

Nhờ vậy, gọi copyOf “cho chắc” gần như không tốn chi phí. Tuy nhiên đây là chi tiết cài đặt, nên bạn đừng viết logic dựa vào việc so sánh ==.

Tương tự còn có Set.copyOf và Map.copyOf. Riêng Set.copyOf không ném lỗi khi nguồn có phần tử trùng mà chỉ giữ một bản, vì nguồn có thể là một List.

5. Collections.unmodifiableList: chỉ là một view

Collections.unmodifiableList có từ Java 1.2 và hoạt động khác hẳn. Nó không sao chép, mà chỉ bọc collection gốc trong một lớp chặn ghi:

List<String> source = new ArrayList<>(List.of("Laptop", "Mouse"));
List<String> view = Collections.unmodifiableList(source);

source.add("Keyboard");

System.out.println(view);
view.add("Monitor");
[Laptop, Mouse, Keyboard]
Exception in thread "main" java.lang.UnsupportedOperationException

Bạn không sửa được qua view, nhưng ai giữ tham chiếu source vẫn sửa được, và thay đổi hiện ngay trên view. Vì vậy unmodifiable nghĩa là “không sửa được qua tham chiếu này”, chứ không có nghĩa là “bất biến”.

Hành vi view này lại hữu ích khi bạn muốn người gọi thấy dữ liệu mới nhất mà không cho họ sửa:

public class ShoppingCart {
    private final List<String> items = new ArrayList<>();

    public void addItem(String item) {
        items.add(item);
    }

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

Getter trả về view mà không cần sao chép, nên chi phí là O(1). Người gọi chỉ đọc được, còn ShoppingCart vẫn toàn quyền sửa bên trong.

Khác biệt cuối cùng: view này chấp nhận null. Lời gọi contains(null) sẽ trả về false thay vì ném lỗi.

6. Các lựa chọn khác và bảng so sánh

Có thêm vài API thường bị nhầm với nhau:

// Java 16+: kết quả không sửa được, CHẤP NHẬN null
List<String> a = Stream.of("A", null).toList();

// Java 10+: kết quả không sửa được, KHÔNG chấp nhận null
List<String> b = Stream.of("A", "B").collect(Collectors.toUnmodifiableList());

// Kích thước cố định nhưng VẪN set() được, ghi thẳng xuống mảng gốc
String[] arr = {"A", "B"};
List<String> c = Arrays.asList(arr);
c.set(0, "Z");
System.out.println(arr[0]); // Z

Bảng tóm tắt:

API Sao chép? null Thấy thay đổi của nguồn? Sửa được?
List.of(...) Không có nguồn Ném NPE Không áp dụng Không
List.copyOf(c) Có Ném NPE Không Không
Collections.unmodifiableList(l) Không (view) Cho phép Có Không (qua view)
stream.toList() Có Cho phép Không Không
Collectors.toUnmodifiableList() Có Ném NPE Không Không
Arrays.asList(arr) Không (view mảng) Cho phép Có Chỉ set

7. Bất biến nông (shallow immutability)

Tất cả các API trên chỉ khóa cấu trúc của collection, không khóa các phần tử bên trong:

public class OrderLine {
    private int quantity;

    public OrderLine(int quantity) { this.quantity = quantity; }
    public int getQuantity() { return quantity; }
    public void setQuantity(int quantity) { this.quantity = quantity; }
}

List<OrderLine> lines = List.of(new OrderLine(1));
lines.get(0).setQuantity(99);
System.out.println(lines.get(0).getQuantity());
99

Bạn không thêm hay bớt được phần tử, nhưng vẫn sửa được nội dung của từng phần tử. Muốn bất biến thật sự, chính các phần tử cũng phải bất biến. Cách gọn nhất là dùng record:

public record OrderLine(String product, int quantity) {}

Ghép lại, đây là mẫu defensive copy chuẩn cho một aggregate bất biến:

import java.util.List;

public record Order(String customer, List<OrderLine> lines) {
    public Order {
        lines = List.copyOf(lines);
    }
}

Compact constructor thay tham số lines bằng một bản sao không sửa được trước khi gán vào field. Accessor lines() trả về chính bản sao đó, nên bạn không cần sao chép lại ở getter. Nếu lines là null hoặc chứa null, object sẽ fail ngay lúc tạo, thay vì gây lỗi ở một chỗ xa hơn.

8. Kiểm thử hành vi

Thêm JUnit 5 và AssertJ vào pom.xml. Nếu dùng Spring Boot 3.x, spring-boot-starter-test đã có sẵn cả hai:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.11.3</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.3</version>
    <scope>test</scope>
</dependency>

Test dưới đây kiểm tra ba hành vi chính của Order và của view:

import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class OrderTest {

    @Test
    void shouldNotReflectChangesToSourceList() {
        List<OrderLine> source = new ArrayList<>();
        source.add(new OrderLine("Laptop", 1));

        Order order = new Order("An", source);
        source.add(new OrderLine("Mouse", 2));

        assertThat(order.lines()).hasSize(1);
    }

    @Test
    void shouldRejectModificationThroughAccessor() {
        Order order = new Order("An", List.of(new OrderLine("Laptop", 1)));

        assertThatThrownBy(() -> order.lines().add(new OrderLine("Mouse", 2)))
                .isInstanceOf(UnsupportedOperationException.class);
    }

    @Test
    void unmodifiableViewShouldReflectSourceChanges() {
        List<String> source = new ArrayList<>(List.of("A"));
        List<String> view = Collections.unmodifiableList(source);

        source.add("B");

        assertThat(view).containsExactly("A", "B");
    }
}

Test đầu xác nhận List.copyOf trong compact constructor đã tách Order khỏi list gốc. Test thứ hai đảm bảo accessor không mở đường sửa dữ liệu. Test cuối ghi lại hành vi view, để không ai nhầm unmodifiableList là một bản sao.

9. Lỗi hay gặp và khi nào không nên dùng

Lưu ý: List.of(...).contains(null) và indexOf(null) ném NullPointerException. Hãy kiểm tra null trước, hoặc dùng Objects.equals khi duyệt list.

Một số lỗi thường gặp khác:

  • Nghĩ unmodifiableList là bất biến. Nó chỉ là view. Nếu nguồn còn bị giữ ở đâu đó, dữ liệu vẫn đổi được. Muốn snapshot thì dùng List.copyOf.
  • Đổi collect(Collectors.toList()) sang toList() một cách máy móc. Collectors.toList() trả về ArrayList sửa được. Nếu phía sau có code gọi add hay sort trên kết quả, code đó sẽ ném UnsupportedOperationException lúc runtime.
  • Gọi sort tại chỗ. List.of(...).sort(...) luôn ném lỗi. Bạn cần sao chép sang ArrayList trước, hoặc dùng stream().sorted().toList().
  • Dựa vào thứ tự của Set.of hoặc Map.of trong log, JSON hay test.
  • Truyền dữ liệu từ nguồn ngoài có thể chứa null (JSON, kết quả từ database) thẳng vào List.copyOf. Hãy lọc trước, hoặc dùng stream().toList() nếu null là giá trị hợp lệ.

Có vài trường hợp không nên dùng immutable collection:

  • Collection cần thay đổi liên tục trong một vòng đời ngắn và cục bộ, ví dụ một buffer gom kết quả trong một method. ArrayList đơn giản và nhanh hơn.
  • Một số thư viện cần collection sửa được, ví dụ entity JPA với Hibernate, nơi field @OneToMany phải là collection sửa được để Hibernate quản lý. Khi đó bạn giữ ArrayList bên trong và chỉ trả về view ở getter.
  • Collection rất lớn được cập nhật thường xuyên. Mỗi lần “sửa” phải sao chép toàn bộ sẽ tốn O(n).

10. Kết luận

Ba API chính phục vụ ba mục đích khác nhau:

  • List.of tạo nhanh một danh sách cố định, không chứa null.
  • List.copyOf tạo một snapshot độc lập. Đây là lựa chọn mặc định cho defensive copy trong constructor hay record.
  • Collections.unmodifiableList là view chỉ đọc trên dữ liệu vẫn đang thay đổi. Nó rẻ, nhưng không bất biến.

Bạn cũng cần nhớ rằng mọi immutable collection trong JDK chỉ bất biến nông. Hãy kết hợp chúng với phần tử bất biến như record để có đối tượng thật sự an toàn khi chia sẻ giữa các thread.

javacollectionsimmutablejava-21clean-code