主题
界面
主题
Stream 是 Java 8 里最改变写代码方式的特性。它把"对一组数据做什么"和"怎么循环"分开:你只描述要什么(过滤、转换、分组),不再手写索引和临时集合。
本节的重点不是背 API,而是三件事:看懂流的三段式结构、理解惰性求值、知道什么时候不该用流。
完成本节后,你应该能够:
filter / map / sorted / distinct / limit 组合出常见的数据处理逻辑;Collectors 完成分组、求和、计数、拼接;List、Map 的基本操作吗?(《List、Set、Map》)List.of(...) 创建的是不可变列表吗?需求:从订单列表里找出"用支付宝支付的用户",去重后按名字排序,输出。
命令式写法:
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);流式写法:
List<String> result = orders.stream()
.filter(order -> "alipay".equals(order.channel()))
.map(Order::user)
.distinct()
.sorted()
.toList();两种写法性能接近,区别在阅读体验:第二种把"过滤 → 取用户名 → 去重 → 排序"这条意图链摊在明面上;第一种需要你自己在脑海里从循环体反推出意图。
代码是给人读的,循环结构会淹没意图——这是 Stream 存在的理由。
创建流 中间操作(可 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 | 真正执行,返回结果 |
orders.stream()
.filter(order -> {
System.out.println("过滤:" + order.user()); // 不会打印
return true;
});上面这段代码什么都不做,因为它没有终端操作。这不是 bug,而是设计:流可以等到最后一步才决定怎么优化(比如 limit 可以让上游少处理一些元素)。
// 收集成集合
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 装结果,打印顺序不确定。测试或输出时需要稳定顺序,加一个容器工厂:
Collectors.groupingBy(Order::user, TreeMap::new, Collectors.summingLong(Order::amountInCents))| 需求 | 写法 |
|---|---|
| 筛选 | filter(条件) |
| 转换 | map(函数) |
| 扁平化 | flatMap(返回流的函数) |
| 去重 | distinct()(依赖 equals / hashCode) |
| 取前 N / 跳过 N | limit(n) / skip(n) |
| 判断 | anyMatch / allMatch / noneMatch |
禁忌一:流不能复用。
Stream<Order> stream = orders.stream();
stream.count();
stream.count(); // IllegalStateException: stream has already been operated upon or closed一个流只能被消费一次。需要重复处理就重新 orders.stream()。
禁忌二:不要在流里修改外部状态。
List<String> result = new ArrayList<>();
orders.stream().forEach(order -> result.add(order.user())); // 能跑,但不该这么写正确做法是用 map + collect 让流自己产出结果。forEach 只用于"最后一步做副作用"(如打印)。在并行流里修改外部集合还会直接产生错误结果。
List<String> names = List.of("张三", "李四", "王五");
List<String> result = names.stream()
.filter(name -> name.length() == 2)
.map(String::toUpperCase)
.toList();
System.out.println(result); // [张三, 李四, 王五](中文长度也是 2)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 是否存在 + 累加"的十几行。
// 不推荐:为了用流而用流
orders.stream()
.forEach(order -> {
if (order.amountInCents() > 1000) {
sendNotification(order); // 大量副作用
updateStatistics(order); // 逻辑纠缠
}
});这种写法比 for 循环更难读、更难调试。判断标准:如果流里全是副作用、没有数据转换,就用 for 循环。
文件:labs/functional/src/main/java/cn/kb/learn/functional/StreamLesson.javaJava 版本:21(javac --release 21) 依赖:无 运行命令:
javac --release 21 -d out StreamLesson.java
java -cp out cn.kb.learn.functional.StreamLessonpackage 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);
}
}预期输出:
使用支付宝的用户:[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) 一满足,整条流水线立刻短路结束——这正是惰性求值的价值。
peek 看执行顺序:在 peek 里打印,把 limit(1) 删掉再看一次,对比处理了几个元素。你会看到中间操作是每个元素依次流过所有阶段,而不是"先全部过滤再全部映射";filter 的条件改成 value -> true,limit(1) 仍然只让一个元素通过;Stream 存到变量后调用两次终端操作,观察 IllegalStateException。Stream<Order> stream = orders.stream();
long count = stream.count();
List<String> users = stream.map(Order::user).toList(); // IllegalStateException修复:一个流只消费一次;需要多次处理就每次都从数据源创建。
orders.stream().filter(order -> order.amountInCents() > 1000); // 什么都不会发生修复:加上终端操作(toList()、count()、forEach(...))。
List<String> result = new ArrayList<>();
orders.parallelStream().forEach(order -> result.add(order.user())); // 结果不可预测修复:让流产出结果,而不是修改外部状态:
List<String> result = orders.stream().map(Order::user).toList();目标:用自己的数据跑通一条完整的流式链路。
步骤:
用户名=金额 形式的字符串,按金额降序;limit(1) 改成 limit(2),预测输出后运行验证;.count(),观察 peek 是否还会打印,解释原因。验收方式:能解释第 3 步与第 4 步的输出变化。
扩展任务:用 partitioningBy 把订单分成"大额(≥1000 分)"和"小额"两组,分别统计两组的总金额与订单数。
基础题
filter 是 ______ 操作,count 是 ______ 操作。代码阅读题
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);limit(2) 放在 map 之后与放在 filter 之后有区别吗?编程题
List<String>,统计每个单词的长度,返回 Map<Integer, List<String>>(长度 → 单词列表),输出按长度升序。综合任务
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()),最后对两组分别去重。
IllegalStateException 而不是给出不可预期的结果。输出 [20, 40]。
区别在于处理多少个元素:
limit(2) 放在 map 之后(本例):上游 filter 需要找到前两个偶数(2 和 4),然后映射;filter 之前:只会取原始列表的前两个元素(1、2),过滤后只剩 2,最终结果只有 [20]。中间操作的顺序会改变语义和计算量,这也是惰性求值能带来优化的原因。
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));
}
}预期输出:
1 -> [c]
2 -> [go]
3 -> [sql]
4 -> [java, rust]
6 -> [python]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):
{alipay=1500, bank=3000, wechat=2000}
消费最高的用户:u1
{false=[u2], true=[u1, u3]}Collectors 完成分组、求和、计数、拼接;limit 的短路效果;for 循环而不是流。《Optional》 解决 null 的传播问题;测试专项线会在 T1-001 JUnit 5 中大量使用流式断言与参数化数据。
暂无