Stream API: map、filter、reduce 和 collect 举例讲解

1. 概述

Stream API 从 Java 8 开始出现,已成为处理集合时不可或缺的工具。本文帮你掌握最重要的 4 个操作:map、filter、reduce 和 collect,通过 Order 和 Customer 的具体例子。读完本文,你将知道何时使用哪个操作、如何组合它们,以及避免使用 stream 时的一些常见错误。

2. 准备示例数据

在深入探讨每个操作之前,你需要一组数据贯穿全文。

public record Order(String id, String product, int quantity, double price, String status) {
    public double total() {
        return quantity * price;
    }
}
import java.util.List;

public class OrderData {
    public static List<Order> sample() {
        return List.of(
            new Order("O1", "Laptop", 1, 15_000_000, "COMPLETED"),
            new Order("O2", "Mouse", 3, 150_000, "COMPLETED"),
            new Order("O3", "Keyboard", 2, 400_000, "CANCELLED"),
            new Order("O4", "Monitor", 1, 3_500_000, "COMPLETED"),
            new Order("O5", "Laptop", 1, 14_000_000, "PENDING")
        );
    }
}

Order 是一个 record——简洁、不可变,适合 stream 演示。total() 方法计算每个 order 的总金额,下面会多次重用。

3. filter: 按条件过滤元素

filter 接收一个 Predicate<T>,保留满足条件的元素。

List<Order> completedOrders = OrderData.sample().stream()
    .filter(order -> order.status().equals("COMPLETED"))
    .toList();

completedOrders.forEach(o -> System.out.println(o.id() + " - " + o.product()));
  • stream() 将 List<Order> 转换为 Stream<Order>。
  • filter 只保留 status 为 COMPLETED 的 order。
  • toList()(Java 16+)将结果收集为不可变 List,代替 collect(Collectors.toList())。
O1 - Laptop
O2 - Mouse
O4 - Monitor

注意: filter 不改变原始数据。Stream 是延迟执行的——如果你没有 terminal operation(如 toList()、forEach()),前面的 filter/map 不会执行。

3.1. 组合多个 filter 条件

List<Order> expensiveCompleted = OrderData.sample().stream()
    .filter(o -> o.status().equals("COMPLETED"))
    .filter(o -> o.total() > 1_000_000)
    .toList();

两个连续的 filter 相当于 AND 条件。你可以写成一个 filter 并用 &&,但分开来写在条件复杂时更易读。

4. map: 转换元素

map 接收一个 Function<T, R>,将每个类型 T 的元素转换为类型 R 的元素。

List<String> productNames = OrderData.sample().stream()
    .map(Order::product)
    .distinct()
    .toList();

System.out.println(productNames);
  • Order::product 是方法引用,等价于 order -> order.product()。
  • distinct() 去除重复元素(基于 equals())。
[Laptop, Mouse, Keyboard, Monitor]

4.1. map 返回完全不同的类型

map 的强大之处在于转换数据类型,不仅仅是提取一个字段。

List<Double> totals = OrderData.sample().stream()
    .map(Order::total)
    .toList();

System.out.println(totals);
[15000000.0, 450000.0, 800000.0, 3500000.0, 14000000.0]

注意: 不要混淆 map 和 forEach。map 返回新的 stream,没有副作用;forEach 用于执行操作(打印、保存数据库等)且不返回任何东西。

5. reduce: 将多个元素合并为一个值

reduce 将整个 stream 合并为单一值,基于 identity(初始值)和一个 BinaryOperator。

double totalRevenue = OrderData.sample().stream()
    .filter(o -> o.status().equals("COMPLETED"))
    .map(Order::total)
    .reduce(0.0, Double::sum);

System.out.println(totalRevenue);
  • 0.0 是 identity——当 stream 为空时的初始值。
  • Double::sum 是累加器,将每个值加到结果中。
1.8950000E7

5.1. 没有 identity 的 reduce

如果不想提供 identity,reduce 返回 Optional<T>,因为 stream 可能为空。

Optional<Order> mostExpensive = OrderData.sample().stream()
    .reduce((o1, o2) -> o1.total() > o2.total() ? o1 : o2);

mostExpensive.ifPresent(o -> System.out.println(o.id() + ": " + o.total()));
O1: 1.5E7

注意: 如果要找 max/min,Java 提供了 Stream.max(Comparator) 和 Stream.min(Comparator)——比 reduce 更简洁明了。仅当合并逻辑比简单比较更复杂时才使用 reduce。

6. collect: 将结果收集到数据结构中

collect 比 toList() 更灵活,允许将结果收集到 Map、Set 或执行 groupBy/partition。

6.1. 收集到 Map

import java.util.Map;
import java.util.stream.Collectors;

Map<String, Double> totalByOrderId = OrderData.sample().stream()
    .collect(Collectors.toMap(Order::id, Order::total));

System.out.println(totalByOrderId);
  • Collectors.toMap 接收两个函数:key 提取器和 value 提取器。
  • 如果有重复的 key,toMap 会抛出 IllegalStateException——如果数据可能有重复 key,需要传入 merge function。
{O1=1.5E7, O2=450000.0, O3=800000.0, O4=3500000.0, O5=1.4E7}

6.2. groupingBy——按条件分组

Map<String, List<Order>> ordersByStatus = OrderData.sample().stream()
    .collect(Collectors.groupingBy(Order::status));

ordersByStatus.forEach((status, orders) ->
    System.out.println(status + ": " + orders.size() + " order(s)"));
COMPLETED: 3 order(s)
CANCELLED: 1 order(s)
PENDING: 1 order(s)

6.3. groupingBy 结合 downstream collector

你可以按状态分组计算收入总和,无需分组后再循环。

Map<String, Double> revenueByStatus = OrderData.sample().stream()
    .collect(Collectors.groupingBy(
        Order::status,
        Collectors.summingDouble(Order::total)
    ));

System.out.println(revenueByStatus);
{COMPLETED=18950000.0, CANCELLED=800000.0, PENDING=14000000.0}

带有 downstream collector(summingDouble、counting、averagingDouble 等)的 groupingBy 帮助你避免为每组手动编写 reduce。

7. 在一个 pipeline 中组合所有四个操作

这是一个实际例子:获取已完成的 order 的产品名称列表,金额 > 100 万,按总金额降序排列。

List<String> topProducts = OrderData.sample().stream()
    .filter(o -> o.status().equals("COMPLETED"))
    .filter(o -> o.total() > 1_000_000)
    .sorted(Comparator.comparingDouble(Order::total).reversed())
    .map(Order::product)
    .toList();

System.out.println(topProducts);
[Laptop, Monitor]

这里操作的顺序很重要:先 filter 以减少需要处理的元素,过滤后再 sorted 以避免不必要的排序,最后 map 以仅获取需要的字段。

8. 为 pipeline 编写测试

始终为 stream pipeline 中的业务逻辑编写测试,不要让它“隐藏”在长长一行代码中而无人验证。

<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.3</version>
    <scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;

class OrderAnalyticsTest {

    @Test
    void shouldCalculateTotalRevenueForCompletedOrders() {
        double totalRevenue = OrderData.sample().stream()
            .filter(o -> o.status().equals("COMPLETED"))
            .map(Order::total)
            .reduce(0.0, Double::sum);

        assertThat(totalRevenue).isEqualTo(18_950_000.0);
    }

    @Test
    void shouldGroupOrdersByStatus() {
        var grouped = OrderData.sample().stream()
            .collect(java.util.stream.Collectors.groupingBy(Order::status));

        assertThat(grouped)
            .containsKey("COMPLETED")
            .extractingByKey("COMPLETED", org.assertj.core.api.InstanceOfAssertFactories.list(Order.class))
            .hasSize(3);
    }
}

第一个测试检查 reduce 的结果是否等于正确的总金额。第二个测试检查 groupingBy 是否在 COMPLETED 组中生成正确数量的元素。

9. 常见错误

  • 当 loop 通常更清楚时使用 stream:如果逻辑有许多副作用(日志、更新外部变量、抛出复杂条件下的异常),传统 for 循环比 stream 更易读。
  • 重复使用已消费的 Stream:stream 只能运行一次。在同一个 stream 上两次调用 collect() 或 forEach() 会抛出 IllegalStateException: stream has already been operated upon or closed。
  • 在 reduce 中忘记合适的 identity:使用错误的 identity(例如计算 sum 时用 1 代替 0)会产生错误的结果但不会报错。
  • toMap 有重复 key:如第 6.1 节所述,需要添加 merge function:Collectors.toMap(keyFn, valueFn, (v1, v2) -> v1)。
  • map 中的副作用:避免在传入 map/filter 的函数中改变外部状态。Stream 可能并行运行,副作用会导致难以调试的竞态条件。

注意: 如果你的 pipeline 有超过 4-5 个连续操作且难以阅读,应该将其拆分为名称清晰的单独方法(filterCompleted、calculateRevenue 等),而不是将所有内容塞入一条长链中。

10. 结论

filter、map、reduce 和 collect 是在 Java 中以函数式风格处理数据的基础工具集。filter 帮助你筛选,map 进行转换,reduce 将结果汇总为单一值,而 collect 灵活地将结果收集到更复杂的结构(如 Map)或按条件分组。掌握好操作的顺序并避免上面提到的常见错误,你就能编写简洁、清晰且易于测试的数据处理 pipeline。

javastream-apifunctional-programmingjava21collections