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。