我以前写的 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 的标志,不是能写出多长的链,而是知道什么时候写,什么时候不写。 多踩几次上面的坑,这份判断力自然就有了。