读完这篇,你能做什么
能区分视图和快照,并为排序与迭代选择符合要求的实现。
学习理由、准备要求与知识点
为什么学它
集合视图和排序规则会影响接口返回、缓存快照和有序映射;误解它们容易产生难以发现的错误。
先读这些内容
知识点清单
- 不可修改视图
List.copyOf快照Arrays.asList固定大小- 浅复制元素共享
- comparing
/thenComparing - 比较器传递性
- fail-fast 尽力检测
- 有序集合等价关系
运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。
01先弄懂它是什么
拿到一个 List 后,先确认它允许哪些修改。Arrays.asList 与原数组相连,可以替换元素,却不能用 add 增加长度;Collections.unmodifiableList 不允许通过这个入口修改,但原列表在别处变化时,这里仍可能看见。能不能增删、能不能替换、会不会跟着来源变化,以及元素对象能不能改,是不同的问题。
如果想保留“这一刻有哪些元素”,可以用 List.copyOf 得到不可修改的快照。原来的可变集合之后增删,不会直接改变这个结果;但复制是浅层的,元素若是可变对象,其字段变化仍可能被看见。实现也可能复用已有的合适列表,不保证一定新建对象。List.of 和 copyOf 都不接受 null,调用前要确认输入符合要求。
视图常用于避免复制:Map.keySet、values、entrySet 与原映射关联,某些删除会反映到原 Map;subList 表示原列表的一段范围。对 subList 存活期间从其它路径进行不支持的结构修改,会破坏其使用前提,不能把一次没抛异常当作安全。视图的生命周期与来源修改应在接口里明确。
文中这几个词,先认一认
- 不可修改视图
- 不许通过当前列表修改数据,但如果别人修改它背后的原列表,你仍可能看到变化。
- 快照
- 记下某一刻的元素集合;来源之后增删不会自动改变它,但元素对象仍可能共享。
- 深度不可变
- 不只容器不能增删,连其中对象及其可访问的业务状态也不能再修改。
- 同分时的比较规则
- 先按成绩排队,同分时再按编号等稳定字段比较,而不是立刻停止比较。这个补充规则用来明确平分时怎样排;若编号等比较字段也相同,仍可能比较为相等。
02看看它怎么工作
视图读取数据时,通常直接交给原容器处理,例如 size 和 get 仍访问原来的数据,所以不必复制全部元素。这样更省分配,但原容器后续怎么修改,也会影响视图能看到什么。快照则复制出独立的容器状态,适合缓存返回、异步任务和需要稳定结果的分页。不过,如果复制期间原容器正被其他代码修改,复制动作本身并不能保证拿到同一时刻的一整份数据。
集合的顺序要说清是怎么来的。不能因为某次遍历 HashMap 恰好得到某个顺序,就把它当成以后都成立的输出规则;按插入顺序也不等于按值排序。TreeSet 与 TreeMap 用比较结果是否为 0 来判断是不是同一个排序键。如果只比较分数,不同 ID 的同分对象也会被当作同一个键。需要保留这些不同对象时,就要在同分后再比较一个稳定、唯一的字段,例如 ID。
在一份顺序固定的快照上分页,可以保存上一页最后一项的排序字段,下一页从排在它后面的第一项开始。排序字段必须稳定,并能区分不同记录,否则同分记录可能漏掉或重复。下面先复制输入,再按 rank、id 排序并保存不可修改列表;每页返回独立的结果列表,不把依赖内部列表的 subList 视图直接交给调用者。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["外部可变列表"] --> B["校验并复制容器"]
B --> C["按 rank 再按 id 排序"]
C --> D["保存不可修改快照"]
D --> E["二分寻找严格大于游标的位置"]
E --> F["复制本页范围后返回"]
A --> G["之后修改来源不改变本次快照"]按顺序看,每一步做了什么
- 分别确认结构修改、元素替换与底层关联。
- 决定需要视图还是快照。
- 检查元素本身的可变性。
- 定义稳定完整的排序键。
- 用来源修改、同分数据和分页边界测试。
03对照实现代码
Microsoft OpenJDK 17.0.20.1;java.base/java/util/Collections.java · java.util.Collections#unmodifiableList(List)
以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。
import java.util.AbstractList;
import java.util.List;
import java.util.Objects;
public final class SourceModel extends AbstractList<String> {
private final List<String> source;
public SourceModel(List<String> source) { this.source = Objects.requireNonNull(source); }
@Override public String get(int index) { return source.get(index); }
@Override public int size() { return source.size(); }
// AbstractList 默认的主要结构修改方法拒绝修改;读取仍委托原列表。
}真实的 Collections.unmodifiableList 会根据原列表是否带有 RandomAccess 标记,选择相应包装。本例继承 AbstractList,让 get 和 size 仍去读取原列表,因此包装后能看到来源的变化,这就是活视图。这个模型只展示二者的关联,没有完整实现真实包装器对迭代、子视图、序列化和随机访问标记的处理。
没有元素复制意味着来源变化可以通过 get 与 size 被观察到。private final source 只固定包装器保存的引用,并不冻结原列表。调用者经视图调用不支持的修改会失败,与持有原列表的另一方仍然能修改它并不矛盾,两条入口的权限不同。
真正的不可修改包装需要覆盖所有可能的修改路径,包括迭代器、子列表与默认方法。模型依赖 AbstractList 的默认拒绝行为,只用于解释受限读取,不是完整可替代包装。仅覆盖 add 而忘记 set 或 iterator.remove 的自制包装可能意外泄露写入口。
活视图不会自动加锁。即使所有读者只能通过不可修改包装访问,另一个线程若在改原列表,读取和遍历仍可能受影响。需要稳定结果时,要先用合适同步方式取得快照,再检查元素对象是否仍会变化。限制通过哪个入口修改,和保证并发读取一致,是两件事。
代码先调用谁,再调用谁
unmodifiableList接收底层列表- 依据
RandomAccess选择包装类型 - 读取委托到底层列表
- 修改入口按包装契约拒绝,来源别名仍可能修改原数据
04动手写一个例子
固定快照上的稳定游标分页
输入不可变 Item(id,rank) 值列表,ID 必须唯一。构建按 rank 升序、id 字典序升序的快照;page 返回严格大于游标的最多 limit 项,null 游标从开头开始,limit 为一到一百。
- 输入 1
[(b,1),(a,1),(c,2)],第一页2项- 输出 1
[(a,1),(b,1)]
- 输入 2
游标(b,1),2项- 输出 2
[(c,2)]
- 输入 3
空快照- 输出 3
[]
构建时先校验 ID 唯一并复制列表,再排序并保存不可修改结果。Item 的组件是 String 与 int,足以形成稳定值;若组件换成可变对象,这个证明需要重新建立。来源列表后续增删不影响已构建快照,分页因此面对固定数据集。
游标保存 rank 与 id 两个排序字段。用二分寻找第一个比较结果大于游标的位置,再取不超过 limit 的连续片段。只用 rank 作为游标会跳过尚未返回的同分项,所以稳定唯一 ID 既参与排序,也必须参与游标比较。
返回时 List.copyOf 内部范围,避免泄露依赖内部列表生命周期的子视图。游标不必是当前快照中真实存在的项,它也可以表示一个排序边界;算法仍返回其后内容。实际跨请求分页还需绑定快照版本和权限,不能把这个内存游标直接当成可信外部令牌。
为什么这样写是对的快照始终按唯一复合键严格排序;二分结束位置之前的项都不大于游标,之后的项都严格大于。
构建校验与复制线性并排序;查询用对数次比较定位,随后复制 p 项。字符串键比较成本还取决于 ID 长度。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
public class Main {
record Item(String id, int rank) {
Item { if (id == null || id.isBlank()) throw new IllegalArgumentException("id"); }
}
static final Comparator<Item> ORDER = Comparator.comparingInt(Item::rank).thenComparing(Item::id);
static final class Snapshot {
private final List<Item> items;
Snapshot(List<Item> source) {
if (source == null) throw new IllegalArgumentException("source");
HashSet<String> ids = new HashSet<>();
List<Item> copy = new ArrayList<>();
for (Item item : source) {
if (item == null || !ids.add(item.id())) throw new IllegalArgumentException("duplicate/null");
copy.add(item);
}
copy.sort(ORDER);
items = List.copyOf(copy); // 容器快照与外部列表脱离。
}
List<Item> page(Item after, int limit) {
if (limit < 1 || limit > 100) throw new IllegalArgumentException("limit");
int lo = 0, hi = items.size();
if (after != null) {
while (lo < hi) {
int mid = lo + (hi-lo)/2;
if (ORDER.compare(items.get(mid),after) <= 0) lo = mid+1;
else hi = mid;
}
}
int end = lo + Math.min(limit,items.size()-lo);
return List.copyOf(items.subList(lo,end)); // 不暴露内部范围视图。
}
}
static void require(boolean condition) {
if (!condition) throw new AssertionError();
}
public static void main(String[] args) {
Item a = new Item("a",1), b = new Item("b",1), c = new Item("c",2);
List<Item> source = new ArrayList<>(List.of(b,a,c));
Snapshot snapshot = new Snapshot(source);
// 同分平价、后续页、尾部、空快照与来源修改。
require(snapshot.page(null,2).equals(List.of(a,b)));
require(snapshot.page(b,2).equals(List.of(c)));
require(snapshot.page(c,2).isEmpty());
require(new Snapshot(List.of()).page(null,2).isEmpty());
source.clear();
require(snapshot.page(null,3).equals(List.of(a,b,c)));
boolean rejected = false;
try { new Snapshot(List.of(a,a)); } catch (IllegalArgumentException e) { rejected = true; }
require(rejected);
System.out.println("PASS 6");
}
}这些特殊情况也要试一试
- 空快照合法。
- 同 rank 使用唯一 ID 确定顺序。
- 尾部游标之后返回空列表。
- 来源结构变化不改变快照。
进阶选读 · 可选深入:数据库游标与快照版本
场景跨请求分页期间数据持续新增或更新。
做法把排序字段、唯一 ID、过滤条件和快照版本纳入游标契约,使用数据库支持的相应查询与隔离;签名或服务端状态防止游标被任意篡改。
复杂度索引匹配时定位与页读取可较高效,具体成本取决于查询计划和一致性方案。
好处与代价稳定快照需要存储或隔离成本,实时更新视图则必须接受并解释变化语义。
05项目里怎么用
返回集合时把可修改性写进接口,不要让调用者通过试 add 才知道。缓存或异步任务通常更适合不可变值快照,内部短期局部处理则可以使用受控视图减少复制。选择应依据生命周期和修改关系,而不是一律把所有列表包装成 unmodifiableList。
长期只保存一个很小的 subList,也可能因此保留它关联的较大底层数据。需要独立保留时,明确复制所需部分可减少这种关联。即使复制了容器,里面的大对象或外部资源仍可能共用,并不会被自动复制或关闭,仍要约定谁负责管理。
排序与分页还要考虑比较器的一致性。按可变字段排序后再修改字段,会让容器或快照的有序前提失效;TreeMap 键比较规则变化也会影响查找。优先用稳定值作为排序和游标键,并通过版本化更新建立新快照,而不是在原快照里偷偷修改元素。
容易踩的坑
- 把
unmodifiableList当成来源不会变化的快照。 - 把
Arrays.asList当成可自由扩容列表。 - 直接长期返回
subList而未说明来源关联。 - 游标只含非唯一排序字段,导致同分项漏读。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 活视图 | 避免复制并实时反映来源 | 生命周期和修改耦合强 | 受控内部短期访问 |
| 不可修改快照 | 读取稳定且接口清楚 | 复制与版本存储成本 | 缓存、异步交付与固定分页 |
| 深不可变值结构 | 共享更易推理 | 构造与更新需要新值 | 跨线程或长期共享数据 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01不可修改视图与快照最主要区别是什么?
展开参考答案与推导
视图通常继续委托来源,来源从其它入口修改后可能被看见;快照表达捕获时的容器状态。二者都不自动冻结可变元素,复制深度仍需单独说明。
02List.copyOf 是否保证返回不同对象?
展开参考答案与推导
不保证实现身份不同,它可以复用合适的已有不可修改列表。应依赖其内容、null 和不可修改契约,不应把引用是否相同当作快照是否正确的判断。
03为什么分页比较必须包含 ID?
展开参考答案与推导
同 rank 的记录需要唯一平价顺序,游标必须能定位到已返回的具体边界。只记录 rank 会把同分未返回项一起跳过,造成遗漏。
换个情况,还会处理吗
01拿到只读列表后数据仍变了,可能哪一层可变?
展开参考答案与推导
可能底层列表仍被其它引用修改,或容器独立但元素对象字段变化。分别检查容器来源关联和元素可变性,不能仅看到不能 add 就推断全部内容固定。
02subList 使用期间原列表被插入,怎么办?
展开参考答案与推导
不要继续依赖未经支持的外部结构修改后的视图行为。若需要长期使用范围,应复制为独立结果;若保持视图,必须限制来源修改并遵守具体接口契约。
03相同分数的对象被 TreeSet 去掉,为什么?
展开参考答案与推导
比较器可能只比较分数,使不同对象 compare 为零。树集合按排序等价管理成员,应补唯一平价字段或重新选择集合,不能期待 equals 自动恢复被比较器合并的区别。
写代码试一试
01验证返回页不可修改
- 输入 / 条件
取第一页后add新Item- 输出 / 预期
抛 UnsupportedOperationException
提示 结果由 List.copyOf 返回。
展开参考答案与推导
对 page 结果调用 add 并断言失败,同时确认再次查询原快照结果不变。该测试检查公开容器契约,不代表元素深不可变在任何未来类型变化下仍成立。
02测试不存在的游标边界
- 输入 / 条件
游标(a5,1),数据(a,1),(b,1)- 输出 / 预期
返回(b,1)
提示 按排序关系而非查找游标身份。
展开参考答案与推导
创建相同 rank、字典序位于 a 与 b 之间的游标并查询。算法应返回严格大于边界的项,即使游标本身未在快照出现,这也是键集分页的一种明确语义。
03让排序改为 rank 降序
- 输入 / 条件
相同数据- 输出 / 预期
c在前,同分a再b
提示 更新同一个 ORDER。
展开参考答案与推导
使用 Comparator.comparingInt(Item::rank).reversed().thenComparing(Item::id),构建与二分都必须使用这个 ORDER。只改排序不改查询比较会破坏单调前提并返回错误页。
读完之后,独立完成
课后作业:自己做一遍
本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能区分视图和快照,并为排序与迭代选择符合要求的实现。
本篇重点加练
重点加练元素共享与比较器合同,避免不可修改集合和排序结果掩盖对象状态问题。
- 浅复制元素共享
- 比较器传递性
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01排错实验
从同一
ArrayList建立不可修改视图、List.copyOf结果和Arrays.asList数组视图,依次修改原容器、原数组及尝试增删结果;分别记录行为。- 提交内容
- Java 实验、自写别名和修改测试、观察矩阵。
- 怎样算完成
- 视图与快照区分
- 固定大小与不可修改区分
练到的知识点不可修改视图 · List.copyOf 快照 · Arrays.asList 固定大小
- 作业 02原理问答
集合元素为可变 Lesson 对象,某方法声称返回“不可变快照”。列出容器变化、元素变化和并发读取三个需要独立定义的合同。
- 提交内容
- 所有权图与合同检查表。
- 怎样算完成
- 不可修改容器不代替元素约束
- 快照时点要求清晰
练到的知识点浅复制元素共享
综合应用
- 作业 03编程实践
对学生记录按分数倒序、姓名和 ID 排序,输入含同分同名及极值 ID;实现比较器并检验反对称、传递和确定性。
- 提交内容
- Java 实现、自写三对象组合与边界测试、运行证据。
- 怎样算完成
- 平价规则完整
- 比较运算无未经处理的溢出
- 不只测试一组排序结果
练到的知识点comparing/thenComparing · 比较器传递性
- 作业 04排错实验
先在遍历中修改列表,再建立比较器只看分数的有序集合并插入同分不同 ID 学生;记录实际行为,区分容器检测机制与领域身份规则。
- 提交内容
- 两个独立 Java 实验、自写契约测试和记录。
- 怎样算完成
- 不将 fail-fast 当作并发保证
- 排序等价与业务唯一性明确
练到的知识点fail-fast 尽力检测 · 有序集合等价关系
- 作业 05面试场景
后台线程更新课程列表,接口返回
unmodifiableList,调用方偶尔看到中间状态。设计需要验证的数据所有权、元素可变性和读取一致性证据。- 提交内容
- 故障交错图与接口修订提案。
- 怎样算完成
- 三个层面的保证分别说明
- 不能只加包装器结束修复
练到的知识点不可修改视图 · List.copyOf 快照 · 浅复制元素共享 · fail-fast 尽力检测
- 作业 06编程实践
实现按任务优先级与创建序号排序的只读报告,任务对象原本可修改;先决定报告是快照还是实时视图,再测试报告生成后修改任务。
- 提交内容
- Java 实现、自写平价/对象修改/空输入测试与运行证据。
- 怎样算完成
- 比较器合同独立验证
- 报告语义与元素所有权一致
练到的知识点浅复制元素共享 · 比较器传递性
- 作业 07排错实验
设计三个对象检验一个自定比较器的传递性,再给比较器增加空值政策;测试需直接检查三元关系,而不仅调用 sort。
- 提交内容
- Java 小实验、自写三元和空值测试及运行记录。
- 怎样算完成
- 顺序合同完整
- 失败样本可定位到具体比较
练到的知识点比较器传递性
- 作业 08设计评审
缓存返回
List.copyOf后外部仍能取得元素上的可变集合。评审公开接口到底允许哪些修改,并设计能验证整个返回对象关系的验收。- 提交内容
- 对象关系图、修改能力表和测试样本。
- 怎样算完成
- 嵌套元素层次均被考虑
- 复制政策由业务需求决定
练到的知识点浅复制元素共享
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
完成并发基础后:排行榜每秒更新、查询频繁,比较复制快照与共享有序结构的方案,定义同分规则、更新时间和资源成本。
- 提交内容
- 方案比较、快照合同与压力验收计划。
- 怎样算完成
- 结果稳定性与新鲜度区分
- 估算复制和更新成本
练到的知识点List.copyOf 快照 · 浅复制元素共享 · comparing/thenComparing · 有序集合等价关系
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 条件变化
排序键从不可变创建序号改为随时变化的热度值。比较“一次生成排名”与“持续维护有序结构”的合同,说明元素修改后如何验证排序。
- 提交内容
- 两种排名模型与事件序列。
- 怎样算完成
- 排序成立的时点明确
- 共享对象更新与次序维护责任清楚
练到的知识点浅复制元素共享 · 比较器传递性
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
Java 17 API:Collections (新标签页)↗
阅读不可修改视图、排序、二分查找与同步包装的前提条件。
- 实践参考文档英文
Java 17 API:Arrays (新标签页)↗
查阅
copyOf、equals、sort、binarySearch和asList的具体契约。 - 深入参考文档英文
Java 17 API:Comparator (新标签页)↗
阅读 comparing、
thenComparing与排序契约,避免减法比较溢出。 - 深入参考文档英文
Java 17 API:TreeMap (新标签页)↗
阅读有序映射、红黑树复杂度和比较器与 equals 一致性的要求。
- 深入参考文档英文
OpenJDK 17u java.util.Collections (新标签页)↗
定位
unmodifiableList的包装选择与底层委托,区分读取视图和复制快照。 - 深入参考文档英文
Java17 List API (新标签页)↗
阅读 of、
copyOf、subList与可修改操作的约定。 - 深入参考文档英文
Java17 Map API (新标签页)↗
理解
keySet、values、entrySet与底层映射关联。