Java 集合、迭代与 Stream
一组订单进入 Java 程序后,可以同时形成几种结构:请求中的明细保留顺序和重复项,已授予的权限只保留唯一值,按订单号查询需要键值映射,待处理任务按队列规则出队。它们对应的接口并不相同。
java.lang.Iterable<E>
└─ java.util.Collection<E>
├─ List<E> 有位置、有相遇顺序,通常允许重复
├─ Set<E> 元素唯一
│ └─ SortedSet / NavigableSet
└─ Queue<E> 等待处理的元素
└─ Deque<E> 两端插入与移除,可表达 FIFO 或 LIFO
java.util.Map<K,V> 键到值的映射;不继承 Collection
└─ SortedMap / NavigableMap接口描述调用者依赖的数据语义,实现类决定这些语义怎样落到数组、哈希表、树或链式结构。遍历器再把结构中的元素交给循环或 Stream;Stream 本身不保存这组订单,而是组织一次从数据源到结果的计算。
集合接口先描述数据语义
选择集合时,先写清业务需要观察什么。List、Set、Queue 和 Map 不是四种性能档位。
| 业务要求 | 首选接口 | 需要继续确定的条件 |
|---|---|---|
| 按位置读取,保留重复项和相遇顺序 | List<E> | 中间插入是否频繁,是否需要随机访问 |
| 保证元素唯一 | Set<E> | 是否还要保留首次出现顺序或按值排序 |
| 根据唯一键读取一个值 | Map<K,V> | 是否要固定迭代顺序、范围查询或并发原子更新 |
| 先进先出处理 | Queue<E> / Deque<E> | 容量是否有界,空队列时返回特殊值还是抛异常 |
| 后进先出处理 | Deque<E> | 使用 push/pop/peek,通常不再选旧的 Stack |
| 按优先级取出下一项 | PriorityQueue<E> | 比较规则、相同优先级的处理和容量策略 |
| 查找最接近的键或一个键范围 | NavigableMap<K,V> / NavigableSet<E> | 自然顺序还是显式 Comparator |
Java 17 集合框架概览把 Collection 与 Map 分成两套接口体系,并列出通用实现。代码可以在字段和参数上暴露接口,把构造实现留在拥有数据结构的模块:
private final Map<OrderId, Order> orders = new HashMap<>();
List<Order> findAll(List<OrderId> ids) {
return ids.stream()
.map(orders::get)
.filter(Objects::nonNull)
.toList();
}这里的返回类型承诺“有顺序的订单序列”,没有承诺调用者拿到 ArrayList。若公共 API 返回具体实现,调用者可能逐渐依赖它的可变性、序列化形态或性能特征,后续就很难替换。
常用实现的差异落在访问方式上
下表中的复杂度只描述常见实现及其公开前提,不是接口的统一保证。数据规模、哈希质量、扩容、缓存局部性和 JVM 版本都会影响实际成本。
| 实现 | 稳定语义与常见操作 | 选择时要看到的代价 |
|---|---|---|
ArrayList | 按索引访问通常为常数时间,尾部追加摊销常数时间 | 中间插入或删除需要移动后续元素;容量可能大于元素数 |
LinkedList | 已经持有节点位置对应的迭代器时,局部插入删除不移动数组 | 按索引查找仍要遍历;每个节点有额外对象与引用,队列通常优先评估 ArrayDeque |
ArrayDeque | 两端添加、查看和移除;适合栈与普通内存队列 | 不接收 null;不是并发队列,也不提供阻塞等待 |
HashSet / HashMap | 哈希质量正常时,基本查找和更新具有预期常数时间 | 不承诺迭代顺序;键或元素的相等性必须稳定 |
LinkedHashSet / LinkedHashMap | 在哈希结构外维护确定的相遇顺序 | 多维护一组链接;LinkedHashMap 还可显式配置访问顺序 |
TreeSet / TreeMap | 按自然顺序或 Comparator 排序,支持范围和邻近查询 | 基本操作通常为对数时间;比较关系决定“相同”键 |
PriorityQueue | peek/poll 得到比较规则下的队首 | 迭代器不保证按优先级顺序遍历;要有序结果需逐个 poll 或另行排序 |
队列接口通常成对提供方法。add/remove/element 在容量不足或队列为空等情形抛异常,offer/poll/peek 使用返回值表达不能插入或没有元素。调用者要按协议选择,不能用 null 混充“没有结果”,因为不少队列实现本就禁止 null。
有序与排序也是两件事。LinkedHashMap 可以保留插入顺序,TreeMap 根据键的比较关系排序;HashMap 的某次输出看似稳定,也没有形成顺序契约。接口需要稳定输出时,应在容器选择或结果排序处显式建立顺序,而不是把当前散列分布写进测试快照。
哈希与排序结构依赖元素契约
哈希容器先用 hashCode 缩小位置,再用 equals 确认逻辑相等。相等对象必须有相同哈希值;对象作为键或 Set 元素留在容器期间,参与 equals/hashCode 的字段还必须保持稳定。前一篇对象模型中的可变键问题会在这里直接表现为 get、contains 或 remove 找不到仍存放在容器内的对象。
排序容器使用 Comparable 或 Comparator 判断位置。比较结果为零时,TreeSet 会把两个值视作相同元素,TreeMap 会把它们视作相同键。若比较关系与 equals 不一致,集合仍可按自身比较契约工作,但 Set 的相等性观察会变得反直觉;领域键通常应让两套关系一致,或只在局部排序结果中使用不一致的展示比较器。
Comparator<Order> byCreatedAtThenId = Comparator
.comparing(Order::createdAt)
.thenComparing(Order::id);第二个比较字段不是装饰。两个不同订单拥有相同创建时间时,只有 thenComparing(Order::id) 才能给排序容器一个稳定的非零区分。
空值策略也属于具体实现和工厂契约。HashMap 接受一个 null 键和 null 值,ConcurrentHashMap 禁止 null 键和值,List.of、Set.of、Map.of 与相应 copyOf 工厂拒绝 null。Map API还规定了可变键风险、三种 collection view 和不可修改工厂的行为。跨实现迁移前,应先把 null 的业务含义改为明确的缺失、空集合、Optional 或领域状态。
Java 21 增加了 SequencedCollection,统一表达首元素、尾元素和反向视图。这是版本相关 API;Java 17 代码仍通过 List、Deque、NavigableSet 及具体有序实现表达相应语义。
遍历时要知道谁拥有结构
下面三个变量都不能通过自身的 add 成功修改,却只有一个保存了创建时的元素引用序列:
var source = new ArrayList<>(List.of("CREATED", "PAID"));
var view = Collections.unmodifiableList(source);
var snapshot = List.copyOf(source);
source.add("SHIPPED");
System.out.println(view); // [CREATED, PAID, SHIPPED]
System.out.println(snapshot); // [CREATED, PAID]source: [CREATED, PAID] -- add(SHIPPED) --> [CREATED, PAID, SHIPPED]
│ │
└──── unmodifiable view ──────┘ 仍观察同一 backing list
copyOf: [CREATED, PAID] 创建时复制元素引用Collections.unmodifiableList(source) 是源列表的不可修改视图:经由 view 调用修改方法会抛 UnsupportedOperationException,持有 source 的代码仍能改变它所观察的结构。List.copyOf(source) 产生不可修改结果,与源集合后续结构变化分离;如果元素对象本身可变,两者仍可能指向同一个元素,所以这是浅层快照,不是对象图深拷贝。List API给出了 of/copyOf 的不可修改与空值规则。
几种常见 API 返回的并非普通独立列表:
| API | 返回对象与源数据的关系 | 典型误判 |
|---|---|---|
Arrays.asList(array) | 背靠数组的固定大小列表,可 set,不能增删 | 把 UnsupportedOperationException 误判为元素不可变 |
list.subList(from, to) | 原列表指定区间的视图 | 从原列表绕过 subList 做结构修改后继续使用视图 |
map.keySet() | 键视图;从视图移除会修改 Map | 以为 clear 只清理临时 Set |
map.values() | 值视图;可能包含重复值 | 把它强行当成 Set |
map.entrySet() | 键值项视图 | 在 Map 已改变后长期缓存 Entry |
Collections.unmodifiableList(list) | 不可经该引用修改的视图 | 误认为源列表也被冻结 |
List.copyOf(collection) | 与源结构分离的不可修改结果 | 误认为元素对象也被深复制 |
给字段、缓存或异步任务传入集合时,要先决定所有权:调用期间只读可以使用清晰的接口约定;需要跨越调用方生命周期时通常复制;确实需要共享实时变化时则公开一个有同步协议的对象,而不是用“unmodifiable”掩盖共享可变状态。
增强 for 使用 Iterator,结构性删除要走同一游标
对 Iterable 使用增强 for,语言层面会获得一个 Iterator 并反复调用 hasNext/next。JLS 17 的增强 for 规则给出了这种翻译。需要在遍历中删除当前元素时,应直接使用同一个 Iterator:
for (Iterator<Order> cursor = orders.iterator(); cursor.hasNext();) {
Order order = cursor.next();
if (order.expired()) {
cursor.remove();
}
}Iterator.remove删除最近一次 next 返回的元素;每次 next 最多删除一次。增强 for 隐藏了 Iterator,循环体中直接调用 orders.remove 会从另一条路径改变结构。ArrayList 等 fail-fast Iterator 常在后续游标操作中抛 ConcurrentModificationException,但这个检测只做 best effort,不能保证发现所有竞态,也不能替代锁、快照或并发集合。
几个线程需要同时更新和遍历时,集合选择必须携带并发语义。Collections.synchronizedList 要求所有访问通过包装器,遍历期间还要按文档在包装器上手工同步。CopyOnWriteArrayList 让遍历器观察创建时的数组快照,适合遍历远多于写入的小型集合。ConcurrentHashMap 的遍历器不会抛 ConcurrentModificationException,可以反映遍历创建时或创建后的部分更新,但不提供整个 Map 在同一时刻的原子快照。
线程安全容器也不能让任意多步代码自动原子化:
// 两个线程都可能先观察到不存在
if (!counts.containsKey(key)) {
counts.put(key, 1);
}
// ConcurrentMap 提供一个原子复合操作
counts.merge(key, 1, Integer::sum);ConcurrentMap API规定了 putIfAbsent、条件替换、条件删除等原子保证和跨线程内存一致性;ConcurrentHashMap API进一步说明检索、更新、空值和弱一致遍历。compute/merge 的函数应短小,不能递归更新同一映射,也不应把阻塞远程调用放进原子更新路径。
Spliterator 同时描述遍历与分割能力
Iterator 以单一游标顺序取值,Spliterator 还可以用 trySplit 把未处理元素切成另一部分。Stream 由此获得顺序遍历和潜在并行分割的入口。
Spliterator<Order> split = orders.spliterator();
boolean ordered = split.hasCharacteristics(Spliterator.ORDERED);
long estimate = split.estimateSize();
Spliterator<Order> prefix = split.trySplit();常见特征包括:ORDERED 表示存在相遇顺序,DISTINCT 表示元素互异,SORTED 表示按比较关系排序,SIZED 表示估算值是精确元素数,IMMUTABLE 与 CONCURRENT 描述遍历期间结构变化的策略。没有报告某个特征,不等于反向特征必然成立;自定义数据源也不能为了获得优化而虚报特征。Spliterator API把它定义为“遍历并分割元素来源”的对象。
分割是否均匀会影响并行执行。数组通常容易按区间二分,链式或生成式来源可能难以形成大小接近的分区。能够调用 parallel() 只表示允许并行执行,不表示数据源适合切分,也不表示任务足以抵消调度与合并成本。
Stream 把遍历组织成一次计算
一条 Stream pipeline 由一个来源、零到多个中间操作和一个终止操作组成:
List<Order>
└─ stream() source
├─ filter(Order::paid) intermediate / lazy
├─ map(Order::customerId) intermediate / lazy
├─ distinct() stateful intermediate
└─ toList() terminal / trigger中间操作返回新的 Stream 描述,没有终止操作时通常不会拉取源元素。终止操作触发遍历后,每个元素尽可能沿着整条链向下流动;findFirst、anyMatch、limit 等短路操作满足结果后可以停止读取剩余元素。Stream 因此适合表达一次计算,不是可以反复读取的集合。
Stream<Order> paid = orders.stream().filter(Order::paid);
long count = paid.count();
paid.findFirst(); // 已消费;通常抛 IllegalStateException要运行第二次,应从可重放的数据源重新创建 pipeline;文件、网络通道等资源型 Stream 还要用 try-with-resources 按其 API 关闭。Stream API明确规定单次操作、延迟执行、non-interference 和多数行为参数的 stateless 要求。
中间操作描述值变化,副作用会破坏执行自由
常用中间操作可以按结果变化理解:
| 操作 | 产生的序列 | 需要注意的状态 |
|---|---|---|
filter | 保留满足条件的元素 | predicate 应无状态且不修改数据源 |
map | 一个输入变为一个输出 | 适合纯转换,不在其中写外部 List |
flatMap | 每个输入展开成零到多个输出 | 内层 Stream 的关闭和数据规模 |
distinct | 按 equals 去重 | 有状态;有序并行时可能需要大量缓冲 |
sorted | 按自然顺序或 Comparator 排序 | 有状态;通常需要看到全部输入 |
limit/skip | 截取相遇顺序的一段 | 有序并行执行可能付出更高协调成本 |
peek | 元素保持不变,附加观察动作 | 主要用于调试;不要把业务写入依赖其执行次数 |
实现可以省略不影响最终结果的阶段。例如已知大小的 List.stream().peek(...).count() 可能直接返回大小而不逐个调用 peek。将发消息、记账或写数据库放进 map/peek,会让业务正确性依赖 pipeline 的优化、短路、重试和并行调度。转换函数应根据输入返回值;真正的外部写入留在具有幂等、事务和错误处理协议的边界。
数据源在 pipeline 执行期间也应保持不变,除非来源明确设计为 concurrent。下面的 predicate 修改同一个 orders,违反 non-interference,结果可能是异常,也可能是遗漏或其他不可依赖的表现:
orders.stream()
.filter(order -> {
orders.remove(order);
return true;
})
.toList();需要过滤并形成新集合时,用 filter(...).toList();需要原地删除时,用 removeIf 或正确的 Iterator;需要并发生产和消费时,选择相应并发结构和协议。
相遇顺序来自数据源与流水线
List 和 LinkedHashSet 的 spliterator 通常报告 ORDERED,HashSet 没有相遇顺序。sorted 可以为下游建立顺序,unordered 则允许放弃既有顺序约束,但不会自动把 pipeline 改成并行。
并行有序 Stream 的 forEach 不保证动作按相遇顺序发生,forEachOrdered 才保留该顺序,并因此增加协调。findFirst 选择相遇顺序中的第一个元素,findAny 允许选择任意元素。选择哪一个取决于业务结果是否要求稳定顺序,而不是哪一个方法名更短。
distinct、sorted 和某些有序切片需要跨元素状态。数据很大时,它们可能把延迟或堆占用推高;先确认来源是否已经唯一或有序,能否把过滤放到排序前,以及业务是否允许 unordered。仅从方法链长度看不出内存成本。
终止操作决定结果形态与冲突策略
Java 17 中,Stream.toList() 返回不可修改 List,并保留已有相遇顺序;Collectors.toList() 不保证返回 List 的具体类型、可变性、可序列化性或线程安全性。需要明确实现时使用 toCollection(ArrayList::new),需要拒绝后续结构修改且拒绝 null 时可用 Collectors.toUnmodifiableList()。Collectors API分别给出了这些结果契约。
List<Order> mutable = stream.collect(Collectors.toCollection(ArrayList::new));
List<Order> unmodifiable = stream.toList();把元素收集成 Map 时,键可能重复。两参数 toMap(keyMapper, valueMapper) 遇到重复键会抛 IllegalStateException;修复方式由业务语义决定:
// 同一客户的金额需要累计
Map<CustomerId, Money> total = orders.stream().collect(
Collectors.toMap(
Order::customerId,
Order::amount,
Money::plus,
LinkedHashMap::new));键本应唯一时,保留异常并在输入边界给出可定位的重复键错误。一个键允许对应多个值时,使用 groupingBy 得到 Map<K, List<V>>。多个值可以按业务规则合并时,提供 merge function;结果还要固定 Map 类型或顺序时,再同时提供 map factory。
随意使用 (oldValue, newValue) -> newValue 会静默覆盖数据,而且并行或上游顺序变化时可能选择不同值。只有业务明确采用“最后写入覆盖”,并且相遇顺序已经定义时,这个合并函数才有完整含义。
并行归约要求可以安全拆分和合并
parallelStream() 或 parallel() 允许实现把来源切成多个分区,在各分区独立计算后合并结果。正确的 reduce(identity, accumulator) 要求 identity 是单位元,accumulator 满足结合律,并且函数无状态、不干扰来源:
long total = amounts.parallelStream()
.mapToLong(Money::cents)
.sum();整数加法的 0 是单位元,按任意分组相加得到同一数学结果。减法不满足结合律:
(20 - 5) - 2 = 13
20 - (5 - 2) = 17顺序循环隐含一种固定分组,并行归约会改变分组方式;把顺序结果当作期望值再责怪 parallel,实际遗漏的是归约契约。浮点加法虽然数学上满足结合律,但有限精度下重新分组也可能改变末位,金融金额通常应使用整数最小单位或明确舍入的十进制类型。
collect 用 supplier、accumulator、combiner 和可选 finisher 管理可变归约。标准 Collectors.toList() 在并行时会创建隔离的中间容器再合并;这不同于在 parallel().forEach(sharedList::add) 中让多个线程共同写一个 ArrayList。后者存在数据竞争,换成 synchronizedList 也只解决单次添加安全,不能自动解决动作顺序、失败回滚和外部副作用。
是否采用并行 Stream,需要在目标 JDK、数据规模和硬件上测量,同时核对:来源能否均匀分割、每个元素计算是否足够重、pipeline 是否有顺序屏障、归约是否满足代数约束、线程是否会阻塞、运行环境是否与其他任务争用处理器。Stream API 不承诺专属线程池;普通服务代码不应借 parallelStream 暗中建立隔离、超时或背压策略。
用固定 JDK 复现集合与流水线故障
配套实验提供 CollectionsStreamLab.java、run-docker.sh 和底层 run.sh。推荐环境是安装 Docker Engine 的 Linux 开发机,执行身份是能够访问 Docker daemon 的普通用户。从仓库根目录运行:
export LAB_DIR="$PWD/docs/.vuepress/public/examples/backend-development/java-collections-stream"
test "$(id -u)" -ne 0 || {
echo '请切换到能够访问 Docker 的普通用户' >&2
exit 1
}
test -r "$LAB_DIR/run-docker.sh" || exit 1
bash "$LAB_DIR/run-docker.sh"LAB_DIR 是本地示例目录,不要指向生产挂载点。包装脚本默认使用 eclipse-temurin:17.0.20_8-jdk@sha256:a27c79d44326d5f689668df5fedfee487652066d2a91e172747056cc7fbee6fc,以宿主 UID/GID 运行容器,禁用网络,移除 Linux capabilities,启用 no-new-privileges,并把根文件系统与源码挂载设为只读。只有 128 MiB 的 /tmp 可写,脚本退出时删除其中的编译目录。镜像来源可在 Eclipse Temurin 官方镜像说明与 Adoptium Temurin 发布页核对。
底层脚本先确认 java 与 javac 都是 Java 17,再使用 --release 17 -Xlint:all -Werror 编译单文件程序。正常模式建立相同的一组订单状态和金额,依次验证首次出现顺序去重、view/snapshot、Iterator 删除、pipeline 延迟、重复客户金额合并、ConcurrentMap 原子 merge、Spliterator 特征和并行整数求和。
预期输出中的完整补丁版本由实际镜像打印,不应硬编码到断言;行为摘要如下:
deduplicated=[CREATED, PAID, SHIPPED]
ownership=source:3 view:3 snapshot:2
iterator-remove=[CREATED, PAID]
lazy-before-terminal=0
lazy-after-terminal=2 result=[paid, shipped]
merged-by-customer={alice=1500, bob=800}
atomic-merge=CREATED:2 PAID:1 SHIPPED:1
spliterator=ORDERED:true DISTINCT:true
associative-sum=sequential:50005000 parallel:50005000随后四个子进程分别运行稳定负例。失败是实验输入的一部分,外层脚本只有看到约定状态码和诊断行才继续:
mode=cme exception=ConcurrentModificationException
mode=duplicate-key exception=IllegalStateException
mode=reuse-stream first-count=2 exception=IllegalStateException
mode=bad-reduction associative=false left=13 right=17 sequential=-78 parallel=0
PASS最后一个 parallel 数值只记录当前 JDK 的一次分组结果,脚本不要求它等于零;稳定断言是 left=13 与 right=17 已经证明减法不满足结合律。若正常求和不一致,实验直接失败;若错误归约的某次顺序与并行结果碰巧相同,也不能反过来证明减法满足契约。
本机已有 JDK 17 时,可以绕过 Docker,但要显式把同一 JDK 根目录交给脚本:
export LAB_DIR="$PWD/docs/.vuepress/public/examples/backend-development/java-collections-stream"
export JDK17_HOME="/opt/jdk-17"
test -x "$JDK17_HOME/bin/java" || exit 1
bash "$LAB_DIR/run.sh"出现异常时,按现象回到拥有结构或组织计算的对象:
| 现象 | 首先检查 | 下一步 |
|---|---|---|
ConcurrentModificationException | 是否在 Iterator 之外结构性修改 backing collection;是否有并发写 | 单线程删除改用 Iterator.remove/removeIf;并发读写改为快照、锁或匹配语义的并发集合 |
UnsupportedOperationException | 值来自 of/copyOf/toList、unmodifiable view、Arrays.asList 还是自定义实现 | 确实需要修改时在所有权边界复制到 ArrayList;不为“防异常”盲目复制所有输入 |
toMap 抛重复键 | key mapper 是否把多个业务对象映射到同一个键 | 在唯一键入口拒绝、改用 groupingBy,或提供有业务含义的 merge function |
NullPointerException 出现在构造或收集 | 目标工厂、实现或 collector 是否禁止 null | 在输入边界清理空值并建立明确缺失语义,不依赖换回允许 null 的实现 |
| 顺序偶尔变化 | 来源是否有 encounter order,是否使用 HashMap/HashSet、unordered、parallel forEach | 需要顺序时选择有序来源、显式排序或使用 forEachOrdered,并把顺序写入契约测试 |
| Stream 已消费 | 是否保存并二次调用同一 Stream | 保存可重放的数据源或 Supplier<Stream<T>>,每次重新建立 pipeline |
| 并行结果变化 | identity、associativity、共享状态、浮点舍入与顺序约束 | 改成合法 reduce/collector;把外部副作用移出 pipeline;再用目标数据测量顺序与并行版本 |
| 延迟或内存突增 | sorted/distinct/groupingBy/collect 是否需要保留大量状态 | 先过滤,利用来源已有索引/顺序,分批处理;用 JFR/JMH 等工具确认瓶颈后再调整 |
| ConcurrentMap 仍丢更新 | 是否把多个线程安全方法拼成非原子 read-modify-write | 使用 putIfAbsent/compute/merge/replace 等匹配语义的原子复合方法 |
Docker 在 Java 版本输出前失败时,检查普通用户的 daemon 权限、镜像摘要和 CPU 平台。出现 FAIL: 时,提示行就是未满足的断言;脚本会清理临时目录,可单独执行对应 mode 复现。不要把 ConcurrentModificationException 消失、某个 HashMap 顺序稳定或错误并行归约碰巧相等当作修复证据,修复要落到所有权、容器契约或归约规则。
权威资料与规范地址
以下地址用于核对 Java 17 的集合、遍历和 Stream 契约;Sequenced Collections 链接只说明 Java 21 的版本差异。实验环境另列官方镜像与发行物入口。
集合、视图与遍历
| 资料 | 用途 |
|---|---|
| Java 17 集合框架概览 | Collection/Map 层次与通用实现 |
Map API | 键值映射、collection views、可变键与不可修改工厂 |
List API | 顺序、of/copyOf 与不可修改列表 |
JLS 17:增强 for | Iterable 上增强 for 的语言翻译 |
Iterator API | 游标遍历与 remove 契约 |
Spliterator API | 遍历、分割与特征位 |
ConcurrentMap API | 原子复合操作与内存一致性 |
ConcurrentHashMap API | 空值、并发检索更新与弱一致遍历 |
Java 21 SequencedCollection | 首尾操作与反向视图的版本入口 |
Stream 与收集器
| 资料 | 用途 |
|---|---|
Stream API | pipeline、延迟、单次消费、顺序与归约 |
Collectors API | 结果集合、分组、重复键与 merge function |
实验环境
| 资料 | 用途 |
|---|---|
| Eclipse Temurin 官方镜像 | JDK 容器镜像标签与使用方式 |
| Adoptium Temurin 发布页 | 发行版、平台和补丁核对 |
