Java常用类库-02-视图浅复制与不可变的边界
返回的列表,谁还能改
商品目录查询返回 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 | |
图中只画一个元素。执行 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 | |
执行命令:
1 | |
本次在 Zulu 8.0.472 和 Amazon Corretto 21.0.11 上分别运行工程的 clean verify。第 02 篇均报告 6 个测试方法、0 失败、0 错误、0 跳过;Java 8 的现代对照方法只确认 API 不存在并返回,因此现代场景的验收依据是 Java 21 的实际执行结果,不能把两个运行都算作现代 API 通过。
1 | |
原始报告保存在工程的 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 需另测 |
