Java中的不可变集合:List.of、copyOf和unmodifiable

1. 概述

Java有多种方式创建“不可修改的”集合:List.of、List.copyOf、Collections.unmodifiableList、Stream.toList()……乍看起来一样,但它们在三个方面有所不同:是否复制数据、是否接受null,以及是否反映原集合的变化。

本文将逐一介绍每种方式,并提供具体示例。读完之后,你将学会选择正确的API,避免意外的NullPointerException,以及正确编写防御性复制。

2. 为什么需要不可变集合

看看下面的Order类:

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;
    }
}

关键字final只锁定引用,不锁定list的内容。外部仍然可以修改:

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

items.add("Mouse");              // 修改原list
order.getItems().add("Keyboard"); // 通过getter修改

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

Order不再能控制自己的状态。当对象在多个线程之间共享时,这类错误更难发现。不可变集合解决了这个问题:没有人能修改它,所以你可以放心共享,无需同步。

3. List.of、Set.of和Map.of

从Java 9开始,factory方法of可以在一行代码中创建不可修改的集合:

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

所有修改数据的方法,如add、remove、set、clear、sort或put,都会抛出UnsupportedOperationException。异常总是被抛出,即使操作实际上没有改变任何东西,例如移除一个不存在的元素。

Map.of只有到10个键值对的重载。如果需要更多,你可以使用Map.ofEntries:

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

3.1. 不接受null

用of创建的集合在初始化时立即拒绝null:

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

有一个更容易踩坑的地方:即使只是查询null也会抛出错误。

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

使用ArrayList的话,上面的调用会返回false。如果你的旧代码有类似list.contains(input)的地方,其中input可能是null,那么切换到List.of会导致这段代码出错。

3.2. Set和Map:重复和顺序

Set.of和Map.of不会悄悄忽视重复元素,而是抛出错误:

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

Set.of和Map.of的遍历顺序不被保证。在OpenJDK中,这个顺序甚至在不同的JVM运行之间也会变化。因此,对Set.of的toString()进行字符串比较的测试可能时而通过时而失败。如果需要保持顺序,你可以使用LinkedHashSet或LinkedHashMap,然后包装一下,如第5节所示。

4. List.copyOf:不可修改的副本

当你提前知道元素时,List.of很合适。而当已经有一个集合时,例如传入构造函数的参数,你可以使用List.copyOf(从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是一个独立的副本。对source的更改不会影响它。和List.of一样,如果源集合包含null,copyOf也会抛出NullPointerException。

有个有趣的细节:如果输入已经是由List.of或List.copyOf创建的不可变列表,OpenJDK会直接返回该实例,而不重新复制:

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

因此,保险起见调用copyOf几乎没有成本。不过这是实现细节,所以你不应该编写依赖于==比较的逻辑。

类似地还有Set.copyOf和Map.copyOf。特别的是,Set.copyOf在源中有重复元素时不会抛出错误,而只是保留一份副本,因为源可能是一个List。

5. Collections.unmodifiableList:只是一个视图

Collections.unmodifiableList从Java 1.2开始存在,其工作方式完全不同。它不复制,而是用一个阻止写入的包装器包裹原集合:

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

你无法通过view进行修改,但拥有source引用的任何人仍然可以修改它,更改会立即在view上显示。因此unmodifiable的意思是“通过这个引用无法修改”,而不是“不可变”。

当你想要调用者看到最新数据但不允许他们修改时,这种视图行为很有用:

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返回视图而无需复制,所以成本是O(1)。调用者只能读取,而ShoppingCart保留了内部的完全修改权限。

最后的区别是:这个视图接受null。contains(null)的调用会返回false而不是抛出错误。

6. 其他选择和比较表

还有几个API经常被搞混:

// Java 16+: 结果不可修改,接受null
List<String> a = Stream.of("A", null).toList();

// Java 10+: 结果不可修改,不接受null
List<String> b = Stream.of("A", "B").collect(Collectors.toUnmodifiableList());

// 大小固定,但仍可调用set(),直接写入原数组
String[] arr = {"A", "B"};
List<String> c = Arrays.asList(arr);
c.set(0, "Z");
System.out.println(arr[0]); // Z

总结表:

API 复制? null 能看到源的变化? 可修改?
List.of(...) 无源 抛NPE 不适用 否
List.copyOf(c) 是 抛NPE 否 否
Collections.unmodifiableList(l) 否(视图) 允许 是 否(通过视图)
stream.toList() 是 允许 否 否
Collectors.toUnmodifiableList() 是 抛NPE 否 否
Arrays.asList(arr) 否(数组视图) 允许 是 仅set

7. 浅层不可变性(shallow immutability)

上述所有API只锁定集合的结构,不锁定内部的元素:

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

你无法添加或删除元素,但仍然可以修改每个元素的内容。要真正实现不可变,元素本身也必须不可变。最简洁的方法是使用record:

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

综合起来,这是一个标准的防御性复制模式,用于不可变聚合:

import java.util.List;

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

Compact构造函数在赋值给字段之前,用一个不可修改的副本替换参数lines。accessor lines()返回这个副本本身,所以你无需在getter中重新复制。如果lines是null或包含null,对象会在创建时立即失败,而不是在远处引发错误。

8. 测试行为

将JUnit 5和AssertJ添加到pom.xml。如果使用Spring Boot 3.x,spring-boot-starter-test已经包含了这两个:

<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>

下面的测试检查Order和视图的三个主要行为:

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");
    }
}

第一个测试确认compact构造函数中的List.copyOf已经将Order与原始列表分离。第二个测试确保accessor拒绝数据修改。最后一个测试记录了视图的行为,以防任何人误认为unmodifiableList是一个副本。

9. 常见错误和何时不应该使用

注意: List.of(...).contains(null)和indexOf(null)会抛出NullPointerException。请先检查null,或在遍历列表时使用Objects.equals。

其他常见错误:

  • 认为unmodifiableList是不可变的。 它只是一个视图。如果源仍然被某处持有,数据仍然可以改变。如果需要快照,请使用List.copyOf。
  • 机械地将collect(Collectors.toList())改为toList()。 Collectors.toList()返回可修改的ArrayList。如果后续代码在结果上调用add或sort,会在运行时抛出UnsupportedOperationException。
  • 就地调用sort。 List.of(...).sort(...)总是抛出错误。你需要先复制到ArrayList,或者使用stream().sorted().toList()。
  • 依赖Set.of或Map.of的顺序 在日志、JSON或测试中。
  • 直接将可能包含null的外部数据(JSON、数据库结果)传入List.copyOf。 请先过滤,或者如果null是有效值,请使用stream().toList()。

有几种情况不应该使用不可变集合:

  • 集合需要在短期和局部生命周期内频繁更改,例如在方法内聚集结果的缓冲区。ArrayList更简单、更快。
  • 某些库需要可修改的集合,例如使用Hibernate的JPA实体,其中@OneToMany字段必须是可修改的集合以供Hibernate管理。在这种情况下,你在内部保持ArrayList,只在getter中返回视图。
  • 非常大的集合被频繁更新。每次“修改”都必须复制整体会耗费O(n)的成本。

10. 结论

三个主要API用于三个不同的目的:

  • List.of快速创建一个固定列表,不包含null。
  • List.copyOf创建独立的快照。这是构造函数或record中防御性复制的默认选择。
  • Collections.unmodifiableList是一个只读视图,用于仍在改变的数据。它很便宜,但不是不可变的。

你也要记住,JDK中所有不可变集合只是浅层不可变。将它们与不可变元素(如record)结合使用,以获得真正在线程间安全共享的对象。

javacollectionsimmutablejava-21clean-code