本节目标:能把「循环 + 临时集合 + 计数器」的代码改写成一条 Stream 管道,理解中间操作与终止操作的分工、惰性求值的时机,并会用
Collectors做分组、计数和统计。
适用版本:JDK 25 LTS(Temurin 25.0.5);Stream.toList()在 JDK 16 起可用,JDK 21 同样支持。
上一节留下的问题
Lambda 让单个条件变短了,但多个步骤仍然要写成嵌套循环。比如「找出 2015 年以后出版的书,按年份排序,取标题」:
List<Book> candidates = new ArrayList<>();
for (Book book : books) {
if (book.year() > 2015) {
candidates.add(book);
}
}
candidates.sort(Comparator.comparingInt(Book::year));
List<String> titles = new ArrayList<>();
for (Book book : candidates) {
titles.add(book.title());
}
三个临时集合、两次遍历,中间还多了一个只用来传递数据的 candidates。再叠加「按作者分组计数」「求平均出版年」,代码会迅速膨胀,而且每一步的意图都被循环细节盖住。
Stream 的思路是把这些步骤串成一条流水线:数据从集合流出来,经过若干次转换,最后汇总成结果。每一步只描述「做什么」,不描述「怎么遍历」。
管道的三段式
一条 Stream 管道由三部分组成:
- 源(source):集合、数组、
Stream.of、Stream.iterate等。 - 中间操作(intermediate operation):
filter、map、sorted、limit等,返回新的 Stream。 - 终止操作(terminal operation):
toList、collect、count、forEach、findFirst等,触发真正的执行并产出结果。
把上面的循环改写成管道:
List<String> titles = books.stream()
.filter(book -> book.year() > 2015)
.sorted(Comparator.comparingInt(Book::year).thenComparing(Book::title))
.map(Book::title)
.toList();
读法是从上到下:取出书 → 留下 2015 年以后的 → 按年份和标题排序 → 取标题 → 收集成列表。没有临时集合,也没有显式的下标。
中间操作可以有很多个,终止操作只能有一个。写完终止操作,这条 Stream 就被消费掉了,不能再次使用。
常用中间操作
| 操作 | 作用 | 备注 |
|---|---|---|
filter(Predicate) | 保留满足条件的元素 | 惰性 |
map(Function) | 一对一转换 | 惰性 |
flatMap(Function) | 一对多转换后展平 | 惰性 |
distinct() | 去重 | 依赖 equals |
sorted() / sorted(Comparator) | 排序 | 有状态,需要看到全部元素 |
limit(n) / skip(n) | 截断 / 跳过 | 可短路 |
peek(Consumer) | 观察元素 | 只用于调试 |
mapToInt / mapToLong / mapToDouble | 转成原始类型流 | 便于统计 |
flatMap 用来把「列表的列表」压平。library 里如果有多个书架,每个书架是一个 List<Book>,可以这样合并:
List<List<Book>> shelves = List.of(
DemoData.BOOKS.subList(0, 2),
DemoData.BOOKS.subList(2, 6));
List<Book> all = shelves.stream()
.flatMap(list -> list.stream())
.toList();
System.out.println(all.size());
输出 6。map 会把每个 List<Book> 变成一个 Stream 元素,得到 Stream<Stream<Book>>;flatMap 把内层 Stream 展开,得到 Stream<Book>。
mapToInt 则把对象流转成 IntStream,后面可以直接调 average()、sum()、summaryStatistics():
int total = books.stream().mapToInt(Book::year).sum();
OptionalDouble average = books.stream().mapToInt(Book::year).average();
终止操作与收集
最常见的终止操作是 toList():
List<String> titles = books.stream()
.map(Book::title)
.toList();
toList() 返回一个不可修改的列表。往里 add 会抛 UnsupportedOperationException,这是很多人在第一次用 Stream 时踩的坑。需要可变列表时,用 collect(Collectors.toCollection(ArrayList::new))。
Collectors 提供了更丰富的汇总方式:
Map<String, Long> countByAuthor = books.stream()
.collect(Collectors.groupingBy(Book::author, Collectors.counting()));
double averageYear = books.stream()
.collect(Collectors.averagingInt(Book::year));
String allTitles = books.stream()
.map(Book::title)
.collect(Collectors.joining(" | "));
IntSummaryStatistics stats = books.stream()
.collect(Collectors.summarizingInt(Book::year));
groupingBy 的第一个参数是分类函数,第二个参数是下游收集器。只写 groupingBy(Book::author) 会得到 Map<String, List<Book>>;加上 counting() 就得到每个作者的书数。
joining 适合拼消息、拼日志、拼 SQL 的 IN 列表(值需要自己转义)。summarizingInt 一次返回 count、sum、min、average、max,比手写四个变量清楚。
reduce 用于把流归约成一个值,比如累加:
int total = books.stream()
.map(Book::year)
.reduce(0, Integer::sum);
对原始类型,mapToInt(...).sum() 更直接;reduce 更适合自定义的合并逻辑。
惰性求值:什么时候真正执行
中间操作是惰性的。只写 filter、map 而不写终止操作,什么都不会发生:
Stream<Book> pipeline = DemoData.BOOKS.stream()
.peek(book -> System.out.println(" peek " + book.title()))
.filter(book -> book.year() > 2015);
System.out.println("before terminal");
long count = pipeline.count();
System.out.println("count = " + count);
本机 JDK 25 输出:
before terminal
peek Effective Java
peek Java Puzzlers
peek Java 并发编程实战
peek 深入理解 Java 虚拟机
peek Java 8 实战
peek Java 编程思想
count = 3
before terminal 先打印,说明管道在创建时没有执行。调用 count() 后,元素才逐个流过 peek 和 filter。这也是 peek 只适合调试的原因:它依赖终止操作触发,而且不保证在并行流里的执行顺序。
惰性还带来短路能力。findFirst、anyMatch、limit 不需要处理全部元素,遇到满足条件的结果就停止。12.3 会专门用 Stream.iterate 演示这一点。
在 library 里落地
把查询逻辑收进 LibraryQueries,每个方法就是一条管道:
public static List<String> titlesPublishedAfter(Collection<? extends Book> books,
int year) {
return books.stream()
.filter(book -> book.year() > year)
.sorted(Comparator.comparingInt(Book::year).thenComparing(Book::title))
.map(Book::title)
.toList();
}
public static Map<String, Long> countByAuthor(Collection<? extends Book> books) {
return books.stream()
.collect(Collectors.groupingBy(
book -> book.author().isEmpty() ? "未署名" : book.author(),
TreeMap::new,
Collectors.counting()));
}
public static double averageYear(Collection<? extends Book> books) {
return books.stream()
.mapToInt(Book::year)
.average()
.orElseThrow(() -> new IllegalArgumentException("没有图书"));
}
public static List<Book> newest(Collection<? extends Book> books, int limit) {
return books.stream()
.map(Book.class::cast)
.sorted(Comparator.comparingInt(Book::year).reversed())
.limit(limit)
.toList();
}
countByAuthor 显式传入 TreeMap::new,是为了让输出按作者名排序,方便测试和日志比对;默认的 HashMap 不保证顺序。newest 里的 map(Book.class::cast) 是为了把 ? extends Book 的捕获类型收窄成 Book,否则 toList() 的返回类型对不上,这一点在 11.2 讲过。
仓储的按作者查询也可以从 11.1 的循环改成管道:
@Override
public List<Book> findByAuthor(String author) {
return findAll().stream()
.filter(book -> book.author().equals(author))
.toList();
}
服务层的搜索同样复用管道:
public List<Book> search(Predicate<Book> criteria) {
return books.findAll().stream().filter(criteria).toList();
}
跑一遍完整查询:
System.out.println("titles = "
+ LibraryQueries.titlesPublishedAfter(DemoData.BOOKS, 2015));
Map<String, Long> byAuthor = LibraryQueries.countByAuthor(DemoData.BOOKS);
System.out.println("byAuthor = " + byAuthor);
System.out.printf("averageYear = %.1f%n",
LibraryQueries.averageYear(DemoData.BOOKS));
IntSummaryStatistics stats = DemoData.BOOKS.stream()
.mapToInt(Book::year)
.summaryStatistics();
System.out.println("stats = " + stats);
System.out.println("newest = " + LibraryQueries.newest(DemoData.BOOKS, 3).stream()
.map(Book::title)
.toList());
本机 JDK 25 输出:
titles = [Java 8 实战, Effective Java, 深入理解 Java 虚拟机]
byAuthor = {Brian Goetz=1, Bruce Eckel=1, Joshua Bloch=2, Raoul-Gabriel Urma=1, 周志明=1}
averageYear = 2012.8
stats = IntSummaryStatistics{count=6, sum=12077, min=2005, average=2012.833333, max=2019}
newest = [深入理解 Java 虚拟机, Effective Java, Java 8 实战]
titles 里 2016 年的《Java 8 实战》排在 2018 年的《Effective Java》前面,因为管道先按年份升序;newest 反过来取最近的 3 本。averageYear 用 %.1f 四舍五入到一位小数,stats 保留完整精度。
常见错误与边界
错误一:重复消费同一条 Stream
Stream<String> stream = List.of("a", "b").stream();
System.out.println(stream.count());
System.out.println(stream.count());
第二次调用抛出:
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:260)
Stream 不是集合,它是一条只能走一次的流水线。需要多次使用,要么每次从集合重新 stream(),要么先把结果收集成 List。
错误二:修改 toList() 的结果
List<String> list = List.of("a", "b").stream().toList();
list.add("c");
Exception in thread "main" java.lang.UnsupportedOperationException
at java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java:159)
toList() 返回不可变列表。需要继续修改时,用 new ArrayList<>(stream.toList()),或者 collect(Collectors.toCollection(ArrayList::new))。
错误三:toMap 遇到重复键
List<String> values = List.of("a", "b", "a");
Map<String, Integer> map = values.stream()
.collect(Collectors.toMap(value -> value, value -> 1));
Exception in thread "main" java.lang.IllegalStateException: Duplicate key a (attempted merging values 1 and 1)
toMap 默认不允许重复键。需要合并时传第三个参数:
Map<String, Integer> map = values.stream()
.collect(Collectors.toMap(value -> value, value -> 1, Integer::sum));
这样 a 的值会变成 2。
边界:不要默认使用 parallelStream
parallelStream() 会把任务拆到多个线程,但不是所有场景都更快。数据量小、操作有状态、依赖顺序、共享可变状态时,并行反而增加开销或引入错误。卷三讲并发时会展开;卷一只用顺序流,需要时再显式评估。
另外,peek 不应该用来做副作用,比如写文件、改共享集合。它的契约是「观察」,不是「执行操作」;在并行流里顺序也不保证。真正的副作用应该放在终止操作 forEach 里,或者干脆用循环。
小结
- Stream 管道由源、中间操作、终止操作三部分组成;中间操作返回新流,终止操作触发执行。
filter、map、flatMap、sorted、limit用来描述转换,toList、collect、count、findFirst用来产出结果。- 中间操作是惰性的,没有终止操作就什么都不做;
findFirst、anyMatch、limit可以短路。 Collectors.groupingBy、counting、averagingInt、summarizingInt、joining覆盖了大多数分组和统计需求。toList()返回不可变列表;需要可变列表时用Collectors.toCollection(ArrayList::new)。- 一条 Stream 只能消费一次,
toMap默认拒绝重复键,parallelStream不是默认选项。
Stream 解决了「怎么把集合转成结果」,但「找不到」这件事还没有类型层面的表达。findFirst() 返回 Optional,findById 也返回 Optional;下一节讲怎么用 Optional 处理缺失值,以及它和惰性求值的关系。
阅读导航:上一节:12.1 Lambda 与函数式接口 · 下一节:12.3 Optional 与惰性求值 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。