一,背景(为什么要有Stream)


背景一:为了拯救“很多核但不会用”的 CPU

—— 解决并行计算难的问题

1. 背景故事

要想利用多核,你得写 ThreadLockExecutorService,还要处理死锁和线程安全。这太难了,导致大部分 Java 代码只能跑在 CPU 的一个核上,其他 7 个核都在围观。

2. 实例对比

场景:你要处理 1 亿条 订单数据,计算总金额。

  • 没有 Stream (Java 7)
    如果你想快点算完,你得自己手动把 List 切成 8 份,开 8 个线程算,最后再把结果加起来。代码可能要写 50 行,而且很容易写出 Bug。
  • 有了 Stream (Java 8)
    Java 官方想:“能不能让程序员别管线程的事,只管业务?”
    于是 Stream 诞生了。
// 只需要加一个单词:parallel()
double total = orders.parallelStream()
                     .mapToDouble(Order::getPrice)
                     .sum();


背景二:为了消灭“又臭又长”的流水账代码

—— 解决代码可读性和维护性问题

1. 背景故事

在 Java 8 之前,处理集合(Collection)是 命令式(Imperative) 的。你必须像个保姆一样,事无巨细地告诉计算机每一步怎么走:先定义一个临时变量,再循环,再 If 判断,再 Add 到新列表……

这种代码写多了,业务逻辑就被淹没在循环控制里了,改起来特别痛苦。

2. 实例对比

场景:从用户列表中,找出“北京地区”的用户,提取他们的“邮箱”,并转换成“大写”。

  • 没有 Stream (Java 7 - 流水账)
// 还要准备个垃圾桶(临时 List)
List<String> emails = new ArrayList<>();

for (User u : userList) {
    // 又是循环又是判断,缩进很深
    if ("Beijing".equals(u.getCity())) {
        String email = u.getEmail();
        if (email != null) {
            emails.add(email.toUpperCase());
        }
    }
}

痛点:读这段代码,你得在脑子里模拟计算机跑一遍循环,才能知道它想干嘛。

  • 有了 Stream (Java 8 - 说人话)
    Stream 引入了 声明式(Declarative) 编程。你只需要告诉计算机 “我要什么”
List<String> emails = userList.stream()
    .filter(u -> "Beijing".equals(u.getCity())) // 只要北京的
    .map(User::getEmail)                        // 只要邮箱
    .map(String::toUpperCase)                   // 变成大写
    .collect(Collectors.toList());              // 打包带走

优势:代码像读英语句子一样顺畅。逻辑即代码。


背景三:为了解决“效率低下”的数据处理

—— 解决多次循环浪费性能的问题

1. 背景故事

在老式写法中,如果你有多个步骤(筛选 -> 变换 -> 截取),往往会导致多次遍历或者生成中间临时集合,浪费内存和 CPU。

2. 实例对比

场景:在一个无限大的整数序列中,找到 前 3 个 大于 100 的数字,并把它们乘以 2。

  • 没有 Stream (Java 7)
    你可能很难写出一个通用的函数,因为你不知道要循环多少次才能找到这 3 个数。如果你先遍历整个列表去筛选,那效率就太低了。
  • 有了 Stream (Java 8 - 惰性求值)
List<Integer> result = infiniteList.stream()
    .filter(x -> x > 100)
    .map(x -> x * 2)
    .limit(3) // 只要前3个
    .collect(Collectors.toList());

底层发生了什么?(Loop Fusion)
Stream 不会先把所有数都 filter 一遍(那样就死循环了)。
它会像流水线一样:

  1. 拿第 1 个数 -> 大于 100 吗?否 -> 丢弃。
  2. 拿第 N 个数 -> 大于 100 吗?是 -> 乘 2 -> 放进结果集(现在有1个了)。
  3. 当结果集满 3 个时,立即停止,后面的数据看都不看。


二,基本原理

Stream流的使用步骤:

1.将集合转为Stream流
2.使用中间方法对Stream流上的数据进行操作
3.使用终结方法操作Stream流上的数据


1. Stream 的生命周期 (Lifecycle)

Stream 的一次完整操作分为三个阶段,严格遵循这个顺序:

  1. 创建流 (Source)
  • 数据的源头。可以是集合 (List, Set)、数组、I/O Channel,甚至是无限序列生成器。
  • 关键点:此时流还不知道要干什么。
  1. 中间操作 (Intermediate Operations)
  • 定义逻辑。如 filter, map, sorted, limit

  • 特性

  • Lazy (惰性):调用这些方法时,代码不会立即执行,只是把操作记录在“账本”上。

  • 返回新流:每个操作都会返回一个新的 Stream 对象,便于链式调用。

  • 分类

  • 无状态 (Stateless)filter, map。处理元素互不干扰。

  • 有状态 (Stateful)sorted, distinct。需要拿到所有元素才能进行下一步。

  1. 终止操作 (Terminal Operations)
  • 触发执行。如 collect, forEach, sum, findFirst
  • 特性
  • 一旦调用,Stream 引擎启动,数据开始流动。
  • 执行完毕后,Stream 关闭,不可再次使用。
  • 生成最终结果(值或副作用)。

2. 底层架构:双向链表与 Sink 接口

当你写出一串链式调用时:

list.stream().filter(x -> x > 5).map(x -> x * 2).collect(toList());

JVM 内部构建了一个复杂的结构来支撑它。

A. 逻辑结构:双向链表 (AbstractPipeline)

Stream 的每一步操作(Source, Filter, Map, Terminal)在底层都对应一个 AbstractPipeline 对象。
这些对象通过 previousStage (上游) 和 nextStage (下游) 指针连接,形成一个双向链表

  • Head:指向数据源。
  • 中间节点:保存了你传入的 Lambda 表达式(比如 x -> x > 5)。
  • Tail:指向终止操作。
B. 执行机制:Sink 接口 (核心)

虽然结构是链表,但数据不是“先存进 Filter 列表,再拿出来给 Map 列表”。
Java 使用了 Sink 接口 实现了逻辑的“包裹”和“传递”。

Sink 是一个消费者接口,包含三个核心方法:

  1. begin(long size):通知下游准备接收数据。
  2. accept(T t)核心处理逻辑(数据来了,怎么搞)。
  3. end():通知下游数据发完了。

“洋葱模型”的构建过程:
当终止操作被触发时,Stream 会从最后一个节点(Terminal)开始倒着往前推,将每一层的逻辑“包装”成一个大的 Sink 对象。

  • SinkMap 里面包着 SinkCollect
  • SinkFilter 里面包着 SinkMap

实际执行流 (accept 链条):
当源头数据 x 进来时,它是这样穿透的:

  1. 调用 SinkFilter.accept(x)
  • 判断 x > 5
  • 如果是 true -> 调用下游 SinkMap.accept(x)
  • 如果是 false -> 直接返回(丢弃数据)。
  1. 调用 SinkMap.accept(x)
  • 计算 y = x * 2
  • 调用下游 SinkCollect.accept(y)
  1. 调用 SinkCollect.accept(y)
  • y 加到 ArrayList 里。

3. 惰性求值 (Lazy Evaluation)

什么是惰性求值?

当你调用 .filter().map() 时,Stream 仅仅是将这些操作节点添加到了链表里(构建了管道),没有任何数据被遍历或计算。只有当 .collect() 被调用时,主阀门才打开。

为什么要惰性求值? (Loop Fusion / 循环融合)

这是 Stream 性能优化的核心。

如果不惰性(传统 Eager 模式):

// 假设有 100 万数据
List list1 = filter(source); // 遍历 100 万次,生成临时 List
List list2 = map(list1);     // 遍历 List1,生成临时 List

这会导致多次遍历大量内存开销

Stream 的做法 (Loop Fusion):
由于惰性机制,Stream 可以在执行前看到“全局视图”。它将 filter 和 map 的逻辑融合在了一次 accept() 调用链中。
效果数据只遍历一次。元素像走传送带一样,经过 filter 既然合格,马上进行 map,然后马上 collect,中间不产生临时集合。

额外红利:短路操作 (Short-circuiting)
比如 .limit(3)。由于是惰性的,一旦 Sink 计数器达到 3,它会立即发出“停止”信号,上游的数据源就不再发送数据了。对于无限流或海量数据,这至关重要。

4. 并行计算 (Parallel Stream)

原理:Fork/Join 框架

并行流利用了 Java 7 引入的 Fork/Join 框架。

  1. 切分 (Splitting)
  • 利用 Spliterator 接口,将大数据源不断二分(Fork),直到每个小任务的数据量适合处理。
  1. 执行 (Executing)
  • 各个小任务被提交到 ForkJoinPool.commonPool() 线程池中。
  • 每个线程领取一个任务,独立运行那一套 Sink 链条(Filter -> Map -> Collect)。
  1. 合并 (Merging)
  • 各个线程的结果最后会被汇聚。比如 collect 也是并行的,最后会将多个小 List 合并成大 List。
不适用并行计算的情形

虽然并行听起来很强,但在以下情况使用反而会变慢出错

  1. 数据量太小
  • 原因:线程切换、任务拆分、合并结果都是有开销的(Overhead)。如果计算任务本身很快(比如只是做简单的加法),拆分的开销反而比计算本身还大。
  • 经验法则:通常数据量在万级以上,且计算逻辑较复杂时,并行才有优势。
  1. 涉及装箱/拆箱 (Boxing/Unboxing)
  • 原因Stream<Integer> 处理基本类型 int 时,频繁的装箱拆箱会消耗巨大 CPU,抵消并行的优势。
  • 解法:使用 IntStream, LongStream 等特化流。
  1. 有状态操作 (Stateful Operations)
  • limit(), skip(), distinct(), sorted()
  • 原因:这些操作通常需要全局协调。比如 limit(5),即使并行处理,所有线程也必须通信,确保总共只取前 5 个,这会导致锁竞争和等待,严重拖慢并行效率。
  1. 数据依赖与线程不安全
  • 情形:如果在 Stream 中修改了外部的非线程安全集合(如 ArrayList),或者后一个元素依赖前一个元素的计算结果。
  • 后果:会导致数据丢失、越界异常或逻辑错误。


三,基础操作



数据类型转换为Stream

单列Collection

将单列集合(Collection 体系,主要包括 ListSet)转换为 Stream 是最基础也是最高频的操作。

由于 Java 8 修改了 java.util.Collection 顶层接口,给它加上了 default 方法,所以所有的 List 和 Set(比如 ArrayList, LinkedList, HashSet, TreeSet 等)都可以直接“一键转换”。

以下是几种常见场景和转换方式:

1. 标准方式:串行流 (.stream())

这是 99% 的情况下你会用到的方法。它会创建一个单线程的流,按顺序处理数据。

适用场景:绝大多数日常业务逻辑。

List<String> list = Arrays.asList("Java", "Go", "Python");

// 转换语法:
Stream<String> stream = list.stream();

// 实际使用:
list.stream()
    .filter(s -> s.length() > 2)
    .forEach(System.out::println);

底层原理
它调用了 Collection 接口默认实现的 stream() 方法,该方法内部调用了 Spliterator(可拆分迭代器)来生成流。

2. 加速方式:并行流 (.parallelStream())

如果你需要处理海量数据(例如 10 万条以上),或者计算任务非常耗时,可以使用并行流。

适用场景:大数据量计算、无状态、无顺序依赖。

Set<Integer> numbers = new HashSet<>();
// ... 假设塞入了 100万个数字 ...

// 转换语法:
Stream<Integer> parallelStream = numbers.parallelStream();

// 或者先获取普通流,再转并行:
// Stream<Integer> pStream = numbers.stream().parallel();

注意

  • .parallelStream() 会利用公共的 ForkJoinPool 线程池。
  • 不要在并行流里修改线程不安全的集合(如 ArrayList),否则会抛出异常或数据错乱。

双列集合

核心原则:Map 本身没有 .stream() 方法。

你必须先将它**“拆解”**成单列集合,才能开启流。根据你需要的零件不同,有三种拆解方式:

  1. entrySet().stream():同时拿走 键和值(最常用,最全)。
  2. keySet().stream():只拿走 键。
  3. values().stream():只拿走 值。

场景设定

假设我们有一个 Map,存储了商品的名称(Key)和价格(Value)。

Map<String, Integer> cart = new HashMap<>();
cart.put("iPhone 15", 8999);
cart.put("华为 Mate60", 6999);
cart.put("小米 14", 3999);
cart.put("机械键盘", 299);
cart.put("手机壳", 99);


方式一:entrySet().stream() (全能型)

适用场景:你在处理逻辑时,既需要 Key 又需要 Value。这是最高效的方式,因为它把键值对打包成了一个 Map.Entry 对象。

需求:找出所有价格大于 5000 元的商品,并打印出“商品名: 价格”。

// 1. 获取 entrySet (它是一个 Set<Map.Entry<String, Integer>>)
// 2. 开启流
cart.entrySet().stream()
    // 此时流里的每一个元素都是一个 Entry 对象
    .filter(entry -> entry.getValue() > 5000) // 筛选 Value > 5000
    .forEach(entry -> {
        // 同时使用 Key 和 Value
        String name = entry.getKey();
        Integer price = entry.getValue();
        System.out.println("贵重物品: " + name + ",价格: " + price);
    });


方式二:keySet().stream() (只查 Key)

适用场景:你只关心 Key,或者 Value 对你来说不重要。

需求:找出名字里包含“手”字的商品名称(不需要管价格)。

// 1. 获取 keySet (它是一个 Set<String>)
// 2. 开启流
cart.keySet().stream()
    // 此时流里的元素只是 String (商品名)
    .filter(key -> key.contains("手"))
    .forEach(key -> System.out.println("相关商品: " + key));
    
// 输出: 手机壳, 华为 Mate60 (假设包含逻辑匹配)

  • 注意:虽然你可以通过 cart.get(key) 在流里反查 Value,但非常不推荐这样做(效率低,相当于二次查找)。如果你需要 Value,请直接用方式一。

方式三:values().stream() (只查 Value)

适用场景:你完全不关心 Key,只关心数据统计。

需求:计算购物车里所有商品的总价格,或者计算平均价格。

// 1. 获取 values (它是一个 Collection<Integer>)
// 2. 开启流
double avgPrice = cart.values().stream()
    // 此时流里的元素只是 Integer (价格)
    .mapToInt(Integer::intValue) // 转为 IntStream 以利用统计方法
    .average()                   // 计算平均值
    .orElse(0.0);

System.out.println("平均价格: " + avgPrice);


数组

数组转为 Stream 主要有两种方式:Arrays.stream()Stream.of()

虽然它们看起来很像,但在处理基本数据类型数组(如 int[])时有巨大的区别,这是最大的坑。

下面我分2种情况详细讲解:


1. 引用类型数组 (如 String[], User[])

对于对象数组,这两种方式效果完全一样,随便用。

方式 A:Arrays.stream() (推荐,最标准)

String[] names = {"Java", "Python", "Go"};

// 转为 Stream<String>
Stream<String> stream = Arrays.stream(names);

stream.forEach(System.out::println);

方式 B:Stream.of() (语法糖)
底层调用的其实就是 Arrays.stream()

Stream<String> stream = Stream.of(names);


2. 基本数据类型数组 (如 int[], long[], double[]) —— 🚨这里有坑!

这是面试和实战中最容易出错的地方。

✅ 正确做法:使用 Arrays.stream()

它会智能地返回特化流IntStream, LongStream, DoubleStream),可以直接进行数值计算,且没有装箱开销。

int[] arr = {1, 2, 3, 4, 5};

// 返回的是 IntStream,而不是 Stream<Integer>
IntStream intStream = Arrays.stream(arr);

System.out.println(intStream.sum()); // 输出: 15

❌ 错误做法:使用 Stream.of()

如果你把 int[] 传给 Stream.of(),Java 会把整个数组对象当作流里的一个元素

int[] arr = {1, 2, 3, 4, 5};

// 坑:返回的是 Stream<int[]>,流里只有一个元素(就是那个数组)
Stream<int[]> stream = Stream.of(arr);

// 输出: 1 (期望是5,但实际上流里只有一个元素)
System.out.println(stream.count()); 

为什么会这样?
因为 Stream.of(T... values) 接收的是泛型可变参数。int[] 本身是一个对象,所以它被当成了一个 T。而 Integer[] 也是对象数组,但它能匹配可变参数的展开规则。


一句话建议:
不管是什么数组,无脑使用 Arrays.stream(),它是最安全、最快且支持基本数据类型优化的标准写法。


零散数据

离散数据(即手头有几个单独的变量或常量,没装在集合里)转 Stream,主要有以下三种场景,越往下越细节:

1. 通用场景:Stream.of()

最常用,适用于任何对象。

// 就像把几个散装的苹果放进传送带
Stream<String> stream = Stream.of("张三", "李四", "王五");

// 也可以传各种类型
Stream<Object> mixStream = Stream.of(1, "A", new Object());

2. 数字场景:IntStream.of()

如果你是 intlongdouble千万别用上面那个,要用特化流,避免自动装箱,性能高。

// ✅ 正确:返回 IntStream,省内存
IntStream intStream = IntStream.of(1, 2, 3, 4, 5);

// ❌ 错误:Stream.of(1, 2, 3) 会变成 Stream<Integer>,效率低

*同理还有 LongStream.of()DoubleStream.of()*

3. 防空指针场景:Stream.ofNullable() (Java 9+)

如果你手头只有一个对象,且可能是 null,用这个最安全。

String str = null;

// ✅ 得到一个空流 (Stream.empty),不会报空指针
Stream<String> stream = Stream.ofNullable(str); 

// ❌ 如果用 Stream.of(null) 会得到一个包含 null 元素的流,处理时容易报错

注意事项

Stream.of方法不能传基本数据类型的数组



中间方法


filter

filter 方法的作用就像一个筛子

它的核心逻辑是:“留下你想要的(返回 true),扔掉你不想要的(返回 false)”

它接收一个 Predicate<T> 接口作为参数。

场景设定

假设有一个数字列表,我们要筛选出大于 5 的数字

List<Integer> list = Arrays.asList(1, 10, 2, 20, 5);

1. 使用 Lambda 表达式 (推荐,现代写法)

代码极其简洁,逻辑一目了然。

list.stream()
    // 意思是:如果 n > 5 为真,就留下;否则丢弃
    .filter(n -> n > 5) 
    .forEach(System.out::println);

// 输出: 10, 20

2. 不使用 Lambda 表达式 (传统写法)

这是 Lambda 出现之前的写法(匿名内部类)。虽然现在很少这么写,但它能帮你看清 filter 的本质:它实际上是在实现 Predicate 接口的 test 方法。

import java.util.function.Predicate;

list.stream()
    .filter(new Predicate<Integer>() {
        @Override
        public boolean test(Integer n) {
            // 这里的逻辑和上面 Lambda 是一样的
            // 返回 true -> 保留该元素
            // 返回 false -> 过滤掉该元素
            return n > 5;
        }
    })
    .forEach(System.out::println);


limit

它的核心逻辑是:“只取前 N 个,后面的我都不要了”

它接收一个 long 类型的值作为参数(截取长度)。

场景设定

假设有一个包含 5 个名字的列表,我们只想要前 3 个人

List<String> list = Arrays.asList("张三", "李四", "王五", "赵六", "钱七");

代码非常语义化,直接告诉计算机“限制 3 个”。

list.stream()
    .limit(3) // 截取前 3 个,剩下的 "赵六" 和 "钱七" 直接丢弃
    .forEach(System.out::println);

// 输出: 张三, 李四, 王五


skip

假设有一个包含 5 个名字的列表,我们跳过前 3 个人

List<String> list = Arrays.asList("张三", "李四", "王五", "赵六", "钱七");

代码非常语义化,直接告诉计算机“跳过前 3 个”。

list.stream()
    .skip(3) // 跳过前 3 个,剩下的 "赵六" 和 "钱七" 不丢弃
    .forEach(System.out::println);

// 输出: 赵六 钱七


distinct

distinct 方法的作用就像一个去重机

它的核心逻辑是:“如果这个元素之前出现过,就扔掉;如果是第一次见,就留下”

不接收任何参数,但它在背后依赖对象自身的规则来判断“谁和谁是一样的”。

场景设定

假设有一个包含重复数字的列表,我们要去除重复项,只保留唯一的数字

List<Integer> list = Arrays.asList(1, 2, 2, 3, 1, 4);

1. 基础用法 (处理数字或字符串)

对于 Java 自带的类型(如 Integer, String),它们已经写好了去重规则,所以直接调用即可。

list.stream()
    // 意思是:开启“去重模式”,后续重复的元素都会被挡住
    .distinct() 
    .forEach(System.out::println);

// 输出: 1, 2, 3, 4 (顺序通常保留首次出现的顺序)

2. 进阶原理 (处理自定义对象) **

它的“底层逻辑”写在数据对象本身里。distinct 依赖对象的 hashCode()equals() 方法来判断两个元素是否相同。

场景:如果你有一个自定义的 Student 类:

class Student {
    String name;
    public Student(String name) { this.name = name; }
    // 假设这里还没写 equals 和 hashCode...
}

问题出现

List<Student> students = Arrays.asList(
    new Student("张三"), 
    new Student("张三") // 虽然名字一样,但在计算机眼里这是两个不同的对象(内存地址不同)
);

students.stream()
    .distinct()
    .forEach(s -> System.out.println(s.name));

// 惨痛结局:输出了两个“张三”。去重失败!

底层解决办法 (重写 equals/hashCode)

要让 distinct 正常工作,你必须告诉 Java “什么样才算同一个学生”

class Student {
    String name;
    // ... 构造方法 ...

    // 1. 重写 equals:告诉 Java,名字一样就是同一个人
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Student student = (Student) o;
        return Objects.equals(name, student.name);
    }

    // 2. 重写 hashCode:相等的对象必须有相同的哈希码(这是 HashMap/HashSet/distinct 的铁律)
    @Override
    public int hashCode() {
        return Objects.hash(name);
    }
}

加上这段代码后,再运行 distinct(),就会成功输出一个“张三”。


concat

concat 的核心逻辑是:物理拼接。它不关心内容,只是单纯把两个管道焊在一起,变成一根长管道。

1. 简单示例:两个同类合并

这是最标准的用法,类型一样(都是 String),直接首尾相连。

// 两个独立的队伍
Stream<String> teamA = Stream.of("张三", "李四");
Stream<String> teamB = Stream.of("王五", "赵六");

// 动作:拼接
// 注意:必须用 Stream.concat(a, b) 这种静态写法
Stream<String> allTeam = Stream.concat(teamA, teamB);

allTeam.forEach(System.out::println);

// 输出顺序:
// 张三 -> 李四 -> 王五 -> 赵六 
// (严格按照先 A 后 B 的顺序)

2. 核心注意事项:类型不一样怎么办?

这是很多新手容易踩的坑。比如你要合并一个 Stream<String> 和一个 Stream<Integer>

情况 A:直接硬拼 (变成 Object)
Java 会自动寻找这两个类型的共同父类。对于 String 和 Integer,它们的共同父类是 Object(以及 Serializable 等接口)。

Stream<String> strStream = Stream.of("A", "B");
Stream<Integer> intStream = Stream.of(1, 2);

// 结果类型变成了 Stream<? extends Object>
Stream<? extends Object> mixedStream = Stream.concat(strStream, intStream);

mixedStream.forEach(obj -> {
    // 麻烦点:这里你拿到的是 Object,没法直接调用 String 的方法
    // 需要 instanceof 判断,很丑陋
    System.out.println(obj.getClass() + ": " + obj);
});

情况 B:先转换,再拼接 (推荐)
最好的办法是先用 map 把它们变成同一种类型,然后再 concat

Stream<String> strStream = Stream.of("A", "B");
Stream<Integer> intStream = Stream.of(1, 2);

// 解决:把 Integer 先 map 成 String
Stream<String> convertedStream = intStream.map(String::valueOf);

// 现在大家都是 String 了,完美拼接
Stream<String> result = Stream.concat(strStream, convertedStream);

3. 致命坑点:原来的流不能再用了!

流(Stream)是一次性的资源。一旦你把它交给了 concat,原来的 teamAteamB 就相当于被消耗掉了

Stream<String> teamA = Stream.of("A");
Stream<String> teamB = Stream.of("B");

Stream<String> result = Stream.concat(teamA, teamB);

// 错误示范!
// teamA.forEach(...) 
// 报错: java.lang.IllegalStateException: stream has already been operated upon or closed
// 解释: teamA 已经被合并进 result 里了,你只能操作 result,不能再碰 teamA。


map

map 方法的作用就像一个加工厂(或者模具)。

它的核心逻辑是:“进来一个东西,我把它改成另一个样子,然后再吐出去”。它不仅能修改数据的值,还能改变数据的类型

它接收一个 Function<T, R> 接口作为参数(T 是输入类型,R 是输出类型)。

场景设定

假设我们有一个全是小写字母的单词列表。

List<String> list = Arrays.asList("apple", "banana", "orange");

1. 使用 Lambda 表达式 (推荐)

我们要把单词全部变成大写

list.stream()
    // 逻辑:s 进来,s.toUpperCase() 出去
    .map(s -> s.toUpperCase()) 
    .forEach(System.out::println);

// 输出: APPLE, BANANA, ORANGE

2. 不使用 Lambda 表达式 (传统写法)

这能让你看到 map 的底层其实是在实现 Function 接口的 apply 方法。

import java.util.function.Function;

list.stream()
    .map(new Function<String, String>() {
        @Override
        public String apply(String s) {
            // 这里的逻辑和上面 Lambda 是一样的
            // 输入一个 String,返回一个处理后的 String
            return s.toUpperCase();
        }
    })
    .forEach(System.out::println);

3. 进阶用法:改变数据类型 (最常用的场景)

map 最强大的地方在于它可以把 A 类型变成 B 类型

场景:我们有一组 User 对象,但我只想要他们的名字列表

// 假设有 User 类
List<User> users = Arrays.asList(
    new User("张三", 20),
    new User("李四", 30)
);

// 此时流的类型是 Stream<User>
List<String> names = users.stream()
    // 关键点:输入 User -> 输出 String
    // 流的类型在这里发生了改变:从 User流 变成了 String流
    .map(user -> user.getName()) 
    .collect(Collectors.toList());

// 结果: ["张三", "李四"]

场景延伸:计算字符串长度(String -> Integer)。

List<Integer> lengths = list.stream()
    .map(s -> s.length()) // 输入 "apple" (String) -> 输出 5 (Integer)
    .collect(Collectors.toList());

// 结果: [5, 6, 6]

4. 注意事项
  1. 一对一映射 (1:1)
    map 也是非常守规矩的。输入 10 个元素,输出肯定也是 10 个元素。它不会filter 那样减少数量,也不会flatMap 那样增加数量。
  2. 中间可能产生 Null
    如果你在 map 的逻辑里返回了 null,那么流里就会混进去 null 元素,这可能会导致后续操作(比如 .map(s -> s.length()))报空指针异常。
// 危险操作示例
.map(s -> {
    if ("banana".equals(s)) return null; // 埋雷
    return s.toUpperCase();
})
// 后面如果再操作这个流,碰到 null 就会炸

  1. 不要在 map 里做耗时操作
    尽量保持 map 的逻辑纯粹(只做数据转换)。如果你在 map 里查数据库或者调 API,整个流的处理速度会被严重拖慢(除非你用并行流,但那有更复杂的问题)。


终结方法


forEach

forEach 的作用就像流水线末端的装箱工人

它的核心逻辑是:“拿到一个,处理一个,然后结束”。它负责消费流中的数据,通常用于产生“副作用”(比如打印日志、存入数据库)。

它接收一个 Consumer<T> 接口作为参数(Consumer = 消费者)。

场景设定

假设我们有一组任务名称,现在要逐个执行打印出来。

List<String> tasks = Arrays.asList("发邮件", "写代码", "开会");

1. 使用 Lambda 表达式 (推荐)

代码最简洁,专门用来“消费”数据。

tasks.stream()
    // 逻辑:拿到 task,直接打印
    .forEach(task -> System.out.println("正在执行: " + task));

// 或者用方法引用(更简短):
// .forEach(System.out::println);

2. 不使用 Lambda 表达式 (传统写法)

这能让你看到 forEach 的本质是在实现 Consumer 接口的 accept 方法。

import java.util.function.Consumer;

tasks.stream()
    .forEach(new Consumer<String>() {
        @Override
        public void accept(String task) {
            // 这里的逻辑和上面 Lambda 是一样的
            // 只负责接收,没有返回值
            System.out.println("正在执行: " + task);
        }
    });

3. 进阶用法:产生“副作用” (Side Effects)

既然 forEach 不能返回值,那它通常用来改变外部世界。比如把数据存到另一个列表,或者写入数据库。

场景:把所有任务保存到一个外部的 resultList 中。

List<String> tasks = Arrays.asList("Task A", "Task B");
List<String> resultList = new ArrayList<>();

tasks.stream()
    .filter(t -> t.startsWith("Task")) // 先过滤
    .forEach(t -> {
        // 动作:修改外部的 list
        // 这就是所谓的“副作用”
        resultList.add(t); 
    });

// 此时 resultList 里就有数据了

4. 注意事项 (非常重要)
  1. 这是一条死胡同
    forEach 返回的是 void。你不能写成 .forEach(...).collect(...) 或者 .forEach(...).filter(...)。它必须写在链条的最后一步
  2. 不要在 forEach 里修改元素本身
    很多新手想用 forEach 来改变数据的值,这是错的!改变值应该用 map
  • .forEach(student -> student.age += 1) (虽然能跑,但逻辑混淆,且不利于并行)
  • .map(student -> { student.age +=1; return student; })
  1. 并行流 (Parallel Stream) 的顺序问题
    如果你开启了并行流 (parallelStream()),forEach 的执行顺序是乱序的。
Arrays.asList(1, 2, 3, 4).parallelStream().forEach(System.out::println);
// 输出可能变成: 3, 1, 4, 2 (无法预测)

如果你在并行流中必须保证顺序,请使用 forEachOrdered(但会牺牲并行带来的性能优势)。

  1. 别在 forEach 里写复杂逻辑
    如果 forEach 里的代码超过 3 行,建议把逻辑抽离成一个单独的方法,然后在 forEach 里调用该方法,保持 Stream 代码的清爽。

count

count 方法的作用就像一个计数器(或者门口的安保人员掐表)。

它是终结方法(Terminal Operation)
它的核心逻辑是:“别废话,告诉我一共剩下了多少个元素”。它不关心元素的内容是什么,只关心数量

不接收任何参数(这是它和 filtermap 等最大的不同),返回一个 long 类型的值。

场景设定

假设我们有一箱水果。

List<String> fruits = Arrays.asList("Apple", "Banana", "Orange", "Apple");

1. 用法:配合中间操作 (最常用)

count 通常和 filterdistinct 连用,用来统计满足特定条件的数量。

场景:我想知道有几个水果是 “Apple”。

long appleCount = fruits.stream()
    // 先用筛子筛出来
    .filter(f -> "Apple".equals(f)) 
    // 再数剩下的数量
    .count();

System.out.println("苹果数量: " + appleCount);
// 输出: 2

场景:我想知道一共有几种不同的水果(去重后计数)。

long uniqueCount = fruits.stream()
    .distinct()
    .count();

System.out.println("水果种类: " + uniqueCount);
// 输出: 3 (Apple, Banana, Orange)

3. 核心注意事项 (踩坑指南)
  1. 返回类型是 long
    它返回的不是 int。因为 Stream 可能是用来处理海量数据的(超过 21亿个),int 装不下,所以必须用 long
  2. 无限流 (Infinite Stream) 的陷阱
    如果你在一个无限流上调用 count(),程序会永远卡死(或者直到内存耗尽)。
// ❌ 这是一个死循环
Stream.generate(() -> "Echo")
      .count(); // 它试图数完无尽的星星,永远数不完


toArray

接受建议!为了保持队形整齐,确实应该把**完全不使用 Lambda(匿名内部类)**的写法补全。这样你可以清楚地看到 toArray 到底在调用哪个接口。


toArray

它是终结方法(Terminal Operation)。它负责把处理完的流元素,打包成一个固定长度的数组

场景设定

假设我们有一组英雄的名字,想把它们取出来放到一个 String[] 数组里,传给老旧的 API 使用。

Stream<String> stream = Stream.of("Batman", "Superman", "Wonder Woman");

1. 使用 Lambda 表达式 / 方法引用 (推荐)

这是最现代、最简洁的写法。

  • 写法 A(方法引用 - 最推荐)
    直接告诉 Stream 用哪个构造器。
// 意思就是:给我用 String[] 的构造器
String[] heroes = stream.toArray(String[]::new);

  • 写法 B(Lambda 表达式)
    写出具体的逻辑。
// 意思就是:你会给我一个长度 length,我给你返回一个这么长的新数组
String[] heroes = stream.toArray(length -> new String[length]);

2. 不使用 Lambda 表达式 (传统写法)

这能让你看清 toArray 接收的参数其实是一个 IntFunction<A[]> 接口。它的 apply 方法接收一个 int(Stream 帮你数好的元素个数),你需要返回一个对应长度的数组容器。

import java.util.function.IntFunction;

String[] heroes = stream.toArray(new IntFunction<String[]>() {
    @Override
    public String[] apply(int length) {
        // 这里的逻辑和上面 Lambda 是一样的
        // 1. length 是 Stream 传进来的(它知道一共有 3 个元素)
        // 2. 你需要创建一个长度为 3 的空数组并返回
        // 3. Stream 随后会把数据填进去
        return new String[length];
    }
});

// 输出结果验证
System.out.println(heroes.length); // 3
System.out.println(heroes[0]);     // Batman

3. 特殊情况:基本数据类型 (int, long, double)

如果你处理的是 IntStream(全是数字的流),情况比较特殊。
IntStreamtoArray()不需要参数的,因为它很清楚自己只能转成 int[]

// 不需要传 new IntFunction... 也不需要传 int[]::new
int[] numbers = IntStream.of(1, 2, 3).toArray(); 

4. 注意事项
  1. 为什么要传参数?
    如果你直接调 stream.toArray()(不带参),Java 会因为泛型擦除的原因,只能给你返回一个 Object[]。你拿回去也没法直接当字符串数组用(强转会报错),所以必须传个“模具”告诉它具体类型。
  2. 返回的是定长数组
    一旦 toArray 执行结束,得到的数组长度就定死了。不能像 List 一样继续 .add()
  3. 对比
  • collect(Collectors.toList()) -> 得到灵活的 ArrayList(推荐)。
  • toArray(String[]::new) -> 得到固定的 String[](只有在必须和旧代码交互时才用)。

collect

它是终结方法(Terminal Operation),也是 Stream API 中功能最强大、最灵活的方法。
它的核心逻辑是:“把传送带上一个个零散的零件,按照说明书,组装成你想要的成品(List, Map, Set, String…)”

它接收一个 Collector 接口,或者三个参数(Supplier, Accumulator, Combiner)。

场景设定

假设我们有一堆杂乱的乐高积木(颜色字符串),我们需要把它们整理好。

Stream<String> blocks = Stream.of("红砖", "蓝砖", "红砖", "黄砖");

1. 基础用法:使用工具类 (99% 的场景)

在绝大多数情况下,我们不需要自己写逻辑,直接用 Java 提供的 Collectors 工具类即可。

// 1. 打包成 List (有序,允许重复)
List<String> list = blocks.collect(Collectors.toList());

// 2. 打包成 Set (去重)
// Set<String> set = blocks.collect(Collectors.toSet());

// 3. 拼成字符串 (拼接)
// String result = blocks.collect(Collectors.joining(",")); 
// 输出: "红砖,蓝砖,红砖,黄砖"

2. 原理剖析:不使用 Lambda / 工具类 (硬核写法)

为了让你看透 collect 到底在干什么,我们来看它的三参数重载版本collect(Supplier, Accumulator, Combiner)
这个版本揭示了收集数据的三个核心步骤:造容器、装进去、合起来

需求:把流里的元素装进一个 ArrayList

传统写法 (匿名内部类)

import java.util.ArrayList;
import java.util.function.Supplier;
import java.util.function.BiConsumer;

ArrayList<String> result = blocks.collect(
    // 1. Supplier: 制造一个新的容器 (造箱子)
    new Supplier<ArrayList<String>>() {
        @Override
        public ArrayList<String> get() {
            return new ArrayList<>(); // 返回一个空列表
        }
    },
    // 2. Accumulator: 怎么把元素放进容器 (装箱子)
    new BiConsumer<ArrayList<String>, String>() {
        @Override
        public void accept(ArrayList<String> list, String item) {
            list.add(item); // 把积木放进列表
        }
    },
    // 3. Combiner: 并行流时,怎么合并两个容器 (把两个箱子的货倒在一起)
    new BiConsumer<ArrayList<String>, ArrayList<String>>() {
        @Override
        public void accept(ArrayList<String> list1, ArrayList<String> list2) {
            list1.addAll(list2); 
        }
    }
);

对应的 Lambda 写法
看看上面有多繁琐,再看看 Lambda 有多香:

// 这里的 ArrayList::new 就是 Supplier
// 这里的 List::add 就是 Accumulator
// 这里的 List::addAll 就是 Combiner
List<String> result = blocks.collect(ArrayList::new, List::add, List::addAll);

collect的第三个参数详解

这个参数在 Stream API 中被称为 组合器 (Combiner)

为什么需要它?(并行计算视角)

在单线程(串行)模式下,确实只有一个结果容器,这个参数通常不会被实际触发(或者说不重要)。但在并行模式下,它是不可或缺的。

当我们调用 .parallelStream() 时,Java 采用了 Fork/Join 框架

  1. 数据切分 (Fork)
    系统将原始的大任务(比如 1000 个元素)切分成多个小任务(比如 4 个线程,每个处理 250 个)。
  2. 独立处理 (Accumulate)
    每个线程都会创建自己独立ArrayList(通过第一个参数 Supplier 创建)。
    每个线程利用第二个参数 Accumulator (List::add),将自己负责的那 250 个元素填充到自己的 ArrayList 中。
  • 此时,内存中存在 4 个独立的 ArrayList,分别存放着局部结果(Partial Results)。
  1. 结果合并 (Join / Combine)
    这是第三个参数 Combiner 发挥作用的时刻。
    主线程(或协调线程)需要将这 4 个独立的 ArrayList 合并成一个最终的 ArrayList
    它会两两调用你提供的 Combiner 逻辑:
  • list1 是线程 A 的结果容器。
  • list2 是线程 B 的结果容器。
  • 执行 list1.addAll(list2) 后,list1 就包含了 A 和 B 的总和。

代码逻辑解析

new BiConsumer<ArrayList<String>, ArrayList<String>>() {
    @Override
    public void accept(ArrayList<String> list1, ArrayList<String> list2) {
        // 这里的 list1 和 list2 是两个不同线程产生的“局部结果容器”
        // 这一步是在做“归并”操作
        list1.addAll(list2); 
    }
}

  • list1:代表“主分支”或者“前一个任务”累积下来的结果列表。
  • list2:代表“子分支”或者“并行任务”计算出来的结果列表。
  • list1.addAll(list2):这是将物理上隔离的两个内存对象中的数据,拷贝到同一个对象中,完成数据的聚合。

为了理解透彻,必须区分第二个参数和第三个参数的不同:

参数位置 名称 类型签名 作用对象 物理含义
第二个 Accumulator (累加器) BiConsumer<List, String> 容器 + 元素 将一个原始数据放入容器。
第三个 Combiner (组合器) BiConsumer<List, List> 容器 + 容器 两个容器合并为一个。
3. 进阶用法:分组与分区 (Map 的魔法)

collect 最厉害的地方在于它可以生成 Map

场景:有一组学生,想按年级把他们分组。

class Student {
    String name;
    String grade;
    // ... 构造函数 ...
}

List<Student> students = Arrays.asList(
    new Student("张三", "一年级"),
    new Student("李四", "二年级"),
    new Student("王五", "一年级")
);

// 目标:得到 Map<String, List<Student>>
// Key是年级,Value是该年级的学生列表
Map<String, List<Student>> groups = students.stream()
    .collect(Collectors.groupingBy(s -> s.grade));

// 结果:
// "一年级" -> [张三, 王五]
// "二年级" -> [李四]

4. 注意事项
  1. 并行流 (Parallel Stream) 的合并
    在上面的“硬核写法”中,第三个参数 Combiner 只有在并行流中才会被用到。
  • 如果是单线程,我就一个箱子装到底,不需要合并。
  • 如果是多线程(并行),每条线程都有自己的箱子,最后必须把小箱子里的货倒进大箱子,这就是 Combiner 的作用。
  1. 不可变集合 (Java 10+)
    Collectors.toList() 返回的 ArrayList可变的(你可以继续 .add())。
    如果你想得到一个只读列表,应该用:
.collect(Collectors.toUnmodifiableList());

  1. 万能的 toMap 有坑
    如果你用 Collectors.toMap(keyMapper, valueMapper),一旦 Key 重复,它会直接报错 (Duplicate Key)。
    解决:必须提供第三个参数(合并策略)。
// 如果 Key 重复,保留旧的 (k1),还是用新的 (k2)?
.collect(Collectors.toMap(User::getId, User::getName, (k1, k2) -> k2)); 

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐