QUICKREF · STREAM · COLLECTORS
Stream 与函数式处理
JDK 21 · 速查主题 05 / 12 · 预计 28 分钟
一句话Stream 是「声明式流水线」:源 + 中间操作 + 终端操作。中间操作惰性执行,只有终端操作才会真正遍历。真正拉开水平差距的不是 map/filter,而是 Collectors 的组合与副作用边界。
学完你要能自己回答
- Stream 的惰性如何体现?为什么没有终端操作时 peek 不执行?
- Collectors.toMap 遇到重复 key 或 null 值会怎样?如何安全处理?
- parallelStream 适合什么场景?为什么它经常让代码更慢?
流水线模型:源 → 中间 → 终端01
Stream 本身不存储数据,它描述一条处理流水线。中间操作(map、filter、sorted)返回新的 Stream 且惰性;终端操作(collect、forEach、count)触发实际计算,之后 Stream 不可复用。
List<String> result = names.stream() // 源
.filter(n -> !n.isBlank()) // 中间:惰性
.map(String::toUpperCase) // 中间:惰性
.sorted() // 中间:有状态,仍惰性
.limit(3) // 中间:短路
.toList(); // 终端:触发执行
// Stream 只能消费一次
Stream<String> s = Stream.of("a", "b");
s.forEach(System.out::println);
s.forEach(System.out::println); // IllegalStateException: stream has already been operated upon
惰性与短路是 Stream 的性能基础:limit(3) 遇到前 3 个元素后就不再向上游要数据;anyMatch 找到第一个匹配就返回。对无限流来说,短路操作是唯一安全的终端操作。
Stream.iterate(1, n -> n * 2)
.limit(10)
.forEach(n -> System.out.print(n + " "));
// 1 2 4 8 16 32 64 128 256 512
Optional<Integer> first = Stream.iterate(1, n -> n + 1)
.filter(n -> n % 7 == 0)
.findFirst();
System.out.println(first); // Optional[7]
创建 Stream 后没有终端操作,什么都不会发生。
peek 是调试中间状态的工具,不是用来做副作用的;依赖 peek 执行顺序的代码在并行流下会出问题。常用操作地图02
| 类别 | 操作 | 说明 |
|---|---|---|
| 映射 | map / mapToInt / flatMap / mapMulti | flatMap 把「一个元素变成多个」再摊平 |
| 过滤 | filter / distinct / takeWhile / dropWhile | distinct 依赖 equals/hashCode |
| 排序 | sorted() / sorted(comparator) | 有状态操作,会缓冲全部元素 |
| 截断 | limit / skip | 短路 / 跳过前 n 个 |
| 调试 | peek | 只用于观察,不保证执行次数 |
| 终端 | collect / reduce / count / forEach | 触发计算 |
| 判断 | anyMatch / allMatch / noneMatch | 短路 |
| 查找 | findFirst / findAny / min / max | 返回 Optional |
| 聚合 | sum / average / summaryStatistics | 基本类型流专用 |
record Order(String user, int amount) {}
List<Order> orders = List.of(
new Order("alice", 120),
new Order("bob", 80),
new Order("alice", 200));
// 按用户分组求和
Map<String, Integer> totals = orders.stream()
.collect(Collectors.groupingBy(Order::user, Collectors.summingInt(Order::amount)));
System.out.println(totals); // {bob=80, alice=320}
// 每个用户取金额最大的订单
Map<String, Order> top = orders.stream()
.collect(Collectors.toMap(Order::user, o -> o,
BinaryOperator.maxBy(Comparator.comparingInt(Order::amount))));
System.out.println(top);
// 基本类型流避免装箱
IntSummaryStatistics stats = IntStream.rangeClosed(1, 100)
.filter(n -> n % 2 == 0)
.summaryStatistics();
System.out.println(stats.getCount()); // 50
System.out.println(stats.getSum()); // 2550
System.out.println(stats.getAverage()); // 51.0
System.out.println(stats.getMax()); // 100
Collectors:组合才是重点03
Collectors 是 Stream 的「下游」机制:分组后还能继续计数、映射、求平均,甚至把两个收集器并联(teeing)。记住几个高频组合,比背全部 API 更有用。
| 收集器 | 作用 | 常见陷阱 |
|---|---|---|
toList()(Stream 方法) | 收集为不可变 List | JDK 16+;不允许 null 元素 |
toUnmodifiableList() | 显式不可变 | 同样拒绝 null |
joining(delimiter) | 字符串拼接 | 元素不能为 null |
groupingBy(classifier) | 分组为 Map<K,List<V>> | 默认 HashMap,null key 会 NPE |
partitioningBy(predicate) | 按布尔分组 | 永远包含 true / false 两个 key |
toMap(key, value) | 收集为 Map | 重复 key 或 null 值抛异常 |
mapping / filtering / flatMapping | 分组后的下游转换 | 别和 Stream 的同名操作混淆 |
teeing(c1, c2, merge) | 并联两个收集器 | JDK 12+ |
counting / summingInt / averagingInt | 聚合 | — |
record Employee(String dept, String name, int salary) {}
List<Employee> staff = List.of(
new Employee("dev", "alice", 30),
new Employee("dev", "bob", 20),
new Employee("ops", "carol", 25));
Map<String, Long> countByDept = staff.stream()
.collect(Collectors.groupingBy(Employee::dept, Collectors.counting()));
Map<String, List<String>> namesByDept = staff.stream()
.collect(Collectors.groupingBy(Employee::dept,
Collectors.mapping(Employee::name, Collectors.toList())));
Map<String, Double> avgByDept = staff.stream()
.collect(Collectors.groupingBy(Employee::dept,
Collectors.averagingInt(Employee::salary)));
System.out.println(countByDept); // {ops=1, dev=2}
System.out.println(namesByDept); // {ops=[carol], dev=[alice, bob]}
System.out.println(avgByDept); // {ops=25.0, dev=25.0}
// teeing:一次遍历同时求和与计数,再算平均
record Stats(long count, int sum) {}
Stats stats = staff.stream().collect(Collectors.teeing(
Collectors.counting(),
Collectors.summingInt(Employee::salary),
Stats::new));
System.out.println(stats); // Stats[count=3, sum=75]
toMap 默认使用 HashMap::new,key 或 value 为 null 时会抛 NPE;重复 key 必须提供 merge 函数。数据里可能有 null 时,改用 HashMap + 循环,或先过滤。Optional:表达「可能没有」04
Optional 是返回值的容器,不是字段、参数或集合元素的替代品。它让调用方在类型上看到「可能为空」,但不应被滥用成到处 get()。
Optional<String> name = findName(id);
// 推荐:提供默认值或做映射
String display = name.orElse("匿名");
int length = name.map(String::length).orElse(0);
name.ifPresentOrElse(
n -> System.out.println("找到 " + n),
() -> System.out.println("未找到"));
// 不推荐:把 Optional 当 null 用
if (name.isPresent()) {
System.out.println(name.get());
}
- 不要用
Optional.of(null),会直接 NPE;允许 null 用ofNullable。 orElse的参数会立即求值;需要惰性计算时用orElseGet。map返回Optional<U>,flatMap用于函数本身返回 Optional 的场景。- 集合元素、Map value、方法参数通常不用 Optional;返回值可以用。
Optional.of("a").orElseThrow(() -> new IllegalStateException("missing"));
Optional.ofNullable(null).orElseGet(() -> "fallback");
Optional.of("x").stream().count(); // 0 或 1,便于和 Stream 衔接
并行流:默认不要用05
parallelStream() 使用公共 ForkJoinPool,看起来一行就能并行,但它只适合数据量大、CPU 密集、无共享状态、易拆分的任务。I/O 密集、数据量小、有顺序依赖时,它通常更慢。
- 数据量小(几千个元素以内)时,拆分与合并的开销超过收益。
forEach不保证顺序;需要顺序用forEachOrdered,但这会削弱并行收益。- 有状态 lambda(修改外部集合、计数器)会导致数据竞争或错误结果。
- 公共 ForkJoinPool 的线程数有限,阻塞任务会拖垮其他并行流。
- 需要自定义线程池时,用
ForkJoinPool提交,而不是在流里阻塞。
// 反例:有共享可变状态
List<Integer> out = new ArrayList<>();
IntStream.range(0, 1000).parallel().forEach(out::add); // 线程不安全
// 正例:无状态、可结合
long count = IntStream.range(0, 1_000_000)
.parallel()
.filter(n -> n % 3 == 0)
.count();
// 正例:需要顺序时明确说明
List<Integer> ordered = IntStream.range(0, 1000)
.parallel()
.boxed()
.collect(Collectors.toList());
性能敏感场景先用 JMH 测量,不要凭感觉在
stream() 后面加 parallel()。多数业务代码的瓶颈在 I/O 或数据库,不在单机循环。常见坑清单06
- Stream 不能复用:终端操作后再次使用抛 IllegalStateException。
- 惰性不是不执行:忘记终端操作,整条流水线不会运行。
- sorted 是全量缓冲:对无限流使用 sorted 会永远不返回。
- flatMap 返回 null:函数返回 null 会导致 NPE;返回
Stream.empty()。 - 装箱开销:大量数值计算用
mapToInt/IntStream。 - findFirst vs findAny:并行流中 findAny 更快,但不保证是第一个。
- Collectors.toList() 不保证可变性:JDK 16 的
Stream.toList()是明确不可变的。 - 不要在流中修改源集合:可能抛 ConcurrentModificationException。
// flatMap 正确返回空流
List<String> words = List.of("a b", "", "c d");
List<String> tokens = words.stream()
.flatMap(s -> s.isBlank() ? Stream.empty() : Arrays.stream(s.split(" ")))
.toList();
System.out.println(tokens); // [a, b, c, d]
// 用 Stream 生成 Map 时处理重复 key
Map<Integer, String> byLength = Stream.of("a", "bb", "cc")
.collect(Collectors.toMap(
String::length,
s -> s,
(a, b) -> a + "," + b)); // 合并重复 key
System.out.println(byLength); // {1=a, 2=bb,cc}
版本边界BOUNDARY
这张表比 API 本身更值得记。写代码时先确认你的运行时版本。
| 特性 / API | 状态 | 说明 |
|---|---|---|
| Stream API | 8 正式 | Java 8 的核心特性之一。 |
| Stream.toList() | 16 正式 | 返回不可变 List,允许 null 元素。 |
| mapMulti | 16 正式 | 比 flatMap 更少装箱的替代方案。 |
| Collectors.teeing | 12 正式 | 并联两个下游收集器。 |
| Stream Gatherers | 22 预览 / 24 正式 | JDK 21 不可用;自定义中间操作要等 22+。 |
费曼自检SELF CHECK
先合上页面,用自己的话回答,再展开对照。能讲出因果和反例,才算真正掌握。
01. 为什么说 Stream 的中间操作是惰性的?举一个能证明的例子。
中间操作只构建流水线,不遍历数据;只有终端操作触发执行。例如 Stream.of("a").peek(System.out::println).filter(x -> true); 没有任何输出,因为没有终端操作。加上 .count() 才会打印。
02. Collectors.toMap 在重复 key 和 null 值上分别是什么行为?
重复 key 且未提供 merge 函数时抛 IllegalStateException;value 为 null 时抛 NullPointerException(HashMap 允许 null,但 toMap 的实现不允许)。需要处理时提供 merge 函数,或先过滤 null / 改用其他收集方式。
03. parallelStream 在什么情况下会变慢或出错?
数据量小、任务 I/O 密集、有共享可变状态、需要严格顺序、源不易拆分时,并行开销大于收益,甚至产生数据竞争。公共 ForkJoinPool 被阻塞任务占满时也会拖慢其他并行流。
04. Optional 应该用在哪些位置?
主要用作可能没有结果的返回值,让调用方在类型上看到空的可能性。不建议用作字段、方法参数或集合元素;这些场景用 null、默认值或空集合更自然。
学习进度
记录保存在本机浏览器 · 不上传