返回的列表,谁还能改

商品目录查询返回 List<Item>,调用方只允许读取。把内部列表套进 Collections.unmodifiableList 后,调用方执行 add 会抛异常,但后台导入程序仍能通过原列表追加商品,调用方下次读取也会看到新增项。这个接口提供的是随源变化的只读视图;若需求是“保留查询当时的商品集合”,这层包装没有完成任务。

判断保护范围,需要沿着三种引用分别检查:列表变量指向哪个容器,容器保存哪些元素引用,元素内部还指向哪些可变状态。列表不可写只回答其中一部分问题。第 01 篇讨论输入中缺失值的含义;这里把同一份商品数据送入不同容器,观察哪些变化仍能传播。

构造方式 源列表增删替换是否传播 返回列表能否增删替换 元素是否共享
Collections.unmodifiableList(source) 是 否 是
new ArrayList<>(source) 否 是 是
ImmutableList.copyOf(source) 否 否 是

表中“传播”均指构造完成后的操作;构造期间源集合不被其他线程修改。Guava 使用 33.5.0-jre,基础代码遵守 Java 8 语法与 API;现代 JDK 对照单独讨论。

视图保护调用入口

JDK 8 的 unmodifiableList 文档用 “read through” 描述查询行为:包装层读取底层列表,修改入口则抛出 UnsupportedOperationException。因此,source.set(0, replacement) 即使没有改变列表长度,仍会改变视图在索引 0 读到的对象。把“只有增删才算修改”当成判断标准,会漏掉这种替换。

包装后的对象和源列表是两条访问路径。view.add 被拒绝,不能证明 source.add 被拒绝;让源列表继续留在导入器手中,就保留了更新视图的能力。对于希望看到实时目录、同时不允许外部增删的接口,这可能正是需要的契约。若导出报表要求行集合固定,则应在边界处建立独立容器。

1
2
3
4
5
view ──委托──> source 容器 ──第0项──> Item A
copy ────────> 新 ArrayList ──第0项──> Item A
frozen ──────> 不可变容器 ──第0项──> Item A
↑
三者仍指向同一元素

图中只画一个元素。执行 source.set(0, itemB) 会替换第一条路径上的引用,后两条路径仍指向 A;执行 itemA.name = "updated" 则改变对象 A 的字段,所有仍持有 A 的路径都能在顺序执行中读取新值。引用替换和对象修改,必须用不同的实验区分。

可迁移的模式是“限制入口”:只读视图 = 读操作委托 + 写操作拒绝。它适合组件内部的只读暴露、单线程界面的实时列表、由统一锁管理的共享模型;前提是接口明确承认数据会变化。只读视图不能替代数据所有权约束,也不自动增加锁。

浅复制切断哪条引用

ArrayList(Collection)按源集合的迭代顺序取得元素。新列表拥有独立的容器状态,所以向副本追加、删除或替换条目不会改变源列表。它没有调用商品对象的复制构造器,也不知道 Item 中哪些字段代表业务快照。

如果商品含一个可变的 name 字段,new ArrayList<>(source) 复制的是指向商品的引用。把整个集合重新分配出来,与把每件商品重新构造出来,是两个独立动作。测试同时使用 assertSame 检查引用和 assertEquals 检查字段值,避免两个内容相同的新对象掩盖共享关系。

需要历史报表时,更清楚的建模方式是提取报表需要的值,构造只有不可变字段的快照对象,再收集到不可修改容器。若快照对象又保存原来的可变标签列表,只复制第一层商品仍然不足。复制深度由报表契约决定,不能用“深复制”这个词替代字段分析。

可迁移的模式是“固定成员”:快照成员 = 复制元素引用序列。它适合固定一批待处理任务、保存某次筛选的成员集合、把可修改工作列表交给独立算法。是否允许元素后续变化,要另外约定;固定成员不能直接兑现“固定当时所有字段”的承诺。

Guava 冻结容器,不递归冻结元素

Guava 的不可变集合契约明确使用 “Shallow immutability”。ImmutableList.copyOf 使元素引用序列不能增加、删除或替换;它拒绝 null 元素,却不会阻止 frozen.get(0).name = "updated"。

商品被复制进不可变容器之后,导入器对原 ArrayList 的追加不影响它,这是正常的防御性复制用法。三个反例分别检查不同边界:原列表中的替换仍会传入只读视图;元素字段更新仍会传入浅副本;含 null 的输入能被前两种方式接受,却不能被 Guava 接受。第三个反例尤其会影响迁移,不能把一次异常当成无关的实现差异。

copyOf 的名字也不保证分配新对象。如果输入本身已经是合适的 Guava 不可变集合,方法可以复用它。防御性复制要保证的是调用方后续修改不了结果的成员序列;对已经满足条件的对象重新分配容器,不会增加这层保护。

固定版本源码中的关键分支是检查输入是否为 ImmutableCollection,取得其 asList(),再判断是否为局部视图。完整不可变列表可能直接返回;局部视图则通过数组路径建立适合该范围的结果。普通集合进入数组构造路径,并执行非空元素检查。具体对象身份是这个版本的优化观察,业务代码不能写 copyOf(x) == x 作为正确性前提。

同版本上游测试分别验证源列表替换不影响结果和 null 拒绝;testCopyOf_shortcut_immutableList 则验证复用优化。上游使用引用身份断言维护实现,本地业务测试只记录身份观察,二者目的不同。

subList 还涉及对象保留关系。很小的子列表可能间接保留整个父集合的数据,Guava 文档因此建议需要独立长期保存时再调用 copyOf。这描述的是可达对象范围,不是已经测得的内存泄漏;本实验不声称测过 GC 回收或堆占用。复制子列表也不会复制其中的商品对象。该版本把长度为 1 的子列表直接做成单元素列表,因此实验选择三项列表的前两项,确保观察局部视图分支,不能拿单元素特例代表全部切片。

完整实验与逐步观察

将以下测试保存到示例工程的 src/test/java/blog/libraries/Chapter02Test.java。依赖由第 00 篇统一冻结,JUnit 使用 5.13.4;无需复制其他业务类。测试中的可变 Item 仅用于暴露别名,不代表商品模型的推荐设计。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
package blog.libraries;

import com.google.common.collect.ImmutableList;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class Chapter02Test {
static final class Item {
String name;
Item(String name) { this.name = name; }
}

@Test void sourceEditsReachViewButNotCopies() {
Item first = new Item("A");
List<Item> source = new ArrayList<>(Arrays.asList(first));
List<Item> view = Collections.unmodifiableList(source);
List<Item> copy = new ArrayList<>(source);
ImmutableList<Item> frozen = ImmutableList.copyOf(source);
source.add(new Item("B"));
assertEquals(2, view.size());
assertEquals(1, copy.size());
assertEquals(1, frozen.size());
Item replacement = new Item("C");
source.set(0, replacement);
assertSame(replacement, view.get(0));
assertSame(first, copy.get(0));
assertSame(first, frozen.get(0));
source.remove(0);
assertEquals("B", view.get(0).name);
assertSame(first, copy.get(0));
assertSame(first, frozen.get(0));
}

@Test void allContainersShareMutableElement() {
Item item = new Item("before");
List<Item> source = new ArrayList<>(Arrays.asList(item));
List<Item> view = Collections.unmodifiableList(source);
List<Item> copy = new ArrayList<>(source);
ImmutableList<Item> frozen = ImmutableList.copyOf(source);
item.name = "after";
assertEquals("after", view.get(0).name);
assertEquals("after", copy.get(0).name);
assertEquals("after", frozen.get(0).name);
frozen.get(0).name = "through-frozen";
assertEquals("through-frozen", source.get(0).name);
}

@Test void unsupportedWritesAndMutableCopy() {
List<String> source = new ArrayList<>(Arrays.asList("A", "B"));
List<String> view = Collections.unmodifiableList(source);
List<String> copy = new ArrayList<>(source);
List<String> frozen = ImmutableList.copyOf(source);
for (List<String> list : Arrays.asList(view, frozen)) {
assertThrows(UnsupportedOperationException.class, () -> list.add("C"));
assertThrows(UnsupportedOperationException.class, () -> list.set(0, "C"));
assertThrows(UnsupportedOperationException.class, () -> list.remove(0));
}
copy.set(0, "C");
assertEquals("A", source.get(0));
assertEquals("C", copy.get(0));
}

@Test void nullPolicyDiffers() {
List<String> source = new ArrayList<>(Arrays.asList("A", null));
assertNull(Collections.unmodifiableList(source).get(1));
assertNull(new ArrayList<>(source).get(1));
assertThrows(NullPointerException.class, () -> ImmutableList.copyOf(source));
}

@Test void immutableReuseIsVersionSpecificObservation() {
ImmutableList<String> original = ImmutableList.of("A", "B", "C");
ImmutableList<String> result = ImmutableList.copyOf(original);
assertEquals(original, result);
System.out.println("CH02 fullImmutableReused=" + (original == result));
ImmutableList<String> subList = original.subList(0, 2);
ImmutableList<String> subCopy = ImmutableList.copyOf(subList);
assertEquals(Arrays.asList("A", "B"), subCopy);
System.out.println("CH02 partialImmutableReused=" + (subList == subCopy));
}

@Test void modernJdkCopyOfIndependentComparison() throws Exception {
Method copyOf;
try {
copyOf = List.class.getMethod("copyOf", Collection.class);
} catch (NoSuchMethodException absentOnJava8) {
System.out.println("CH02 modernListCopyOf=NOT_APPLICABLE API requires Java 10+");
return;
}
Item item = new Item("before");
List<Item> source = new ArrayList<>(Arrays.asList(item));
@SuppressWarnings("unchecked")
List<Item> modern = (List<Item>) copyOf.invoke(null, source);
source.clear();
assertEquals(1, modern.size());
assertSame(item, modern.get(0));
item.name = "after";
assertEquals("after", modern.get(0).name);
assertThrows(UnsupportedOperationException.class,
() -> modern.add(new Item("B")));
InvocationTargetException thrown = assertThrows(InvocationTargetException.class,
() -> copyOf.invoke(null, Arrays.asList(item, null)));
assertInstanceOf(NullPointerException.class, thrown.getCause());
System.out.println("CH02 modernListCopyOf=PASS isolatedContainer/sharedElement/rejectWrite/rejectNull");
}

}

执行命令:

1
2
cd examples/java-libraries
./mvnw -s .mvn/settings.xml -Dtest=Chapter02Test test

本次在 Zulu 8.0.472 和 Amazon Corretto 21.0.11 上分别运行工程的 clean verify。第 02 篇均报告 6 个测试方法、0 失败、0 错误、0 跳过;Java 8 的现代对照方法只确认 API 不存在并返回,因此现代场景的验收依据是 Java 21 的实际执行结果,不能把两个运行都算作现代 API 通过。

1
2
3
4
5
6
7
8
9
Java 8:
CH02 modernListCopyOf=NOT_APPLICABLE API requires Java 10+
CH02 fullImmutableReused=true
CH02 partialImmutableReused=false

Java 21:
CH02 modernListCopyOf=PASS isolatedContainer/sharedElement/rejectWrite/rejectNull
CH02 fullImmutableReused=true
CH02 partialImmutableReused=false

原始报告保存在工程的 evidence/02/jdk8-Chapter02Test.xml 和 evidence/02/jdk21-Chapter02Test.xml,含运行 JDK 属性与测试输出。完整工程下载见第 00 篇,单篇入口见实验运行说明。

第一项测试按 add → set → remove 推进,每一步都立即断言。只检查操作结束后的长度会漏掉 set;只检查内容相等则不能证明复制前后的元素是否仍是同一个对象。第二项从不可变容器取出元素再更新字段,排除了“只有持有源列表才能改元素”的误解。第三项使用有效索引和非空列表测试写入失败,避免用没有实际效果的操作推导所有可选操作的异常行为。

身份观察只打印 fullImmutableReused 和 partialImmutableReused,测试通过的条件仍是值契约。不同实现版本即使重新分配了完整列表,只要内容和不可变保证正确,也不应因此被判为业务回归。反过来,一次 == 为真也不能推出全部 copyOf 重载、全部集合类型都不分配对象。

线程安全与发布是另外两项责任

Guava 文档保证不可变集合本身可被多个线程并发访问,这个保证不能扩展为“列表中任意对象都可无同步地修改”。顺序测试中的 item.name = ... 只证明别名共享,没有验证另一个线程何时能看到这次写入。共享可变字段的读写需要自己的同步协议,列表容器并不会为元素的 setter 增加锁。

JLS 8 §17.4.5定义 happens-before,例如同一监视器的解锁与后续加锁、volatile 写与后续读之间的关系。若服务通过一个字段不断替换最新目录,那个字段如何发布新引用、读线程如何取得新版本,是独立于 copyOf 的设计问题。通过 volatile 引用发布构造完成的快照,是一种明确的交接方式;仅在字段声明上写 List 或 ImmutableList 不能代替交接协议。

JLS 8 §17.5还规定正确构造对象的 final 字段语义。但 final List<Item> 固定的是字段引用,不能据此禁止列表或 Item 修改,也不能为构造结束后的任意元素更新提供可见性。文章的实验没有安排并发调度,因此不把测试通过表述为并发正确性的证明。

构建快照时同样有前提:如果源列表正在被另一个线程修改,调用一个复制方法不等于取得业务层一致的时间点。Guava 对同步集合或并发集合还有专门的安全复制说明,但这仍不等于取得跨元素字段的业务事务快照。对于普通共享 ArrayList,应在约定锁内复制,或在导入批次完成、源对象不再变化时转换并发布;单纯捕获 ConcurrentModificationException 后重试不能定义商品字段间的一致性。

JDK 21 的独立对照

List.copyOf 自 Java 10 提供,JDK 21 文档规定返回不可修改列表、拒绝 null,并使源集合后续的改变不反映到结果中。它同样不递归冻结元素。Java 8 的编译示例不使用此方法,现代对照必须单独编译或运行。

完整测试中的现代对照通过反射查找 List.copyOf(Collection),因此源文件仍可由 Java 8 编译。Java 8 缺少该 API 时打印 NOT_APPLICABLE,不算现代场景通过;JDK 21 必须实际调用并通过容器隔离、元素共享、写入拒绝和 null 拒绝四项断言。反射调用抛出的 InvocationTargetException 还需拆出 cause,检查真正的 NullPointerException,否则只验证包装异常会掩盖目标方法的错误。

API 选择还受返回类型约束影响:Guava 的 ImmutableList 在类型中表达不可变容器意图,JDK 方法返回 List,需要通过接口文档说明限制。已经使用 Guava 的 Java 8 项目可直接采用其契约;现代项目若只需要这一项能力,JDK 方法足以作为候选,但 null、元素状态与调用方写入行为仍需用原有输入回归。

手算与改动练习

设 source 初始只包含对象 A,A 的名称为 old,分别创建视图、浅副本和 Guava 不可变列表。按顺序执行:把 A 名称改为 new,用 B 替换源列表第 0 项,再清空源列表。每步填写三个结果的长度和第 0 项名称,清空后的空列表记为“无第 0 项”。

答案是第一步三者均为 1/new;第二步视图为 1/B,两个副本仍为 1/new;第三步视图长度为 0,两个副本仍为 1/new。这里 B 表示 B 对象的名称。只要把图中的容器引用和元素引用分开,结果不依赖背诵方法名。

改动练习分两次提交。先把浅副本外面再套一层 unmodifiableList,增加测试证明原列表的 clear 不影响结果、结果的 add 被拒绝,但 A 的名称变化仍可观察。再把 Item 换为只保存最终名称的快照类型,复制时逐项新建快照,证明源对象改名不影响结果。第二次修改解决的是元素状态隔离,不应通过删除元素更新断言取得通过。

模式速查

正文实验对应的全部容器、null与现代API断言见 完整测试源码,在第00篇首批工程或第39篇全量工程中复跑;附件 运行说明 保留本篇实验入口。

需求关键词 模式 可用方式 必须另查的边界
随源变化,禁止外部增删 限制入口 unmodifiableList(source) 原引用仍可写、并发读写协议
固定成员,继续加工 固定成员 new ArrayList<>(source) 元素引用共享
固定成员,禁止增删替换 冻结容器 Guava copyOf;现代 JDK List.copyOf null、可变元素、API 版本
保留某时刻商品字段 提取值快照 新建不可变业务值,再冻结容器 嵌套字段、构建时一致性
长期保存很小的子列表 缩小保留范围 对子列表再 copyOf 不等于深复制,GC 需另测