《Java 编程入门》12.2 Stream 管道

从循环加临时列表的搜索、排序、统计代码出发,讲清 Stream 管道的源、中间操作与终止操作,说明惰性求值何时真正执行;再用 filter、map、sorted、groupingBy、summarizingInt 改造 library 的查询与统计,并给出流复用、不可变 toList、toMap 重复键的真实报错。

本节目标:能把「循环 + 临时集合 + 计数器」的代码改写成一条 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 管道由三部分组成:

  1. 源(source):集合、数组、Stream.of、Stream.iterate 等。
  2. 中间操作(intermediate operation):filter、map、sorted、limit 等,返回新的 Stream。
  3. 终止操作(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 与惰性求值 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发