集合和泛型真正棘手的地方,往往不在语法本身,而在模块之间如何传递数据。本文围绕“方法返回的集合被调用方误修改”这一常见问题,讲清泛型不变性、PECS、不可变集合、防御性拷贝,以及工程实践中的常见陷阱。

在业务代码里,集合几乎无处不在:查询结果是 List,配置映射是 Map,权限集合是 Set。很多问题并不是集合不会用,而是集合越过方法、模块或线程边界之后,所有权变得模糊了。

一个典型场景是:服务对象内部维护了一组状态,方法把这个 List 直接返回,调用方为了方便调用了 clear()add(),结果内部状态被悄悄改变。代码没有报错,编译器也不会提醒,但后续行为已经失去控制。

这篇文章集中讨论一个具体问题:如何设计集合与泛型 API,让调用方能够正常使用数据,却不能意外破坏对象内部状态。

一、问题从一个直接返回开始

下面的代码看起来很自然:

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

public class UserGroup {
    private final List<String> members = new ArrayList<>();

    public void addMember(String name) {
        members.add(name);
    }

    public List<String> getMembers() {
        return members;
    }
}

调用方可能这样使用:

UserGroup group = new UserGroup();
group.addMember("Alice");
group.addMember("Bob");

List<String> members = group.getMembers();
members.clear();

System.out.println(group.getMembers().size()); // 0

UserGroup 的内部集合本来应该由它自己管理,但 getMembers() 返回了同一个对象的引用。调用方获得的不是一份数据,而是内部数据的操作权限。

这类问题通常有几个表现:

  • 某个查询方法调用后,缓存中的数据突然变少;
  • 测试之间互相污染,单独运行通过,整套运行失败;
  • 一个线程遍历集合时,另一个线程修改集合;
  • 调用方为了排序,直接对返回结果调用 sort(),导致内部顺序发生变化。

核心并不只是“不要返回可变集合”,而是要明确集合的所有权:谁创建、谁负责修改、谁只能读取。

二、泛型为什么不能随意替换

很多开发者第一次接触通配符时,会疑惑下面的代码为什么不能编译:

List<Integer> integers = new ArrayList<>();
List<Number> numbers = integers; // 编译错误

IntegerNumber 的子类型,但 List<Integer> 不是 List<Number> 的子类型。原因很简单:如果允许这样赋值,就可以通过 numbers 放入 Double,从而破坏原本只允许整数的集合。

numbers.add(3.14); // 如果前面的赋值成立,就会破坏 List<Integer>

Java 泛型默认是不变的,这种限制保护了类型安全。需要表达“可以读取某种父类型集合”时,应使用上界通配符:

public static double sum(List<? extends Number> values) {
    double result = 0.0;
    for (Number value : values) {
        result += value.doubleValue();
    }
    return result;
}

List<? extends Number> 可以接收 List<Integer>List<Long>List<BigDecimal>。但它主要用于读取,因为编译器无法确定其中具体是哪一种 Number

public static void readOnly(List<? extends Number> values) {
    Number first = values.get(0);
    // values.add(1); // 编译错误
}

如果方法的职责是向集合写入某个类型,则应使用下界通配符:

public static void addDefaults(List<? super Integer> values) {
    values.add(0);
    values.add(1);
}

List<? super Integer> 可以接收 List<Integer>List<Number>List<Object>。向其中写入 Integer 是安全的,但读取时只能当作 Object 使用。

这就是常说的 PECS 原则:Producer Extends,Consumer Super。集合作为生产者时用 extends,作为消费者时用 super。它不是为了让方法签名变复杂,而是为了准确表达调用方向。

三、为返回值建立清晰的不可变边界

回到 UserGroup,最直接的修复方式是返回一个不可修改的视图:

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

public class UserGroup {
    private final List<String> members = new ArrayList<>();

    public void addMember(String name) {
        members.add(name);
    }

    public List<String> getMembers() {
        return Collections.unmodifiableList(members);
    }
}

调用方仍然可以遍历和读取,但修改操作会抛出 UnsupportedOperationException

List<String> members = group.getMembers();
members.add("Charlie"); // 抛出 UnsupportedOperationException

不过,unmodifiableList 是不可修改视图,不是独立副本。如果 UserGroup 自己继续修改 members,调用方之前拿到的视图仍然会看到变化:

List<String> view = group.getMembers();
group.addMember("Charlie");
System.out.println(view); // 可以看到 Charlie

这是否正确,取决于 API 语义。如果返回值代表“当前状态的实时只读视图”,它是合适的;如果返回值代表“调用时的快照”,就应该进行防御性拷贝:

public List<String> getMembersSnapshot() {
    return new ArrayList<>(members);
}

在 Java 9 及以上,也可以写成:

public List<String> getMembersSnapshot() {
    return List.copyOf(members);
}

List.copyOf 返回不可修改集合,并且会复制容器结构。需要注意,它不允许元素为 null。如果业务允许 null,应使用 new ArrayList<>(members),或者先明确清理和校验数据。

不可变集合只保证集合结构不能被修改。若元素本身是可变对象,仍然可能通过元素修改内部状态:

public final class User {
    private String name;

    public User(String name) {
        this.name = name;
    }

    public void rename(String name) {
        this.name = name;
    }
}

即使返回 List.copyOf(users),调用方仍可以拿到 User 后调用 rename()。因此,真正的防御性设计还要考虑元素是否需要不可变,或者是否需要转换为专门的返回对象。

四、输入集合也不能默认信任

问题不仅存在于返回值,构造方法和业务方法接收集合时也一样:

public class PermissionSet {
    private final Set<String> permissions;

    public PermissionSet(Set<String> permissions) {
        this.permissions = Set.copyOf(permissions);
    }

    public Set<String> permissions() {
        return permissions;
    }
}

这里复制输入集合后,外部再修改原始 Set,也不会影响 PermissionSet。如果直接保存传入对象:

this.permissions = permissions;

那么调用方仍然保留修改内部状态的能力。

工程上可以把规则概括为:

  • 接收外部集合时,除非明确约定所有权转移,否则复制;
  • 返回内部集合时,根据语义选择只读视图、不可变副本或转换结果;
  • 集合元素如果可变,要判断是否需要深层复制或不可变对象;
  • API 文档中说明返回的是实时视图还是调用时快照。

五、常见坑与边界条件

1. Arrays.asList 不是普通的 ArrayList

List<String> names = Arrays.asList("A", "B");
names.set(0, "C"); // 可以
names.add("D");   // UnsupportedOperationException

它的大小固定,支持替换元素,但不支持增删。如果需要可变列表,应显式复制:

List<String> mutableNames = new ArrayList<>(Arrays.asList("A", "B"));

2. subList 不是独立集合

List<String> source = new ArrayList<>(List.of("A", "B", "C"));
List<String> part = source.subList(0, 2);
source.add("D");

subList 是原列表的视图。原列表结构变化后,子列表可能出现异常或表现不符合预期。需要独立结果时使用:

List<String> snapshot = new ArrayList<>(source.subList(0, 2));

3. 遍历时直接结构修改

for (String name : names) {
    if (name.isBlank()) {
        names.remove(name);
    }
}

这可能抛出 ConcurrentModificationException。单线程场景可以使用迭代器:

Iterator<String> iterator = names.iterator();
while (iterator.hasNext()) {
    if (iterator.next().isBlank()) {
        iterator.remove();
    }
}

也可以使用 removeIf

names.removeIf(String::isBlank);

这解决的是遍历过程中的修改问题,不等于解决多线程安全问题。多线程访问时,需要根据场景选择锁、并发集合或不可变快照。

4. 用可变对象作为 HashMap 的键

如果键参与 equalshashCode 的字段被修改,元素可能仍在桶中,却无法通过新的键值找到。作为哈希键的对象最好不可变,或者至少保证参与相等性判断的字段在放入集合后不再变化。

六、实践中的设计选择

对于查询接口和领域对象,可以优先采用以下策略:

public final class OrderView {
    private final List<String> itemNames;

    public OrderView(List<String> itemNames) {
        this.itemNames = List.copyOf(itemNames);
    }

    public List<String> itemNames() {
        return itemNames;
    }
}

如果方法需要填充调用方提供的集合,可以使用泛型下界:

public static <T> void copyNonNull(
        Iterable<? extends T> source,
        Collection<? super T> target) {
    for (T value : source) {
        if (value != null) {
            target.add(value);
        }
    }
}

这个签名表达得很准确:从 source 读取 T,向 target 写入 T。同时,使用 IterableCollection 而不是过度限定为 List,能减少调用方不必要的约束。

最后,集合类型也应表达意图:需要去重时使用 Set,需要按键查找时使用 Map,只需要顺序遍历时可以接收 CollectionIterable。不要因为“以后可能会用到”就把所有参数都写成 List

总结

集合和泛型的工程化用法,本质上是在管理三件事:类型边界、修改权限和对象所有权。

泛型不变性保证了类型安全;extendssuper 用来描述读取与写入方向;防御性拷贝解决外部对象对内部状态的意外影响;不可变集合则让数据在模块边界上更容易被信任。

写集合 API 时,可以先问自己三个问题:这个集合由谁负责修改?调用方拿到的是实时视图还是快照?集合里的元素是否也允许被修改?这些问题回答清楚之后,具体选择 List.copyOfCollections.unmodifiableListnew ArrayList 还是专门的返回对象,就不再只是语法偏好,而会成为有明确依据的设计决定。

最后修改:2026 年 08 月 17 日
如果觉得我的文章对你有用,请随意赞赏