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)结合使用,以获得真正在线程间安全共享的对象。