一、流是什么,为什么需要它
很多人学流的时候,第一个疑问就是:我有集合了,为什么还需要流?
要回答这个问题,先要理解集合和流的本质区别:
集合:关注的是数据怎么存
流: 关注的是数据怎么处理
集合是一个容器,你把东西放进去,需要的时候取出来。而流完全不一样,流没有存储,它更像是一条传送带,数据在上面流动,你在传送带旁边安装各种处理装置,数据经过的时候自动被处理。
这个区别带来了一个非常重要的思维转变:以前你在想怎么操作集合,现在你只需要描述你想要什么结果。
二、命令式 vs 声明式,这才是流的灵魂
书里举了一个非常好的例子,生成5到20之间不重复的7个随机整数并排序。
传统写法(命令式):
Random rand = new Random(47);
SortedSet<Integer> rints = new TreeSet<>();
while(rints.size() < 7) {
int r = rand.nextInt(20);
if(r < 5) continue;
rints.add(r);
}
你必须仔细读完整段代码,才能弄明白它在干什么。为什么有个while循环?为什么要判断r<5?这些都是实现细节,和你真正想表达的意图混在一起,阅读负担很重。
流式写法(声明式):
new Random(47)
.ints(5, 20)
.distinct()
.limit(7)
.sorted()
.forEach(System.out::println);
这段代码几乎就是在用中文说话:生成5到20的随机数,去重,取7个,排序,打印。代码直接表达了意图,而不是实现步骤。
这就是声明式编程的魅力:说你想要什么,而不是说怎么做。
三、流的三种操作,必须牢记
流的所有操作分为三类,理解这三类是学流的基础:
第一类:创建流
流从哪里来?有很多来源:
从集合创建 ,这是最常见的方式,任何集合调用 .stream() 就能得到一个流。你的List、Set、Map都可以变成流。
从数组创建 ,通过 Arrays.stream() 把数组变成流。
直接创建 ,用 Stream.of() 直接把几个元素变成流,就像 Stream.of("张三","李四","王五")。
无限流 ,这是流最神奇的地方之一。Stream.generate() 和 Stream.iterate() 可以创建理论上无限长的流。比如用 Stream.generate() 不断生成随机数,用 Stream.iterate() 生成斐波那契数列。这在集合里是完全不可能的,你不可能创建一个无限长的ArrayList。
第二类:中间操作
中间操作是流的核心,它接收一个流,返回另一个流,所以可以链式调用,一个接一个串起来。
filter(过滤):给它一个条件,满足条件的元素留下,不满足的丢掉。就像筛子。
map(映射/转换):把每个元素转换成另一种形式。把字符串列表转成长度列表,把用户列表转成用户名列表,都是map干的事。
distinct(去重):去掉重复元素,不需要手动维护Set了。
sorted(排序):可以用默认排序,也可以传入自定义的比较器。
limit(截取):只取前N个元素,对无限流特别重要,有了limit才能从无限流里取有限个元素。
skip(跳过):跳过前N个元素,和limit配合可以实现分页效果。
peek(查看):这是个专门用来调试的操作,可以查看流中每个元素,但不改变它,就像在流水线上装了个摄像头。
flatMap(扁平化):这个稍微复杂,但非常重要。如果你的map操作产生的是一个流的流(每个元素变成了一个流),flatMap会把这些流都摊平,合并成一个流。就像把一箱箱苹果全部倒出来,变成一堆苹果。
第三类:终端操作
终端操作是流的终点,调用之后流就结束了,产生一个最终结果。
forEach:遍历每个元素,做某件事,没有返回值。
collect :把流收集成集合或其他形式,是最常用的终端操作。比如收集成List、Set,或者用 Collectors.joining() 把字符串流拼接成一个字符串。
sum/count/min/max:数学统计操作,对数值流特别有用。
findFirst/findAny:找到第一个或任意一个满足条件的元素。
noneMatch/anyMatch/allMatch:判断流中的元素是否满足某个条件。
四、流最容易被忽略的特性:懒加载
这是流的一个核心特性,很多人学了很久都没真正理解它。
流是懒加载的,中间操作在你调用终端操作之前,一个字节都不会执行。
Stream.of(1, 2, 3, 4, 5)
.filter(n -> {
System.out.println("filter: " + n);
return n > 3;
})
.map(n -> {
System.out.println("map: " + n);
return n * 2;
});
// 此时什么都没打印!因为没有终端操作
只有加上终端操作,整条流水线才真正运转起来:
.forEach(System.out::println);
// 现在才开始执行
懒加载带来了两个巨大好处:
第一,无限流成为可能。你可以创建一个理论上无限的流,因为它不会真的去生成无限个元素,只有在被需要的时候才生成。加上limit限制,整个计算就变得有限可控。
第二,性能优化 。流可以短路求值,比如 findFirst() 找到第一个满足条件的元素就立刻停止,不会傻傻地把剩余元素都处理一遍。
五、内部迭代 vs 外部迭代
这个概念书里特别强调,值得单独说清楚。
外部迭代就是我们传统的for循环写法,你自己控制迭代的过程:从哪开始、到哪结束、每次怎么走,全部由你来指挥。
内部迭代就是流的方式,你把迭代的控制权交出去,只告诉流"我要过滤"、"我要排序",具体怎么迭代是流内部的事,你看不到也不需要关心。
放弃迭代控制权听起来像是失去了什么,实际上却是获得了更多:
你不控制迭代 → 流可以自由决定怎么迭代
→ 并行处理成为可能
→ 只需要改一行代码,流就可以用多核并行处理
→ 你写串行代码,享受并行性能
这就是为什么书里说"通过放弃对迭代过程的控制,可以把控制权交给并行化机制"。
六、flatMap,流里最难理解但最重要的操作
很多人学到flatMap就卡住了,觉得很抽象。其实用一个生活例子就能说清楚。
假设你有三个快递箱(三个流),每个箱子里有几件衣服(元素)。
map的结果:给你三个箱子,每个箱子里的衣服处理了一下,但还是三个箱子装着。
flatMap的结果:把三个箱子全部打开,所有衣服都倒出来,变成一堆衣服,箱子消失了。
map: [箱子1[衣服,衣服], 箱子2[衣服], 箱子3[衣服,衣服,衣服]]
flatMap: [衣服, 衣服, 衣服, 衣服, 衣服, 衣服]
在实际编程中,最典型的场景就是处理文件单词。每一行是一个元素,你想得到所有单词的流。用map把每行分割成单词数组,得到的是"数组的流"。用flatMap才能得到真正的"单词的流"。
七、流的创建方式全景
除了最常见的集合转流,还有几个特别有用的创建方式值得了解:
Stream.generate():接收一个Supplier,不断调用它生成元素。适合生成随机数、创建相同对象的序列等场景。因为是无限流,必须配合limit使用。
Stream.iterate():接收一个初始值和一个函数,每次把上一个结果传给函数得到下一个值。天然适合生成数列,比如斐波那契数列、等差数列。
IntStream.range():生成整数范围的流,可以完美替代传统for循环,让循环逻辑变成流式风格。
Files.lines():直接把文件的每一行变成流,处理文件再也不需要先把所有行读入内存。
八、一个完整的流使用场景
把所有知识串起来,看一个完整的例子:
想象你有一个订单列表,要找出所有金额大于1000元的订单,提取订单号,去重后排序,最后拼成一个字符串展示:
订单列表
→ filter:只要金额>1000的 (中间操作,Predicate)
→ map:提取订单号 (中间操作,Function)
→ distinct:去重 (中间操作)
→ sorted:排序 (中间操作)
→ collect(joining(",")):拼接字符串 (终端操作)
整个过程就像一条流水线,数据从左边进来,经过一道道工序,最终得到你想要的结果。没有临时变量,没有循环,代码即注释。
九、总结:流改变了什么
改变了思维方式:
以前:怎么操作数据(命令式)
现在:想要什么结果(声明式)
改变了代码风格:
以前:循环+判断+临时变量,逻辑分散
现在:链式调用,逻辑集中,一眼看清意图
改变了性能可能性:
以前:串行是默认,并行需要大量改造
现在:parallel()一行代码,串行变并行
流的本质:
不是新的数据结构
是一种新的处理数据的方式
是声明式编程思想在Java中的体现
学会用流,不只是学会了一个API,而是学会了一种新的思考和表达问题的方式。 当你开始习惯用流思考问题,你会发现代码变得更短、更清晰、更接近你真正想表达的意思。