我以前写的 Java 集合代码,长这样:三层 for 循环套 if 再套 for,中间穿插着往一个外部 Set 里 add。功能没错,但读起来像侦探小说——你得在脑子里一步步模拟,才知道它到底想干嘛。 直到用上 Stream,我才意识到:很多代码的长度,其实来自把“做什么”写成了“怎么做”。
从一段三层 for 循环说起
需求很普通:有一批部门,每个部门下有若干用户,我要拿到所有“启用状态的部门”里、 “启用状态的用户”的邮箱,去重并排序。命令式的写法:
Set<String> emails = new TreeSet<>();
for (Dept dept : depts) {
if (!dept.isActive()) {
continue;
}
for (User user : dept.getUsers()) {
if (user.isActive()) {
emails.add(user.getEmail());
}
}
}
它能跑,但它把“过滤、扁平化、映射、收集”四件事,揉进了两层循环和一堆下标/continue 里。 换成 Stream,意图一下子就清晰了:
Set<String> emails = depts.stream()
.filter(Dept::isActive) // 只要启用的部门
.flatMap(dept -> dept.getUsers().stream()) // 把所有用户“摊平”成一条流
.filter(User::isActive) // 只要启用的用户
.map(User::getEmail) // 取邮箱
.collect(Collectors.toCollection(TreeSet::new)); // 去重 + 排序 + 收集
这不是炫技。当你能像这样一行链式地把意图说清楚, 代码就从“实现细节”变成了“业务描述”,谁来读都能一眼看懂。
Lambda 与函数式接口:参数化的行为
Stream 能这么优雅,前提是 Lambda。Lambda 的本质是把“行为”当成参数传。 Java 里它对应“函数式接口”——只有一个抽象方法的接口,比如:
@FunctionalInterface
public interface Predicate<T> {
boolean test(T t); // 传入 T,返回 boolean —— 用来做判断
}
Predicate<User> p = user -> user.isActive(); 就是一个 Lambda。
j.u.f 里预置了几个常用的函数式接口,记住它们,Stream 的 API 就通了大半:
Predicate<T>:T → boolean,用于filter;Function<T, R>:T → R,用于map;Consumer<T>:T → void,用于forEach;Supplier<T>:() → T,用于延迟生成。
上面 Dept::isActive 这种写法叫方法引用,
是 dept -> dept.isActive() 的简写。能用方法引用就别写 Lambda,更干净。
Stream 流水线:中间操作是惰性的
理解 Stream 最关键的一点:中间操作是惰性的(lazy),只有遇到终止操作才会真正执行。 一条 Stream 流水线分三段:
- 数据源:
stream()、of()、Collection.stream(); - 中间操作(可链式,惰性):
filter、map、flatMap、sorted、distinct、limit; - 终止操作(触发整条流水线):
collect、forEach、reduce、count、anyMatch。
惰性带来一个好处:findFirst() 配合 filter 时,找到第一个匹配就停,
不会遍历整个集合;limit(n) 也不会把上游全部算出来。短路的终止操作,能让流水线提前结束。
flatMap 是另一个容易被忽略的利器——它把“流中的流”拍平成一条流。
比如把 List<List<Integer>> 合并成一个列表:
List<Integer> all = groups.stream()
.flatMap(List::stream) // 每个 List 变成流,再合并
.collect(Collectors.toList());
几个容易踩的坑
1. Stream 是一次性的
一条 Stream 流只能消费一次,终止操作之后它就“作废”了:
Stream<User> s = users.stream().filter(User::isActive);
long n = s.count(); // OK,终止操作
s.count(); // ❌ IllegalStateException: stream has already been operated upon
想多次使用,得每次重新 stream()。别把 Stream 存进字段里反复用。
2. parallelStream 不是免费的午餐
把 stream() 换成 parallelStream(),理论上能并行提速。但它:
- 用的是全局共享的
ForkJoinPool.commonPool(),可能和你别的任务抢资源; - 对数据量小、或单元素处理很快的场景,分任务的调度开销可能比串行还慢;
- 一旦在
forEach/ 累加里用了非线程安全的容器(如ArrayList),结果就乱了。
// ❌ 危险:多线程往同一个 ArrayList 写,丢数据
List<String> result = new ArrayList<>();
users.parallelStream()
.filter(User::isActive)
.forEach(result::add);
// ✅ 正确:用 collect,它内部会保证线程安全地合并
List<String> result = users.parallelStream()
.filter(User::isActive)
.collect(Collectors.toList());
3. 别在 forEach 里搞副作用
forEach 是终止操作,理论上可以,但很多人喜欢在里面改外部状态、做赋值。
一旦换成 parallelStream,这些副作用立刻变成并发 bug。原则上:
要产生结果就用 collect/reduce,forEach 只用于真正的“消费”(打日志、下发)。
4. peek 不是用来干活的
peek 是个中间操作,设计初衷是调试用的(窥探流中元素)。
因为中间操作惰性,没有终止操作时 peek 可能根本不执行。别拿它当 forEach 来改造数据。
什么时候不该用 Stream
Stream 不是银弹。下面这些场景,老老实实写循环反而更好:
-
需要中途 break / 提前返回复杂逻辑:虽然
anyMatch、findFirst能短路,但带状态的复杂跳出会让流变得比循环更难读。 - 性能敏感的热点循环:Stream 有对象创建和方法调用的开销, 超高频调用的核心路径,朴素循环通常更快。
- 多个中间变量、嵌套很深:强行写成一条长链,调试时异常栈难定位, 可读性反而下降。这时拆成几个普通步骤更清楚。
Stream 的价值不在“代码行数变少”,而在“让代码贴近问题的描述方式”。 当一条链式调用能像自然语言一样读出业务意图时,用它;当它让人皱眉时,退回到循环。 工具是为你服务的,别反过来。
真正掌握 Stream 的标志,不是能写出多长的链,而是知道什么时候写,什么时候不写。 多踩几次上面的坑,这份判断力自然就有了。