Stream 是什么:为什么告别 for 循环
Java 8 之前处理集合,永远是 for 循环加 if 判断:遍历列表、过滤条件、转换格式、收集结果——四步下来几十行样板代码。Stream API 把这四步变成了一条流水线:数据像水流一样从源头进来,经过过滤、映射、排序,最后流进一个收集器。
举个例子:从一个用户列表里,找出年龄大于 18 的,提取他们的名字,按名字排序,转成 List。传统写法:
List<String> names = new ArrayList<>();
for (User u : users) {
if (u.getAge() > 18) {
names.add(u.getName());
}
}
Collections.sort(names);
Stream 写法:
List<String> names = users.stream()
.filter(u -> u.getAge() > 18)
.map(User::getName)
.sorted()
.collect(Collectors.toList());
一行链式调用,意图清晰:过滤、映射、排序、收集。读代码的人一眼就知道这四步在干什么,不需要在 for 循环里翻逻辑。
Stream 不是数据结构,它不存储数据,也不改数据源。它是一个计算流水线——中间操作都是惰性的,只有终端操作触发时才真正执行。
- 中间操作filter、map、sorted、distinct,返回新 Stream,可链式。
- 终端操作collect、count、reduce、forEach,触发实际计算。
- groupingBy一行代码完成分组统计。
- parallelStream自动并行处理,大数据集提速。
中间操作:filter、map、sorted、distinct
中间操作返回一个新的 Stream,你可以接着链式调用。它们都是惰性的——写在那里不会执行,直到终端操作出现。
| 操作 | 作用 | 示例 |
|---|---|---|
| filter | 按条件过滤元素 | .filter(u -> u.getAge() > 18) |
| map | 把元素转换成另一种类型 | .map(User::getName) |
| sorted | 排序 | .sorted(Comparator.comparing(User::getAge)) |
| distinct | 去重 | .distinct() |
| limit | 取前 N 个 | .limit(10) |
| skip | 跳过前 N 个 | .skip(5) |
map 是最常用的中间操作:它把 Stream<User> 变成 Stream<String>(提取名字),把 Stream<Order> 变成 Stream<BigDecimal>(提取金额)。每一层 map 做一次类型转换,像流水线的一道工序。
sorted 支持自定义比较器。按多个字段排序时用 Comparator 链式调用:Comparator.comparing(User::getAge).thenComparing(User::getName)——先按年龄排,年龄相同再按名字排。
终端操作:collect、reduce、count
终端操作是流水线的终点——它触发整个计算,产出一个结果。没有终端操作,前面写的所有 filter、map 都不会执行。
// 收集成 List
List<String> names = users.stream()
.map(User::getName)
.collect(Collectors.toList());
// 计数
long adultCount = users.stream()
.filter(u -> u.getAge() >= 18)
.count();
// 求和
int totalAge = users.stream()
.mapToInt(User::getAge)
.sum();
// reduce:把所有元素归约成一个值
Optional<User> oldest = users.stream()
.reduce((a, b) -> a.getAge() > b.getAge() ? a : b);
collect 是最灵活的终端操作——配合 Collectors 工具类,可以收集成 List、Set、Map,也可以分组、拼接字符串、求平均值。它是 Stream 世界里的瑞士军刀。
reduce 用于把流里所有元素合并成一个值。求和、求最大值、拼接字符串都可以用 reduce 实现。它返回 Optional,因为流可能是空的。
collect 分组:groupingBy 实战
业务里经常要分组统计:按部门分组算人数、按状态分组算订单量、按城市分组列用户。传统做法是建一个 Map,遍历列表,put 进去 if else 判断。Stream 的 groupingBy 一行搞定。
// 按部门分组
Map<String, List<User>> byDept = users.stream()
.collect(Collectors.groupingBy(User::getDept));
// 按部门分组,只统计每组人数
Map<String, Long> countByDept = users.stream()
.collect(Collectors.groupingBy(User::getDept, Collectors.counting()));
// 按部门分组,统计每组平均年龄
Map<String, Double> avgAgeByDept = users.stream()
.collect(Collectors.groupingBy(
User::getDept,
Collectors.averagingInt(User::getAge)
));
// 拼接成逗号分隔字符串
String names = users.stream()
.map(User::getName)
.collect(Collectors.joining(", "));
groupingBy 的第二个参数是下游收集器——你可以在分组之后再做计数、求和、取最大值。这比写一个 Map 加 for 循环清晰太多。
另一个高频用法是 toMap:把 List 转成 Map,key 是 id,value 是对象本身。做查找时 O(1) 命中,不用每次遍历。
Lambda 与方法引用
Stream 的参数都是 Lambda 表达式——匿名函数,不需要定义类。u -> u.getAge() > 18 就是一个 Lambda:入参 u,返回一个布尔值。
当 Lambda 只是调用一个已有的方法时,可以用方法引用简写:User::getName 等价于 u -> u.getName()。方法引用有四种形式:
// 静态方法引用
Integer::parseInt
// 实例方法引用(特定对象)
user::getName
// 实例方法引用(任意对象)
String::length
// 构造方法引用
User::new
方法引用让代码更短、意图更清晰。看到 User::getName 就知道是在提取 name 属性,不需要读 u -> u.getName() 再理解一遍。
并行流:什么时候用 parallelStream
Stream 有一个隐藏超能力:把普通流换成并行流,中间操作自动在多线程上执行。只需要把 .stream() 改成 .parallelStream(),或者在流上调用 .parallel()。
// 串行:单线程跑
long count = users.stream()
.filter(User::isActive)
.count();
// 并行:ForkJoinPool 多线程跑
long count = users.parallelStream()
.filter(User::isActive)
.count();
但并行流不是银弹。它适合的场景是:数据量大(十万级以上)、每个元素处理独立、不涉及共享可变状态。如果只是几百个元素,并行流的线程切换开销比串行还慢。
| 场景 | 推荐 |
|---|---|
| 小列表(< 1 万) | 串行 stream(),并行开销大于收益 |
| 大列表纯计算 | parallelStream(),多核提速明显 |
| 涉及 IO / 数据库 | 不要用并行流,用线程池+异步 |
| 有共享可变变量 | 禁止并行,会有线程安全问题 |
Stream 的坑与最佳实践
Stream 写起来爽,但有几个坑必须避开:
- 不要在 Stream 里修改外部变量:比如在 forEach 里往一个外部 List add 元素——串行时没事,并行时线程不安全。要用 collect。
- 不要嵌套多层 Stream:三层以上 map/filter 嵌套,可读性比 for 循环还差。拆成多个命名清晰的步骤。
- 不要为了用 Stream 而用 Stream:简单的遍历打印用 for 循环更直接。Stream 的价值在于过滤-转换-收集的流水线,不是所有场景都适合。
- 注意装箱性能:处理大量数字时,用 mapToInt/mapToLong 得到原始类型流,避免自动装箱的开销。
最佳实践:把复杂的 Stream 链提取成一个命名方法。不要在业务代码里写十行链式调用,而是提取成 filterAdults(users) 这样的方法,主流程读起来就是业务语言。
Stream 的核心价值不是"代码更短",而是"意图更清晰"。过滤什么、转换什么、收集成什么——三步写在一条链上,读代码的人不需要在 for 循环里拆逻辑。