Java常用类库-09-Immutable集合的构建与共享边界
商品目录的冻结范围
目录导入程序把商品列表交给查询线程,查询接口承诺结果不能增删。如果返回 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,不可变列表仍是 1。原 item 从 A 变成 A2 后,两种结果中的第一个元素都读到 A2。结构隔离与元素隔离必须使用两项观测,单个 size 断言只能验证前者。
这种区别在 Map 中还有查找风险:键对象参与 equals/hashCode 的状态若被修改,已经建立的哈希查找关系可能不再符合调用方预期。对不可变 Map 也应使用稳定键。值对象的可变字段则会影响读到的业务内容。采用不可变 DTO 或在边界处复制需要保护的字段,比尝试“冻结一层集合”更接近完整业务快照。
所谓深复制也要限定范围。商品对象可能引用价格历史、标签列表、外部服务代理和缓存句柄,机械复制整个可达对象图通常没有明确业务含义。目录快照可以只包含 ID、金额及已经复制的标签值;运行时协作对象不进入快照。这是按接口承诺选择状态范围,而非依据字段层数判断复制是否足够。
并发示例只证明受控可见性
本实验另设写线程更新 AtomicReference
这个例子没有制造数据竞争。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 的多值语义。
