Java Stream 原理与实践
一,背景(为什么要有Stream)
背景一:为了拯救“很多核但不会用”的 CPU
—— 解决并行计算难的问题
1. 背景故事
要想利用多核,你得写 Thread、Lock、ExecutorService,还要处理死锁和线程安全。这太难了,导致大部分 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 个数 -> 大于 100 吗?否 -> 丢弃。
- …
- 拿第 N 个数 -> 大于 100 吗?是 -> 乘 2 -> 放进结果集(现在有1个了)。
- …
- 当结果集满 3 个时,立即停止,后面的数据看都不看。
二,基本原理
Stream流的使用步骤:
1.将集合转为Stream流
2.使用中间方法对Stream流上的数据进行操作
3.使用终结方法操作Stream流上的数据
1. Stream 的生命周期 (Lifecycle)
Stream 的一次完整操作分为三个阶段,严格遵循这个顺序:
- 创建流 (Source):
- 数据的源头。可以是集合 (
List,Set)、数组、I/O Channel,甚至是无限序列生成器。 - 关键点:此时流还不知道要干什么。
- 中间操作 (Intermediate Operations):
-
定义逻辑。如
filter,map,sorted,limit。 -
特性:
-
Lazy (惰性):调用这些方法时,代码不会立即执行,只是把操作记录在“账本”上。
-
返回新流:每个操作都会返回一个新的 Stream 对象,便于链式调用。
-
分类:
-
无状态 (Stateless):
filter,map。处理元素互不干扰。 -
有状态 (Stateful):
sorted,distinct。需要拿到所有元素才能进行下一步。
- 终止操作 (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 是一个消费者接口,包含三个核心方法:
begin(long size):通知下游准备接收数据。accept(T t):核心处理逻辑(数据来了,怎么搞)。end():通知下游数据发完了。
“洋葱模型”的构建过程:
当终止操作被触发时,Stream 会从最后一个节点(Terminal)开始倒着往前推,将每一层的逻辑“包装”成一个大的 Sink 对象。
- SinkMap 里面包着 SinkCollect
- SinkFilter 里面包着 SinkMap
实际执行流 (accept 链条):
当源头数据 x 进来时,它是这样穿透的:
- 调用
SinkFilter.accept(x):
- 判断
x > 5? - 如果是
true-> 调用下游SinkMap.accept(x)。 - 如果是
false-> 直接返回(丢弃数据)。
- 调用
SinkMap.accept(x):
- 计算
y = x * 2。 - 调用下游
SinkCollect.accept(y)。
- 调用
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 框架。
- 切分 (Splitting):
- 利用
Spliterator接口,将大数据源不断二分(Fork),直到每个小任务的数据量适合处理。
- 执行 (Executing):
- 各个小任务被提交到
ForkJoinPool.commonPool()线程池中。 - 每个线程领取一个任务,独立运行那一套 Sink 链条(Filter -> Map -> Collect)。
- 合并 (Merging):
- 各个线程的结果最后会被汇聚。比如
collect也是并行的,最后会将多个小 List 合并成大 List。
不适用并行计算的情形
虽然并行听起来很强,但在以下情况使用反而会变慢或出错:
- 数据量太小:
- 原因:线程切换、任务拆分、合并结果都是有开销的(Overhead)。如果计算任务本身很快(比如只是做简单的加法),拆分的开销反而比计算本身还大。
- 经验法则:通常数据量在万级以上,且计算逻辑较复杂时,并行才有优势。
- 涉及装箱/拆箱 (Boxing/Unboxing):
- 原因:
Stream<Integer>处理基本类型int时,频繁的装箱拆箱会消耗巨大 CPU,抵消并行的优势。 - 解法:使用
IntStream,LongStream等特化流。
- 有状态操作 (Stateful Operations):
- 如:
limit(),skip(),distinct(),sorted()。 - 原因:这些操作通常需要全局协调。比如
limit(5),即使并行处理,所有线程也必须通信,确保总共只取前 5 个,这会导致锁竞争和等待,严重拖慢并行效率。
- 数据依赖与线程不安全:
- 情形:如果在 Stream 中修改了外部的非线程安全集合(如
ArrayList),或者后一个元素依赖前一个元素的计算结果。 - 后果:会导致数据丢失、越界异常或逻辑错误。
三,基础操作
数据类型转换为Stream
单列Collection
将单列集合(Collection 体系,主要包括 List 和 Set)转换为 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() 方法。
你必须先将它**“拆解”**成单列集合,才能开启流。根据你需要的零件不同,有三种拆解方式:
entrySet().stream():同时拿走 键和值(最常用,最全)。keySet().stream():只拿走 键。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()
如果你是 int、long、double,千万别用上面那个,要用特化流,避免自动装箱,性能高。
// ✅ 正确:返回 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,原来的 teamA 和 teamB 就相当于被消耗掉了。
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):
map也是非常守规矩的。输入 10 个元素,输出肯定也是 10 个元素。它不会像filter那样减少数量,也不会像flatMap那样增加数量。 - 中间可能产生 Null:
如果你在map的逻辑里返回了null,那么流里就会混进去null元素,这可能会导致后续操作(比如.map(s -> s.length()))报空指针异常。
// 危险操作示例
.map(s -> {
if ("banana".equals(s)) return null; // 埋雷
return s.toUpperCase();
})
// 后面如果再操作这个流,碰到 null 就会炸
- 不要在 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. 注意事项 (非常重要)
- 这是一条死胡同:
forEach返回的是void。你不能写成.forEach(...).collect(...)或者.forEach(...).filter(...)。它必须写在链条的最后一步。 - 不要在 forEach 里修改元素本身:
很多新手想用forEach来改变数据的值,这是错的!改变值应该用map。
- ❌ 错:
.forEach(student -> student.age += 1)(虽然能跑,但逻辑混淆,且不利于并行) - ✅ 对:
.map(student -> { student.age +=1; return student; })
- 并行流 (Parallel Stream) 的顺序问题:
如果你开启了并行流 (parallelStream()),forEach的执行顺序是乱序的。
Arrays.asList(1, 2, 3, 4).parallelStream().forEach(System.out::println);
// 输出可能变成: 3, 1, 4, 2 (无法预测)
如果你在并行流中必须保证顺序,请使用 forEachOrdered(但会牺牲并行带来的性能优势)。
- 别在 forEach 里写复杂逻辑:
如果forEach里的代码超过 3 行,建议把逻辑抽离成一个单独的方法,然后在forEach里调用该方法,保持 Stream 代码的清爽。
count
count 方法的作用就像一个计数器(或者门口的安保人员掐表)。
它是终结方法(Terminal Operation)。
它的核心逻辑是:“别废话,告诉我一共剩下了多少个元素”。它不关心元素的内容是什么,只关心数量。
它不接收任何参数(这是它和 filter、map 等最大的不同),返回一个 long 类型的值。
场景设定
假设我们有一箱水果。
List<String> fruits = Arrays.asList("Apple", "Banana", "Orange", "Apple");
1. 用法:配合中间操作 (最常用)
count 通常和 filter 或 distinct 连用,用来统计满足特定条件的数量。
场景:我想知道有几个水果是 “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. 核心注意事项 (踩坑指南)
- 返回类型是
long:
它返回的不是int。因为 Stream 可能是用来处理海量数据的(超过 21亿个),int装不下,所以必须用long。 - 无限流 (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(全是数字的流),情况比较特殊。IntStream 的 toArray() 是不需要参数的,因为它很清楚自己只能转成 int[]。
// 不需要传 new IntFunction... 也不需要传 int[]::new
int[] numbers = IntStream.of(1, 2, 3).toArray();
4. 注意事项
- 为什么要传参数?
如果你直接调stream.toArray()(不带参),Java 会因为泛型擦除的原因,只能给你返回一个Object[]。你拿回去也没法直接当字符串数组用(强转会报错),所以必须传个“模具”告诉它具体类型。 - 返回的是定长数组:
一旦toArray执行结束,得到的数组长度就定死了。不能像 List 一样继续.add()。 - 对比:
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 框架:
- 数据切分 (Fork):
系统将原始的大任务(比如 1000 个元素)切分成多个小任务(比如 4 个线程,每个处理 250 个)。 - 独立处理 (Accumulate):
每个线程都会创建自己独立的ArrayList(通过第一个参数Supplier创建)。
每个线程利用第二个参数Accumulator(List::add),将自己负责的那 250 个元素填充到自己的ArrayList中。
- 此时,内存中存在 4 个独立的
ArrayList,分别存放着局部结果(Partial Results)。
- 结果合并 (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. 注意事项
- 并行流 (Parallel Stream) 的合并:
在上面的“硬核写法”中,第三个参数Combiner只有在并行流中才会被用到。
- 如果是单线程,我就一个箱子装到底,不需要合并。
- 如果是多线程(并行),每条线程都有自己的箱子,最后必须把小箱子里的货倒进大箱子,这就是
Combiner的作用。
- 不可变集合 (Java 10+):
Collectors.toList()返回的ArrayList是可变的(你可以继续.add())。
如果你想得到一个只读列表,应该用:
.collect(Collectors.toUnmodifiableList());
- 万能的 toMap 有坑:
如果你用Collectors.toMap(keyMapper, valueMapper),一旦 Key 重复,它会直接报错 (Duplicate Key)。
解决:必须提供第三个参数(合并策略)。
// 如果 Key 重复,保留旧的 (k1),还是用新的 (k2)?
.collect(Collectors.toMap(User::getId, User::getName, (k1, k2) -> k2));
更多推荐

所有评论(0)