商品目录的冻结范围

目录导入程序把商品列表交给查询线程,查询接口承诺结果不能增删。如果返回 Collections.unmodifiableList,导入程序继续向原列表追加商品,查询结果也会变。如果改为 ImmutableList.copyOf,追加不再传播;但商品对象内部的名称若仍可修改,查询结果中的名称仍会变。

这里有两个不同的状态集合:容器保存的元素引用序列,以及这些引用指向的对象状态。不可变集合固定前者。要让一次目录查询代表某个时刻的完整业务状态,还需要约束商品对象及其可达对象。只检查 add 是否抛异常,尚未覆盖目录的版本含义。

第 02 篇建立了视图、浅复制与不可变的区别。本篇以 Guava 33.5.0-jre 继续追踪 ImmutableList、ImmutableSet、ImmutableMap 的构建、访问和 builder 复用路径。实验遵循 Java 8 API,附 完整测试、运行说明;JDK 现代工厂方法作为明确标注版本的替代方案讨论。

构建边界也是校验边界

同一批商品标签 B、A、B 进入不同集合,会得到不同结构。ImmutableList 保留三个位置,ImmutableSet 去掉相等的重复元素,迭代顺序按首次出现保留为 B、A。ImmutableMap 则根据键判重;builder 两次插入 B,并不会表达“两个 B 商品”,严格构建会抛出 IllegalArgumentException。

这些行为与 ImmutableList、ImmutableSet、ImmutableMap 的契约对应。List 和 Set 不接受 null 元素,Map 不接受 null 键和值。把允许 null 的集合复制过来,复制过程本身可能失败,不能把 copyOf 当成无条件成功的类型转换。

构造目标 重复规则 顺序 null
ImmutableList 保留重复元素 按输入顺序 拒绝元素 null
ImmutableSet 按 equals 去重 首次出现顺序 拒绝元素 null
ImmutableMap 默认 builder 重复键构建时报错 插入顺序 拒绝键和值 null

表中的 Map 顺序排除了显式 orderEntriesByValue 的情况。输入来自 HashMap 或无序集合时,“保留输入顺序”不能凭空产生稳定业务顺序。构建过程只固定本次接收到的顺序;需要按商品编号排序,应在输入或 builder 的明确排序配置中定义。

Set 去重依赖 equals/hashCode。若两个商品在业务上属于同一 SKU,但对象没有按 SKU 实现相等,集合不会自行理解业务身份。相反,若 equals 只按名称比较,价格不同的两个对象也可能被合并。不可变集合可以维持结构约束,无法修复输入对象的身份模型。

Map 的重复策略适合放在导入边界显式选择。buildOrThrow 适用于“重复编号即坏数据”;buildKeepingLast 则适用于“后面的配置覆盖前面的配置”。本实验对 B→1、A→2、B→3 验证严格构建失败,保留末值构建得到 B→3。前一种失败不会自动说明导入无效,后一种成功也不会证明覆盖符合业务需求,选择来自格式契约。

builder 可以复用,但不会自动清空

Builder 是可变的累积器。先添加 A 并 build 得到第一个 ImmutableList,再添加 B 并 build,第二个列表为 A、B,第一个列表仍为 A。Set builder 和 Map builder 也允许构建后继续添加,之前构建的产物不受后续 builder 操作影响。复用不代表“构建后重置”,更不代表可以跨请求共享一个累积器。

商品分组导出若在循环外建一个 builder,每处理一家商户后 build,却没有创建新 builder,第二家商户的结果会包含第一家的商品。这个错误不违反集合不可变保证;它来自累积范围被放大。Builder 生命周期应与一次结果构建一致,只有确实要构建递增快照时才连续复用。

ImmutableList.Builder 的固定实现沿用数组式 builder 的容量与复制管理;ImmutableSet.Builder在 build 后设置 forceCopy,后续写入前复制必要的内部状态。能够共享内部存储与能够影响已经发布的内容是两件事,实现负责在再次写入前隔离。

Map 的构建还有重复项与排序分支。ImmutableMap.Builder 固定源码维护 entries、size 和 entriesUsed,构建时进入 RegularImmutableMap.fromEntryArray;末值策略不能把 builder 中的重复信息破坏掉,否则后续严格构建可能错误地成功。默认未配置值排序与配置排序的内部处理不同,应用只应依赖公开的重复策略和迭代顺序。

源码中的数组共享属于优化细节。业务代码不应通过引用相等来判断“有没有复制”,也不应依赖某次构建后出现几个数组。可迁移判断是用后续写入验证隔离:构建产物甲、继续修改构建器、构建产物乙,再检查甲的内容。这一模式也适用于请求对象、查询条件和配置构建器。

访问路径没有深复制元素

不可变列表的 get 返回保存的元素引用;迭代同样沿容器中的引用访问对象。公开修改方法拒绝操作,并不会在每次 get 时复制对象。因此,对 StringBuilder 元素追加字符后,ImmutableList.get(0).toString 也会变化。本实验同时保留原列表、只读包装与不可变复制,对源结构追加和元素修改分别断言。

引用关系可以表示为:

1
2
3
4
source list ------> item (StringBuilder)
|
unmodifiable view --读取 source
immutable list ---> 同一个 item

源列表追加第二个元素后,只读包装大小变成 2,不可变列表仍是 1。原 item 从 A 变成 A2 后,两种结果中的第一个元素都读到 A2。结构隔离与元素隔离必须使用两项观测,单个 size 断言只能验证前者。

这种区别在 Map 中还有查找风险:键对象参与 equals/hashCode 的状态若被修改,已经建立的哈希查找关系可能不再符合调用方预期。对不可变 Map 也应使用稳定键。值对象的可变字段则会影响读到的业务内容。采用不可变 DTO 或在边界处复制需要保护的字段,比尝试“冻结一层集合”更接近完整业务快照。

所谓深复制也要限定范围。商品对象可能引用价格历史、标签列表、外部服务代理和缓存句柄,机械复制整个可达对象图通常没有明确业务含义。目录快照可以只包含 ID、金额及已经复制的标签值;运行时协作对象不进入快照。这是按接口承诺选择状态范围,而非依据字段层数判断复制是否足够。

并发示例只证明受控可见性

本实验另设写线程更新 AtomicReference,该引用作为元素放进 ImmutableList。写线程设置 new 后释放 CountDownLatch,读线程 await 成功后读取元素,结果为 new。等待有五秒上限,线程在 finally 中 join,测试最终确认线程结束。

这个例子没有制造数据竞争。AtomicReference 与 CountDownLatch 提供明确同步,目的是使“不可变容器内的元素仍能变化”成为可重复观测,而非依赖偶然调度。CountDownLatch内存一致性保证规定:线程在countDown前执行的动作,happens-before其他线程在对应await成功返回后执行的动作;这与 JLS 8 内存模型相容。

把元素改成普通非 volatile 字段,却保留相同的锁存器顺序,仍可利用这条同步关系;把同步也去掉,则不能因为某次读到了 new 就宣称线程安全。容器不可变没有替代元素的同步协议,也没有自动提供跨多个元素更新的原子快照。一次目录版本切换若要求多个商品同时更新,通常应先建立一个完整新快照,再通过明确的发布位置替换引用。

可迁移判断是分别列出“结构不可变”“元素不可变”“安全发布”“复合操作原子性”四项要求。它们可以分别成立,也可能只成立其中几项。配置缓存、路由表和只读目录都能使用这组问题定位承诺范围。

与 JDK 工厂方法比较时保留差异

Java 8 的 Collections.unmodifiableList 适合需要随底层集合变化的只读视图;若要结构快照,需要先显式复制,再包装。Java 9 增加 List.of、Set.of、Map.of;Java 10 增加 copyOf。它们不能直接写进以 Java 8 API 编译的公共示例,现代调用应放进对应版本模块或独立测试。

JDK 21 List描述不可修改列表以及 copyOf 的复制语义;Set与 Map工厂集合不承诺 Guava 这里的插入顺序。Set.of 拒绝重复参数,Set.copyOf 对输入重复项的处理又不同。因此,迁移不能只检查返回类型仍是 Set。

第 02 篇已有 JDK 21 List.copyOf 的反射对照测试,Java 8 分支明确记录不可用。本篇没有重复运行所有现代 Set/Map 工厂组合,关于它们的说明属于文档契约对照。若应用输出顺序进入签名、缓存键或导出文件,应把顺序加入迁移回归,不能以“都是不可变集合”为理由删除断言。

读操作与迭代器的承诺

常规 ImmutableList 实现以数组保存引用,get 检查索引后读取对应位置;子列表可能以偏移和长度表示原列表的一段。迭代访问可以复用这种索引语义,返回的仍是同一批元素引用。访问不产生新商品对象,所以“每次查询都得到独立对象”从来不是不可变列表提供的保证。

迭代器无法通过 remove 修改不可变集合,与集合本身拒绝 add、set、remove 一致。但这种操作限制不负责让元素的方法只读,forEach 中调用商品的 setter 仍然是一次元素状态修改。如果接口希望暴露的对象只有查询能力,需要在元素类型或返回 DTO 上表达,不能依赖外层 Iterator 的限制。

不可变 Set 和 Map 的读取还要根据元素或键的 hashCode、equals 判断匹配。容器结构不变时,业务仍可能在查找过程中调用自定义实现。耗时很高、带外部状态或违反相等契约的实现,会把自身成本和不稳定性带入集合访问。源码分析应停在这条调用边界,不能因为容器名包含 Immutable 就推导读取完全没有用户代码参与。

第 02 篇已经区分完整不可变集合复制时可能复用实例,以及部分视图复制时建立独立结构的情况。这些路径提醒调用方不要把 copyOf 当成按字面执行某一种固定分配策略的命令。公开保证是可观察内容与修改限制;若关注内存滞留,应另行观察被保留的引用及其生命周期。本篇没有堆转储与分配基准,不能从数组分支估算节省了多少内存。

结果与练习

Chapter09Test 在 JDK 8、21 各运行四个方法,失败、错误、跳过均为 0。断言覆盖 null、重复、顺序、三种 builder 的复用、源结构与可变元素、同步后的跨线程元素变化。原始记录保存在 evidence/09/,没有运行内存占用或吞吐基准,内部共享分析也没有转化成性能倍数结论。

反例题:builder 连续产生两个列表,旧列表未变,能否判断它们完全不共享对象?不能。它们可以共享相同元素,甚至实现可在保证隔离的前提下共享内部存储;测试只证明公开可观察状态没有被 builder 后续写入改变。

改动练习:将 StringBuilder 元素替换为带可变标签列表的商品 DTO。先复现修改标签传播,再定义只含不可变字符串及不可变标签快照的返回 DTO,断言两层状态均不传播。复制范围必须覆盖接口承诺,不能只把最外层 List 换一个类名。

需求信号 可迁移判断 验收动作
构建后继续追加 检查构建器累积范围 新旧产物分别断言
查询结果代表导入时刻 分离结构与元素状态 改源结构、再改元素
多线程只读 拆开不可变与发布协议 验证明确同步顺序
JDK/Guava 迁移 保留重复与顺序契约 同输入逐项比较

前篇:字符处理单位。下一篇:Multimap 的多值语义。