Java 8 Stream 流常用操作:从入门到原理

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());     // 终止操作

可以简单理解为:

  1. 从一个集合、数组或其他数据源创建 Stream
  2. 用一组中间操作描述数据怎么流动
  3. 用一个终止操作真正触发执行,并拿到结果

这里有个很重要的点:中间操作不会马上执行。只有遇到 collectforEachcount 这种终止操作时,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 判断是否重复依赖 equalshashCode。如果这两个方法没写好,就可能出现"看起来重复,但去不掉"的情况。

七、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());

sortedfiltermap 不太一样。filtermap 基本是来一个处理一个,而 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());

不过这里要提醒一下:真实项目里,如果数据来自数据库,分页最好交给数据库做。先查出所有数据再在内存里 skiplimit,数据量一大就很难受。

九、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 用在真正需要"执行动作"的地方,比如打印、发送消息、调用某个方法。只要是为了生成一个新集合,优先考虑 mapfiltercollect

十、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()));

注意 maxmin 返回的是 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 并不是把每一步都单独跑一遍,而是把中间操作组合成了一条流水线。一个元素进入流水线后,会尽可能一路走到底。

上面的流程大概是:

  1. a 进入 filter,不满足条件,被丢掉
  2. bb 进入 filter,满足条件,再进入 map
  3. ccc 进入 filter,满足条件,再进入 map
  4. limit(2) 已经拿到两个结果,后面的元素不用再处理

这就是 Stream 延迟执行和短路优化的意义。

十六、中间操作和终止操作

常见中间操作有:

  • filter
  • map
  • flatMap
  • distinct
  • sorted
  • limit
  • skip
  • peek

中间操作的特点是:返回值仍然是 Stream,所以还能继续链式调用。

常见终止操作有:

  • collect
  • forEach
  • count
  • max
  • min
  • reduce
  • anyMatch
  • allMatch
  • noneMatch
  • findFirst

终止操作会真正触发执行。执行完以后,这个 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 或数值流。

日常开发里,先把这几个操作用熟就够了:

  • filter
  • map
  • flatMap
  • sorted
  • collect
  • groupingBy
  • reduce
  • anyMatch
  • findFirst

最后再记住几个原则:

  • 中间操作只是描述过程,终止操作才会触发执行
  • Stream 只能消费一次
  • 尽量写无副作用的 Lambda
  • 并行流不要乱用
  • 链式调用太长时,适当拆开,代码反而更好读

Stream 用好了,确实能让集合处理代码清爽很多。但也别为了用 Stream 而用 Stream。代码最终是写给人看的,清晰永远比花哨更重要。

相关推荐
李小狼lee1 小时前
认识一下枚举类型
后端
卷无止境1 小时前
Jupyter Kernel 是什么?原来notebook不仅可用python
后端
星栈1 小时前
我把售后模块砍到只剩 64 行:Rust 全栈 CRM 的 MVP 取舍实录
前端·后端·开源
无限进步_1 小时前
【Linux】进度条:行缓冲区、\r 与 fflush 的实战
linux·服务器·开发语言·数据结构·后端
阿宇的技术日志1 小时前
大模型 Agent 记忆系统:主流范式、技术拆解与架构选型指南
后端·架构
Oneslide1 小时前
临时关闭 Windows Defender实时防护
后端
枕星而眠1 小时前
C++面向对象核心:类间关系与继承深度解析
运维·开发语言·c++·后端
小谢小哥1 小时前
62-Maven核心详解
java·后端·架构