Java 8 Stream 流常用操作:一篇文章讲清楚怎么用、为什么这么用
Java 8 的 Stream API,应该算是很多 Java 开发者从"手写 for 循环"走向"声明式处理集合"的一个分水岭。
以前我们处理集合,大概率会写成这样:
java
List<String> result = new ArrayList<>();
for (String name : names) {
if (name.length() > 3) {
result.add(name.toUpperCase());
}
}
用了 Stream 之后,可以写成:
java
List<String> result = names.stream()
.filter(name -> name.length() > 3)
.map(String::toUpperCase)
.collect(Collectors.toList());
这段代码并不是单纯"少写几行"而已。它更重要的地方在于:代码直接表达了数据要经历什么处理过程。
filter:筛选map:转换collect:收集结果
读起来像一条流水线,这也是 Stream 最核心的感觉。
一、Stream 到底是什么
Stream 不是集合,它不存数据。它更像是对数据源的一次处理过程。
一个典型的 Stream 通常由三部分组成:
java
List<String> result = names.stream() // 数据源
.filter(name -> name.length() > 3) // 中间操作
.map(String::toUpperCase) // 中间操作
.collect(Collectors.toList()); // 终止操作
可以简单理解为:
- 从一个集合、数组或其他数据源创建 Stream
- 用一组中间操作描述数据怎么流动
- 用一个终止操作真正触发执行,并拿到结果
这里有个很重要的点:中间操作不会马上执行。只有遇到 collect、forEach、count 这种终止操作时,Stream 才会真正开始跑。
二、创建 Stream 的几种常见方式
平时最常见的是从集合创建:
java
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
Stream<Integer> stream = numbers.stream();
如果你想尝试并行处理,可以用:
java
Stream<Integer> parallelStream = numbers.parallelStream();
不过先别急着把所有 stream() 都改成 parallelStream()。并行流有自己的成本,不是用了就一定快,后面会说。
数组也可以直接转成 Stream:
java
String[] words = {"Java", "Stream", "Lambda"};
Stream<String> stream = Arrays.stream(words);
如果只是临时造几个元素,可以用 Stream.of:
java
Stream<String> stream = Stream.of("Java", "Spring", "MySQL");
还有一种比较有意思的写法,可以创建无限流:
java
Stream<Integer> numbers = Stream.iterate(1, n -> n + 1);
numbers.limit(5).forEach(System.out::println);
输出:
text
1
2
3
4
5
无限流一定要小心配合 limit 使用,不然它真的会一直跑下去。
三、filter:过滤数据
filter 是 Stream 里最常用的操作之一,用来保留符合条件的数据。
java
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
List<Integer> evenNumbers = numbers.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
System.out.println(evenNumbers); // [2, 4, 6]
filter 接收的是一个 Predicate,也就是"判断条件"。每个元素都会被判断一次,返回 true 的继续往后走,返回 false 的就被过滤掉。
写业务代码时,filter 很适合表达"只要哪些数据":
java
List<User> adults = users.stream()
.filter(user -> user.getAge() >= 18)
.collect(Collectors.toList());
这种写法比把条件藏在 for 循环里更直观。
四、map:把一种数据变成另一种数据
map 用来做转换。
比如把字符串全部转成大写:
java
List<String> names = Arrays.asList("alice", "bob", "charlie");
List<String> upperNames = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());
System.out.println(upperNames); // [ALICE, BOB, CHARLIE]
再看一个更贴近业务的例子。假设有一个 User 类:
java
class User {
private String name;
private int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
}
现在只想拿到用户名列表:
java
List<String> names = users.stream()
.map(User::getName)
.collect(Collectors.toList());
这就是 map 很典型的用法:从对象里提取某个字段,或者把一种对象转换成另一种对象。
它背后的函数接口是 Function<T, R>,意思是输入一个 T,返回一个 R。
五、flatMap:把嵌套结构摊平
flatMap 一开始可能不太好理解,但用一次就很清楚。
假设现在有一个二维列表:
java
List<List<String>> groups = Arrays.asList(
Arrays.asList("Java", "Spring"),
Arrays.asList("MySQL", "Redis")
);
如果我们想把它变成一个普通的一维列表,可以这样写:
java
List<String> skills = groups.stream()
.flatMap(List::stream)
.collect(Collectors.toList());
System.out.println(skills); // [Java, Spring, MySQL, Redis]
如果这里用的是 map(List::stream),得到的是 Stream<Stream<String>>,也就是一堆小流。flatMap 会顺手把这些小流合并成一个大流。
再看一个字符串拆词的例子:
java
List<String> lines = Arrays.asList("java stream", "spring boot");
List<String> words = lines.stream()
.flatMap(line -> Arrays.stream(line.split(" ")))
.collect(Collectors.toList());
System.out.println(words); // [java, stream, spring, boot]
所以可以记住一句话:map 是一对一转换,flatMap 是一对多之后再摊平。
六、distinct:去重
distinct 用来去重:
java
List<Integer> numbers = Arrays.asList(1, 2, 2, 3, 3, 3);
List<Integer> result = numbers.stream()
.distinct()
.collect(Collectors.toList());
System.out.println(result); // [1, 2, 3]
对于字符串、数字这种类型,直接用一般没问题。
但如果是自定义对象,distinct 判断是否重复依赖 equals 和 hashCode。如果这两个方法没写好,就可能出现"看起来重复,但去不掉"的情况。
七、sorted:排序
自然排序很简单:
java
List<Integer> numbers = Arrays.asList(5, 1, 3, 2, 4);
List<Integer> sortedNumbers = numbers.stream()
.sorted()
.collect(Collectors.toList());
System.out.println(sortedNumbers); // [1, 2, 3, 4, 5]
对象排序通常会配合 Comparator:
java
List<User> sortedUsers = users.stream()
.sorted(Comparator.comparing(User::getAge))
.collect(Collectors.toList());
如果想按年龄倒序:
java
List<User> sortedUsers = users.stream()
.sorted(Comparator.comparing(User::getAge).reversed())
.collect(Collectors.toList());
sorted 和 filter、map 不太一样。filter、map 基本是来一个处理一个,而 sorted 需要先攒一批数据再排序,所以它是有状态的中间操作,成本也更高一些。
八、limit 和 skip:截取数据
limit 用来取前 N 个:
java
List<Integer> firstThree = numbers.stream()
.limit(3)
.collect(Collectors.toList());
skip 用来跳过前 N 个:
java
List<Integer> afterThree = numbers.stream()
.skip(3)
.collect(Collectors.toList());
这两个经常被拿来做简单分页:
java
int pageNo = 2;
int pageSize = 10;
List<User> page = users.stream()
.skip((long) (pageNo - 1) * pageSize)
.limit(pageSize)
.collect(Collectors.toList());
不过这里要提醒一下:真实项目里,如果数据来自数据库,分页最好交给数据库做。先查出所有数据再在内存里 skip、limit,数据量一大就很难受。
九、forEach:遍历
forEach 是终止操作,它会触发 Stream 执行:
java
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
names.stream()
.forEach(System.out::println);
如果是并行流,forEach 不保证顺序:
java
names.parallelStream()
.forEach(System.out::println);
想保持顺序的话,用 forEachOrdered:
java
names.parallelStream()
.forEachOrdered(System.out::println);
平时业务里,我更建议把 forEach 用在真正需要"执行动作"的地方,比如打印、发送消息、调用某个方法。只要是为了生成一个新集合,优先考虑 map、filter、collect。
十、collect:把结果收起来
collect 是最常见的终止操作之一。
收集成 List:
java
List<String> result = names.stream()
.filter(name -> name.length() > 3)
.collect(Collectors.toList());
收集成 Set:
java
Set<String> result = names.stream()
.collect(Collectors.toSet());
收集成 Map:
java
Map<String, Integer> nameAgeMap = users.stream()
.collect(Collectors.toMap(User::getName, User::getAge));
这里有个很常见的坑:如果 key 重复,toMap 会直接抛异常。
比如两个用户同名:
java
Map<String, Integer> nameAgeMap = users.stream()
.collect(Collectors.toMap(User::getName, User::getAge));
就可能报 IllegalStateException。
更稳一点的写法是加上合并策略:
java
Map<String, Integer> nameAgeMap = users.stream()
.collect(Collectors.toMap(
User::getName,
User::getAge,
(oldValue, newValue) -> newValue
));
这段代码的意思是:如果 key 重复,就保留新的值。
十一、groupingBy:分组
groupingBy 是我觉得 Stream 里特别实用的一个收集器。
按年龄分组:
java
Map<Integer, List<User>> usersByAge = users.stream()
.collect(Collectors.groupingBy(User::getAge));
得到的结果大概是:
text
{
18=[...],
20=[...],
25=[...]
}
如果只想统计每个年龄有多少人,可以继续配合 counting:
java
Map<Integer, Long> countByAge = users.stream()
.collect(Collectors.groupingBy(
User::getAge,
Collectors.counting()
));
groupingBy 的逻辑其实不复杂:先用分类函数算出 key,再把当前元素放到这个 key 对应的集合里。默认情况下,它用的是 HashMap。
十二、count、max、min、reduce:聚合操作
统计数量:
java
long count = users.stream()
.filter(user -> user.getAge() >= 18)
.count();
找最大值:
java
Optional<User> oldestUser = users.stream()
.max(Comparator.comparing(User::getAge));
oldestUser.ifPresent(user -> System.out.println(user.getName()));
注意 max 和 min 返回的是 Optional,因为 Stream 可能是空的。
再看 reduce。它适合把多个元素归并成一个结果:
java
List<Integer> numbers = Arrays.asList(1, 2, 3, 4);
int sum = numbers.stream()
.reduce(0, Integer::sum);
System.out.println(sum); // 10
这里的 0 是初始值,Integer::sum 是累加规则。
也可以写成 Lambda:
java
int sum = numbers.stream()
.reduce(0, (a, b) -> a + b);
如果只是对数字求和,很多时候用 mapToInt().sum() 会更直接。
十三、anyMatch、allMatch、noneMatch、findFirst
这几个操作写业务判断很舒服。
是否存在成年人:
java
boolean hasAdult = users.stream()
.anyMatch(user -> user.getAge() >= 18);
是否全部都是成年人:
java
boolean allAdult = users.stream()
.allMatch(user -> user.getAge() >= 18);
是否没有未成年人:
java
boolean noTeenager = users.stream()
.noneMatch(user -> user.getAge() < 18);
查找第一个满足条件的用户:
java
Optional<User> firstAdult = users.stream()
.filter(user -> user.getAge() >= 18)
.findFirst();
这些操作大多有短路特性。比如 anyMatch 只要找到一个符合条件的元素,就可以停了,不需要继续遍历后面的数据。
十四、数值流:IntStream、LongStream、DoubleStream
如果处理的是数字,Java 提供了专门的基本类型流,比如 IntStream。
java
int sum = IntStream.rangeClosed(1, 100)
.sum();
System.out.println(sum); // 5050
对象流也可以转成数值流:
java
int totalAge = users.stream()
.mapToInt(User::getAge)
.sum();
如果想一次拿到最大值、最小值、平均值,可以用:
java
IntSummaryStatistics statistics = users.stream()
.mapToInt(User::getAge)
.summaryStatistics();
System.out.println(statistics.getMax());
System.out.println(statistics.getMin());
System.out.println(statistics.getAverage());
数值流的好处是避免频繁装箱、拆箱,代码也更贴近"我要算一个数字结果"。
十五、Stream 为什么是延迟执行的
很多人刚开始用 Stream,会以为下面这段代码是先把所有元素 filter 完,再统一 map:
java
List<String> result = Arrays.asList("a", "bb", "ccc", "dddd").stream()
.filter(s -> {
System.out.println("filter: " + s);
return s.length() > 1;
})
.map(s -> {
System.out.println("map: " + s);
return s.toUpperCase();
})
.limit(2)
.collect(Collectors.toList());
System.out.println(result);
实际输出可能是:
text
filter: a
filter: bb
map: bb
filter: ccc
map: ccc
[BB, CCC]
你会发现,dddd 根本没有被处理。
原因是 Stream 并不是把每一步都单独跑一遍,而是把中间操作组合成了一条流水线。一个元素进入流水线后,会尽可能一路走到底。
上面的流程大概是:
a进入filter,不满足条件,被丢掉bb进入filter,满足条件,再进入mapccc进入filter,满足条件,再进入maplimit(2)已经拿到两个结果,后面的元素不用再处理
这就是 Stream 延迟执行和短路优化的意义。
十六、中间操作和终止操作
常见中间操作有:
filtermapflatMapdistinctsortedlimitskippeek
中间操作的特点是:返回值仍然是 Stream,所以还能继续链式调用。
常见终止操作有:
collectforEachcountmaxminreduceanyMatchallMatchnoneMatchfindFirst
终止操作会真正触发执行。执行完以后,这个 Stream 就不能再用了。
错误示例:
java
Stream<String> stream = Stream.of("Java", "Spring");
stream.forEach(System.out::println);
stream.count(); // IllegalStateException
一个 Stream 只能消费一次。如果还要再处理,就重新创建一个 Stream。
十七、并行流不是万能加速器
并行流看起来很诱人:
java
long count = numbers.parallelStream()
.filter(n -> n % 2 == 0)
.count();
但它不是免费的。
并行流需要拆分任务、分配线程、合并结果。如果数据量很小,或者每个元素处理得很快,这些额外成本可能比收益还大。
比较适合并行流的场景:
- 数据量比较大
- 每个元素处理逻辑比较耗时
- 没有共享可变状态
- 数据源容易拆分,比如数组、
ArrayList、数值范围
不太适合的场景:
- 数据量很小
- 操作强依赖顺序
- 中间会修改共享变量
- I/O 密集型任务
尤其要小心这种写法:
java
List<Integer> result = new ArrayList<>();
IntStream.rangeClosed(1, 1000)
.parallel()
.forEach(result::add); // 有线程安全问题
更推荐这样:
java
List<Integer> result = IntStream.rangeClosed(1, 1000)
.parallel()
.boxed()
.collect(Collectors.toList());
一句话:并行流可以用,但别为了"看起来高级"去用。
十八、peek:适合调试,别滥用
peek 可以在流水线中偷看一下当前元素:
java
List<String> result = names.stream()
.filter(name -> name.length() > 3)
.peek(name -> System.out.println("after filter: " + name))
.map(String::toUpperCase)
.collect(Collectors.toList());
它很适合临时调试。
但不要把复杂业务逻辑塞进 peek,更不要依赖它修改外部状态。这样写短期可能能跑,长期看会让代码越来越难读。
另外,peek 也是中间操作。如果没有终止操作,它不会执行:
java
names.stream()
.peek(System.out::println); // 不会输出
十九、实战例子:订单统计
假设有一个订单类:
java
class Order {
private String orderNo;
private String userId;
private BigDecimal amount;
private String status;
public Order(String orderNo, String userId, BigDecimal amount, String status) {
this.orderNo = orderNo;
this.userId = userId;
this.amount = amount;
this.status = status;
}
public String getOrderNo() {
return orderNo;
}
public String getUserId() {
return userId;
}
public BigDecimal getAmount() {
return amount;
}
public String getStatus() {
return status;
}
}
统计已支付订单总金额:
java
BigDecimal totalPaidAmount = orders.stream()
.filter(order -> "PAID".equals(order.getStatus()))
.map(Order::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
按用户统计订单数:
java
Map<String, Long> orderCountByUser = orders.stream()
.collect(Collectors.groupingBy(
Order::getUserId,
Collectors.counting()
));
找出金额最高的订单:
java
Optional<Order> maxOrder = orders.stream()
.max(Comparator.comparing(Order::getAmount));
把订单号拼成逗号分隔的字符串:
java
String orderNos = orders.stream()
.map(Order::getOrderNo)
.collect(Collectors.joining(","));
这些代码的好处是,业务意图很直接:过滤已支付订单、取金额、累加。读代码的人不需要先在脑子里还原一遍 for 循环。
二十、几个常见坑
1. Stream 不能复用
java
Stream<String> stream = names.stream();
stream.count();
stream.collect(Collectors.toList()); // 错误
一个 Stream 被终止操作消费后,就结束了。
2. toMap 遇到重复 key 会报错
java
Map<String, User> map = users.stream()
.collect(Collectors.toMap(User::getName, user -> user));
如果 name 重复,就会抛异常。更稳的写法:
java
Map<String, User> map = users.stream()
.collect(Collectors.toMap(
User::getName,
user -> user,
(oldUser, newUser) -> newUser
));
3. 并行流里别随便改共享集合
java
List<String> result = new ArrayList<>();
names.parallelStream()
.forEach(result::add); // 不推荐
应该写成:
java
List<String> result = names.parallelStream()
.collect(Collectors.toList());
4. Optional 不要直接 get
java
User user = users.stream()
.filter(u -> u.getAge() > 100)
.findFirst()
.get(); // 可能抛 NoSuchElementException
更安全一点:
java
Optional<User> user = users.stream()
.filter(u -> u.getAge() > 100)
.findFirst();
user.ifPresent(System.out::println);
或者给一个默认值:
java
User user = users.stream()
.filter(u -> u.getAge() > 100)
.findFirst()
.orElse(null);
总结
Stream 最吸引人的地方,不是把 for 循环换成链式调用,而是让代码更接近数据处理本身。
你想过滤,就写 filter;想转换,就写 map;想分组,就写 groupingBy;想聚合,就写 reduce 或数值流。
日常开发里,先把这几个操作用熟就够了:
filtermapflatMapsortedcollectgroupingByreduceanyMatchfindFirst
最后再记住几个原则:
- 中间操作只是描述过程,终止操作才会触发执行
- Stream 只能消费一次
- 尽量写无副作用的 Lambda
- 并行流不要乱用
- 链式调用太长时,适当拆开,代码反而更好读
Stream 用好了,确实能让集合处理代码清爽很多。但也别为了用 Stream 而用 Stream。代码最终是写给人看的,清晰永远比花哨更重要。