Skip to content

Stream API ​

S1-026必学进阶约 90 分钟JDK 21编程语言基础 · 函数式与 Stream

1. 本节定位 ​

Stream 是 Java 8 里最改变写代码方式的特性。它把"对一组数据做什么"和"怎么循环"分开:你只描述要什么(过滤、转换、分组),不再手写索引和临时集合。

本节的重点不是背 API,而是三件事:看懂流的三段式结构、理解惰性求值、知道什么时候不该用流。

2. 学习目标 ​

完成本节后,你应该能够:

  • 写出"创建 → 中间操作 → 终端操作"的完整流式代码;
  • 用 filter / map / sorted / distinct / limit 组合出常见的数据处理逻辑;
  • 用 Collectors 完成分组、求和、计数、拼接;
  • 解释惰性求值:为什么没有终端操作时中间操作不会执行;
  • 说出流的两个禁忌:不能复用、不要在流里改外部状态。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

需求:从订单列表里找出"用支付宝支付的用户",去重后按名字排序,输出。

命令式写法:

java
Set<String> users = new HashSet<>();
for (Order order : orders) {
    if ("alipay".equals(order.channel())) {
        users.add(order.user());
    }
}
List<String> result = new ArrayList<>(users);
Collections.sort(result);

流式写法:

java
List<String> result = orders.stream()
        .filter(order -> "alipay".equals(order.channel()))
        .map(Order::user)
        .distinct()
        .sorted()
        .toList();

两种写法性能接近,区别在阅读体验:第二种把"过滤 → 取用户名 → 去重 → 排序"这条意图链摊在明面上;第一种需要你自己在脑海里从循环体反推出意图。

代码是给人读的,循环结构会淹没意图——这是 Stream 存在的理由。

5. 核心概念 ​

5.1 流的三段式 ​

text
创建流          中间操作(可 0 到 N 个)              终端操作(必须有 1 个)
orders.stream() → filter → map → sorted → distinct → toList()
                  ↑ 惰性,不执行                       ↑ 触发执行
阶段方法举例特点
创建stream()、Stream.of(...)、Arrays.stream(...)把数据变成流
中间操作filter、map、sorted、distinct、limit、skip、flatMap、peek惰性,返回新流,不执行
终端操作toList、collect、forEach、count、sum、reduce、anyMatch、findFirst真正执行,返回结果

5.2 惰性求值:不调用终端操作,什么都不发生 ​

java
orders.stream()
      .filter(order -> {
          System.out.println("过滤:" + order.user());   // 不会打印
          return true;
      });

上面这段代码什么都不做,因为它没有终端操作。这不是 bug,而是设计:流可以等到最后一步才决定怎么优化(比如 limit 可以让上游少处理一些元素)。

5.3 常用终端操作与 Collectors ​

java
// 收集成集合
List<String> list = stream.toList();                          // 不可变列表(Java 16+)
Set<String> set = stream.collect(Collectors.toSet());

// 分组:按渠道把订单分组
Map<String, List<Order>> byChannel = orders.stream()
        .collect(Collectors.groupingBy(Order::channel));

// 分组 + 聚合:按用户求和
Map<String, Long> amountByUser = orders.stream()
        .collect(Collectors.groupingBy(Order::user, Collectors.summingLong(Order::amountInCents)));

// 分组 + 计数
Map<String, Long> countByChannel = orders.stream()
        .collect(Collectors.groupingBy(Order::channel, Collectors.counting()));

// 分区:分成满足条件与不满足两组
Map<Boolean, List<Order>> partitioned = orders.stream()
        .collect(Collectors.partitioningBy(order -> order.amountInCents() >= 1000));

// 拼接
String joined = orders.stream().map(Order::user).distinct().collect(Collectors.joining("、"));

想让输出顺序可预测

groupingBy 默认用 HashMap 装结果,打印顺序不确定。测试或输出时需要稳定顺序,加一个容器工厂:

java
Collectors.groupingBy(Order::user, TreeMap::new, Collectors.summingLong(Order::amountInCents))

5.4 六类高频组合 ​

需求写法
筛选filter(条件)
转换map(函数)
扁平化flatMap(返回流的函数)
去重distinct()(依赖 equals / hashCode)
取前 N / 跳过 Nlimit(n) / skip(n)
判断anyMatch / allMatch / noneMatch

5.5 两个禁忌 ​

禁忌一:流不能复用。

java
Stream<Order> stream = orders.stream();
stream.count();
stream.count();   // IllegalStateException: stream has already been operated upon or closed

一个流只能被消费一次。需要重复处理就重新 orders.stream()。

禁忌二:不要在流里修改外部状态。

java
List<String> result = new ArrayList<>();
orders.stream().forEach(order -> result.add(order.user()));   // 能跑,但不该这么写

正确做法是用 map + collect 让流自己产出结果。forEach 只用于"最后一步做副作用"(如打印)。在并行流里修改外部集合还会直接产生错误结果。

6. 递进示例 ​

6.1 最小示例 ​

java
List<String> names = List.of("张三", "李四", "王五");
List<String> result = names.stream()
        .filter(name -> name.length() == 2)
        .map(String::toUpperCase)
        .toList();
System.out.println(result);   // [张三, 李四, 王五](中文长度也是 2)

6.2 业务示例:订单统计 ​

java
record Order(String user, String channel, long amountInCents) {}

List<Order> orders = List.of(
        new Order("u1", "alipay", 1000),
        new Order("u1", "wechat", 2000),
        new Order("u2", "alipay", 500),
        new Order("u2", "alipay", 1500),
        new Order("u3", "bank", 3000)
);

Map<String, Long> amountByUser = orders.stream()
        .collect(Collectors.groupingBy(
                Order::user,
                TreeMap::new,
                Collectors.summingLong(Order::amountInCents)));

System.out.println(amountByUser);   // {u1=3000, u2=2000, u3=3000}

这段代码替代了原本需要"遍历 + 判断 key 是否存在 + 累加"的十几行。

6.3 反例:把流当成循环用 ​

java
// 不推荐:为了用流而用流
orders.stream()
      .forEach(order -> {
          if (order.amountInCents() > 1000) {
              sendNotification(order);      // 大量副作用
              updateStatistics(order);      // 逻辑纠缠
          }
      });

这种写法比 for 循环更难读、更难调试。判断标准:如果流里全是副作用、没有数据转换,就用 for 循环。

7. 完整可运行代码 ​

文件:labs/functional/src/main/java/cn/kb/learn/functional/StreamLesson.javaJava 版本:21(javac --release 21) 依赖:无 运行命令:

powershell
javac --release 21 -d out StreamLesson.java
java -cp out cn.kb.learn.functional.StreamLesson
java
package cn.kb.learn.functional;

import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class StreamLesson {
    record Order(String user, String channel, long amountInCents) {
    }

    public static void main(String[] args) {
        List<Order> orders = List.of(
                new Order("u1", "alipay", 1000),
                new Order("u1", "wechat", 2000),
                new Order("u2", "alipay", 500),
                new Order("u2", "alipay", 1500),
                new Order("u3", "bank", 3000)
        );

        // 1. 过滤 + 映射 + 去重
        List<String> alipayUsers = orders.stream()
                .filter(order -> "alipay".equals(order.channel()))
                .map(Order::user)
                .distinct()
                .toList();
        System.out.println("使用支付宝的用户:" + alipayUsers);

        // 2. 分组求和(用 TreeMap 保证输出顺序稳定)
        Map<String, Long> amountByUser = orders.stream()
                .collect(Collectors.groupingBy(
                        Order::user,
                        TreeMap::new,
                        Collectors.summingLong(Order::amountInCents)));
        System.out.println("各用户消费(分):" + amountByUser);

        // 3. 分组计数
        Map<String, Long> countByChannel = orders.stream()
                .collect(Collectors.groupingBy(
                        Order::channel,
                        TreeMap::new,
                        Collectors.counting()));
        System.out.println("各渠道订单数:" + countByChannel);

        // 4. 拼接
        String channels = orders.stream()
                .map(Order::channel)
                .distinct()
                .sorted()
                .collect(Collectors.joining("、"));
        System.out.println("出现过的渠道:" + channels);

        // 5. 惰性求值与短路:limit(1) 让上游只处理一个元素
        System.out.println("--- 惰性求值演示 ---");
        long count = Stream.of("a", "b", "c", "d")
                .peek(value -> System.out.println("peek: " + value))
                .filter(value -> !"b".equals(value))
                .limit(1)
                .count();
        System.out.println("统计结果:" + count);

        // 6. 数值聚合
        long total = orders.stream()
                .mapToLong(Order::amountInCents)
                .sum();
        System.out.println("总金额(分):" + total);
    }
}

预期输出:

text
使用支付宝的用户:[u1, u2]
各用户消费(分):{u1=3000, u2=2000, u3=3000}
各渠道订单数:{alipay=3, bank=1, wechat=1}
出现过的渠道:alipay、bank、wechat
--- 惰性求值演示 ---
peek: a
统计结果:1
总金额(分):8000

第 5 段是本节最值得盯住的地方:Stream.of("a", "b", "c", "d") 有四个元素,但只打印了一次 peek: a。因为 limit(1) 一满足,整条流水线立刻短路结束——这正是惰性求值的价值。

8. Debug 观察点 ​

  1. 用 peek 看执行顺序:在 peek 里打印,把 limit(1) 删掉再看一次,对比处理了几个元素。你会看到中间操作是每个元素依次流过所有阶段,而不是"先全部过滤再全部映射";
  2. 观察短路:把 filter 的条件改成 value -> true,limit(1) 仍然只让一个元素通过;
  3. IDEA 的流调试神器:在断点停下后,点击流式代码左侧的 Trace Current Stream Chain 图标(沙漏形),IDEA 会把每一步的元素变化可视化成一张表,这是排查"数据在哪一步被过滤掉了"的最快方式;
  4. 验证流的不可复用:把 Stream 存到变量后调用两次终端操作,观察 IllegalStateException。

9. 常见错误 ​

错误 1:复用已消费的流 ​

java
Stream<Order> stream = orders.stream();
long count = stream.count();
List<String> users = stream.map(Order::user).toList();   // IllegalStateException

修复:一个流只消费一次;需要多次处理就每次都从数据源创建。

错误 2:忘记终端操作 ​

java
orders.stream().filter(order -> order.amountInCents() > 1000);   // 什么都不会发生

修复:加上终端操作(toList()、count()、forEach(...))。

错误 3:在流中修改外部集合 ​

java
List<String> result = new ArrayList<>();
orders.parallelStream().forEach(order -> result.add(order.user()));   // 结果不可预测

修复:让流产出结果,而不是修改外部状态:

java
List<String> result = orders.stream().map(Order::user).toList();

10. 动手实验 ​

目标:用自己的数据跑通一条完整的流式链路。

步骤:

  1. 运行第 7 节代码,确认输出一致;
  2. 增加需求:找出"消费超过 1000 分的用户",输出 用户名=金额 形式的字符串,按金额降序;
  3. 把第 5 段的 limit(1) 改成 limit(2),预测输出后运行验证;
  4. 删掉第 5 段的 .count(),观察 peek 是否还会打印,解释原因。

验收方式:能解释第 3 步与第 4 步的输出变化。

扩展任务:用 partitioningBy 把订单分成"大额(≥1000 分)"和"小额"两组,分别统计两组的总金额与订单数。

11. 练习题 ​

基础题

  1. 判断:中间操作会立即执行。(对 / 错)
  2. 填空:filter 是 ______ 操作,count 是 ______ 操作。
  3. 简答:为什么说"流不能复用"?

代码阅读题

java
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);
List<Integer> result = numbers.stream()
        .filter(n -> n % 2 == 0)
        .map(n -> n * 10)
        .limit(2)
        .toList();
System.out.println(result);
  1. 输出是什么?limit(2) 放在 map 之后与放在 filter 之后有区别吗?

编程题

  1. 给定 List<String>,统计每个单词的长度,返回 Map<Integer, List<String>>(长度 → 单词列表),输出按长度升序。

综合任务

  1. 给定订单列表,实现一个 Report 类,提供三个方法:
    • Map<String, Long> amountByChannel():各渠道总金额(按渠道名升序);
    • String topUser():消费最高的用户名(并列时取字典序小的);
    • Map<Boolean, List<String>> splitByAmount(long threshold):按是否超过阈值把用户名分组,用户名按原顺序去重。
分级提示

提示 1(方向):第 5 题用 Collectors.groupingBy(String::length, TreeMap::new, Collectors.toList())。

提示 2(关键点):第 6 题的 topUser() 可以先用 Collectors.groupingBy + summingLong 得到金额表,再转成流比较,注意并列时要用 thenComparing 保底。

提示 3(接近答案):第 6 题的 splitByAmount 用 Collectors.partitioningBy(...),值收集用 Collectors.mapping(Order::user, Collectors.toList()),最后对两组分别去重。

12. 参考答案 ​

基础题
  1. 错。中间操作是惰性的,只有终端操作才会触发执行。
  2. 中间、终端。
  3. 因为流内部持有状态(当前处理到哪个元素、上游是谁)。一旦被终端操作消费,内部状态就已经走到末尾,再操作无法保证语义,所以 Java 直接抛出 IllegalStateException 而不是给出不可预期的结果。
代码阅读题(第 4 题)

输出 [20, 40]。

区别在于处理多少个元素:

  • limit(2) 放在 map 之后(本例):上游 filter 需要找到前两个偶数(2 和 4),然后映射;
  • 若放在 filter 之前:只会取原始列表的前两个元素(1、2),过滤后只剩 2,最终结果只有 [20]。

中间操作的顺序会改变语义和计算量,这也是惰性求值能带来优化的原因。

编程题(第 5 题)
java
package cn.kb.learn.functional;

import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;

public class WordLengthGroup {
    public static void main(String[] args) {
        List<String> words = List.of("java", "go", "python", "c", "rust", "sql");

        Map<Integer, List<String>> byLength = words.stream()
                .collect(Collectors.groupingBy(
                        String::length,
                        TreeMap::new,
                        Collectors.toList()));

        byLength.forEach((length, list) -> System.out.println(length + " -> " + list));
    }
}

预期输出:

text
1 -> [c]
2 -> [go]
3 -> [sql]
4 -> [java, rust]
6 -> [python]
综合任务(第 6 题)
java
package cn.kb.learn.functional;

import java.util.*;
import java.util.stream.Collectors;

public class Report {
    record Order(String user, String channel, long amountInCents) {
    }

    private final List<Order> orders;

    public Report(List<Order> orders) {
        this.orders = orders;
    }

    public Map<String, Long> amountByChannel() {
        return orders.stream()
                .collect(Collectors.groupingBy(
                        Order::channel,
                        TreeMap::new,
                        Collectors.summingLong(Order::amountInCents)));
    }

    public String topUser() {
        return orders.stream()
                .collect(Collectors.groupingBy(Order::user, Collectors.summingLong(Order::amountInCents)))
                .entrySet().stream()
                .max(Comparator
                        .comparingLong(Map.Entry<String, Long>::getValue)
                        .thenComparing(Map.Entry::getKey, Comparator.reverseOrder()))
                .map(Map.Entry::getKey)
                .orElseThrow();
    }

    public Map<Boolean, List<String>> splitByAmount(long threshold) {
        return orders.stream()
                .collect(Collectors.partitioningBy(
                        order -> order.amountInCents() >= threshold,
                        Collectors.mapping(Order::user, Collectors.collectingAndThen(
                                Collectors.toList(),
                                list -> list.stream().distinct().toList()))));
    }

    public static void main(String[] args) {
        List<Order> orders = List.of(
                new Order("u1", "alipay", 1000),
                new Order("u1", "wechat", 2000),
                new Order("u2", "alipay", 500),
                new Order("u3", "bank", 3000)
        );
        Report report = new Report(orders);

        System.out.println(report.amountByChannel());
        System.out.println("消费最高的用户:" + report.topUser());
        System.out.println(report.splitByAmount(1000));
    }
}

关于 topUser() 的并列处理:max 的比较器是"金额大者优先;金额相同时,用 thenComparing 配合 reverseOrder 让字典序小的胜出"。这是流式写法里比较绕的一处,值得单独断点跑一遍看清楚。

实际输出(假设数据如上,u1 合计 3000、u3 合计 3000):

text
{alipay=1500, bank=3000, wechat=2000}
消费最高的用户:u1
{false=[u2], true=[u1, u3]}

13. 掌握标准 ​

  • [ ] 能独立写出"创建 → 中间操作 → 终端操作"的完整链路;
  • [ ] 能用 Collectors 完成分组、求和、计数、拼接;
  • [ ] 能解释惰性求值并预测 limit 的短路效果;
  • [ ] 知道什么时候该用 for 循环而不是流。

14. 扩展资源 ​

15. 下一步学习 ​

《Optional》 解决 null 的传播问题;测试专项线会在 T1-001 JUnit 5 中大量使用流式断言与参数化数据。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引