集合是Java中使用最多的API。要是没有集合,还能做什么呢?几乎每个java应用程序都会制造处理集合。集合对于很多的编程任务来说都是非常基本的:它们可以让你把数据分组并且加以处理。为了解释集合是怎么工作的,想象一下你准备一系列菜,组成一张菜单,然后再遍历一次,把每盘菜的热量加起来。你可能想选出那些热量比较低的菜,组成一张健康的特殊菜单。尽管集合对于几乎任何一个Java应用程序都是不可或缺的,但是集合操作缺远远算不上完美。
A.很多任务逻辑都涉及类似于数据库的操作,比如对几道菜按照类别进行分类(比如全素菜肴),或者查找出最贵的菜。你自己用迭代器重新实现过这些操作多少遍?大部分数据库都允许你声明式地指定这些操作。比如,以下sql查询语句就可以选出热量较低的菜肴名称:select name from dishes where calorie < 400。你看,你不需要实现如何根据菜肴的属性进行筛选(比如利用得迭代器和累加器),你只需要表达你想要什么。这个基本的思路意味着,你用不着担心怎么去显示地实现这些查询语句。
B.要是要处理大量元素有该怎么办呢?为了提供性能,你需要并行处理,并且利用多核架构。但是写并行代码比用迭代器还要复杂,而且调试起来也够难受的!
那Java语言的设计者能做些什么,来帮助节约宝贵的时间,让你这个程序员活的轻松一点儿呢?你可能已经猜到了,答案就是流。
1.流是什么
流是Java API的新成员,它允许你以声明性方式处理数据集合(通过查询语句来表达,而不是临时编写一个实现)。就现在来说,你可以把它们看成遍历数据集的高级迭代器。此外,流还可以透明地并行处理,你无需写任何多线程代码了!我们简单看看使用流的好处吧。下面两端代码都是返回低热量的菜肴名称的,并且按照卡路里排序,一个使用Java7,写的,另一个是用Java8的流写的。比较一下。不用担心Java8代码怎么写的:
之前(Java7):
List<Dish> lowCaloricDishes = new ArrayList<>();
List<Dish> menu = Arrays.asList(new Dish("1", 100), new Dish("1", 300), new Dish("1", 500),
new Dish("1", 1000));
for (Dish d : menu) { //使用迭代器筛选元素
if(d.getCalories() < 400){
lowCaloricDishes.add(d);
}
}
Collections.sort(lowCaloricDishes, new Comparator<Dish>() { // 使用匿名类菜肴排序
@Override
public int compare(Dish o1, Dish o2) {
return Integer.compare(o1.getCalories(), o2.getCalories());
}
});
List<String> lowCaloricDishesName = new ArrayList<>();
for(Dish d: lowCaloricDishes){
lowCaloricDishesName.add(d.getName()); //处理排序后的菜名列表
}
在这段代码中,你用了一个“垃圾变量”lowCaloricDishes。它唯一的作用就是作为一次性中间容器。在Java8中,实现的细节被放在它本该归属的库里了。
List<String> lowCaloricDishesName = new ArrayList<>();
lowCaloricDishesName =
menu.stream()
.filter(d -> d.getCalories() < 400) //选出400卡路里以下的菜肴
.sorted(Comparator.comparing(Dish::getCalories)) //按照卡路里排序
.map(Dish::getName) //提取菜肴的名称
.collect(Collectors.toList()); //将所有的名称保存在List中
为了利用多核架构并行执行这段代码,你只需要把stream()换成parallelStream();
lowCaloricDishesName =
menu.parallelStream()
.filter(d -> d.getCalories() < 400) //选出400卡路里以下的菜肴
.sorted(Comparator.comparing(Dish::getCalories)) //按照卡路里排序
.map(Dish::getName) //提取菜肴的名称
.collect(Collectors.toList()); //将所有的名称保存在List中
你可能会想,在调用parallelStream方法的时候到底发生了什么。用了多少个线程?对性能有多大的提升?这些问题在以后会详细的讨论。现在,你看出来了,从软件工程师的角度来看,新的方法有几个显而易见的好处。
A.代码是以声明性方法来写的:说明了想要完成什么(筛选热量低的菜肴)而不是说明了如何实现一个操作(利用循环和if条件等控制流语句)。你在之前也看到了,这种方法加上行为参数化让你可以轻松应对变化的需求:你很容易再创建一个代码版本,利用Lamba表达式来筛选高卡路里的菜肴,而不是去复制粘贴代码。
B .你可以把几个基础操作筛选链接起来,来表达复杂的数据处理流水线(在filter后面接上sorted、map和collect操作, 如下图所示),同时保持代码清晰可读。filter的结果被传给了sorted方法,再传给map方法,最后传给collect方法。
因为filter、sorted、map和collect等操作是与具体线程模型无关的高层次构件,所以它们的内部实现可以是单线程的,也可能透明地充分利用你的多级架构!在实践中,这意味着你用不着为了让某些数据处理任务并行而去操心线程和锁了,Stream API都替你想好了!
2.流简介
要讨论流,我们先来谈谈集合,这是最容易上手的方法。Java8中的集合支持一个新的stream方法,它会返回一个流(接口定义在java.util.stream.Stream里)。你在后面会看到,还有很多的方式来可以得到流,比如利用数值范围或从I/O资源生成的流元素。
那么,流到底是什么呢?简短的定义就是"从支持数据处理操作的源生成的元素序列"。让我们一步步剖析这个定义。
A.元素序列--就像集合一样,流也提供了一个接口,可以访问特定元素类型的一组有序值。因为集合是数据结构,所以它的主要目的是以特定的时间/空间复杂度存储和访问元素(ArrayList和LinkedList)。但是流的目的在于表达计算,比如你前面简单的filter、sorted和map。集合讲的是数据,流讲得是计算。我们在后面几节详细解释这个思想。
B.源--流会使用一个提供数据的源,如集合、数组或者输入/输出资源。请注意,从有序集合生成流时会保留原有的顺序。由列表生成的流,其元素顺序与列表一致。
C.数据处理操作--流的数据处理功能支持类似于数据库的操作,以及函数式编程语言中的常用操作,如filter、map、reduce、find、match、sort等等。流操作可以顺序执行,也可以并行执行。
此外,流操作有两个重要的特点。
A.流水线--很多流操作本身会返回一个流,这样多个操作就可以链接起来,形成一个大的很大的流水线。这让我们以后的一些优化成为了可能,如延迟和短路。流水线的操作可以可以看做对数据源进行数据库式查询。
B.内部迭代--与使用迭代器显示迭代的集合不同,流的迭代操作是在背后进行的。
&emsp 让我们来看一段能够体现所有这些概念的代码:
Dish类的定义:
package com.example.demo13;
public class Dish {
private final String name;
private final boolean vegetarion;
private final int calories;
private final Type type;
public enum Type{
MEAT, FISH, OTHER
}
public Dish(String name, boolean vegetarion, int calories, Type type) {
this.name = name;
this.vegetarion = vegetarion;
this.calories = calories;
this.type = type;
}
public String getName() {
return name;
}
public boolean isVegetarion() {
return vegetarion;
}
public int getCalories() {
return calories;
}
public Type getType() {
return type;
}
}
main方法所在类的定义:
package com.example.demo13;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class Demo13 {
public static void main(String[] args) {
List<Dish> menu = Arrays.asList(
new Dish("pork", false, 800, Dish.Type.MEAT),
new Dish("beef", false, 700, Dish.Type.MEAT),
new Dish("chicken", false, 400, Dish.Type.MEAT),
new Dish("french fries", true, 530, Dish.Type.OTHER),
new Dish("rice", true, 350, Dish.Type.OTHER),
new Dish("season fruit", true, 120, Dish.Type.OTHER),
new Dish("pizza", true, 550, Dish.Type.OTHER),
new Dish("prawns", false, 300, Dish.Type.FISH),
new Dish("salmon", false, 450, Dish.Type.FISH));
List<String> treenHighCaloricDishNames =
menu.stream(). //从menu获得流(菜肴列表)
filter(d -> d.getCalories() > 300). //建立流水线的操作:首先选出高热量的菜肴
map(Dish::getName). //获取菜名
limit(3). //只选择前面三个
collect(Collectors.toList()); //结果保存在另一个List集合中
System.out.println(treenHighCaloricDishNames);
}
}
在本例中,我们先是对menu调用stream方法,由菜单得到一个流。数据源是菜肴列表(菜单),它给流提供一个元素序列。接下来,对流应用一系列数据处理操作:filter、map、limit和collect。除了collect之外,所有这些操作都会返回另一个流,这样它们就可以结合成一条流水线,于是就可以看做对源的一个查询。最后,collect操作开始处理流水线,并且返回结果(它和别的操作不一样,因为它返回的不是流,在这里是一个list)。在调用collect之前,没有任何结果产生,实际上根本就没有从menu里选择元素。你可以这么理解:链中的方法调用都在排队等待,知道调用collect。如下图所示,显示了流操作的顺序:filter、map、limit、collect,每个操作简介如下。
A.filter--接收Lambda表达式,从流中排除某些元素。在本例中,通过传递Lambda表达式 d -> d.getCalories() > 300,选择出热量超过300卡路里的菜肴。
B.map--接收一个Lambda表达式,将元素转换成其他形式或者提取信息。在本例中,通过传递给方法引用Dish::getName,相当于Lamda表达式 d->d.getName(),提取了每道菜的菜名。
C.limit--截断流,使其元素不超过给定的数量。
D.collect--将流转换为其他形式。在本例中,流被转换成为一个列表。它看起来有点儿变魔术,之后我们详细的解释collect的工作原理。现在你可以把collect看做能够接受各种方案作为参数,并将流中元素累积成为一个汇总结果的操作。这里的toList()方法就是将流转换为列表的方案。
注意看,我们刚刚解释了这段代码,与逐项处理菜单列表的代码有很大不同。首先,我们使用了声明性的方式来处理菜单数据,即你说的对这些数据需要做什么:“查找热量最高的三道菜的菜名。” 你并没有去实现筛选(filter)、提取(map)、或者截断(limit)功能,Streams库已经自带了。因此,Stream API在决定何如优化这条流水线时更加的灵活。例如,筛选、提取和截断操作可以一次进行,并在找到这三道菜后立即停止。
3.流与集合
Java为现有的集合概念和新的流概念都提供了接口,来配合代表元素型有序值的数据接口。所谓有序,就是说我们一般都是按照顺序提取有用值,而不是随机取用的。那这两者有什么区别呢?
我们先来打个直观的比方吧。比如说,存在DVD里的电影,这就是一个集合(也许是字节,也许是帧,这个无所谓),因为它包含了整个数据接口。现在再来想想在互联网上通过视频流看同样的电影。现在这是一个流(字节流或者帧流)。流媒体视频播放器只要提前下载用户观看位置那几帧就可以,这样不用等到流中大部分值计算出来,你就可以显示流的开始部分了(想想观看直播足球赛)。特别要注意,视频播放器可能没有将整个流作为集合,保存所需要的部分内存缓冲区--而且要是非得要等到最后一帧出现才能开始看,那等待的时间就太长了。出于实现的考虑,你也可以让视频播放器把流的一部分缓存在集合里面,但和概念上的差异不是一回事。
粗略的说,集合与流之间的差异就在于什么时候进行计算。集合是一格内存中的数据结构,它包含数据结构中目前所有的值--集合中的每个元素都得先计算出来才能添加到集合中。(你可以往集合里面加东西或者删东西,但是不管什么时候,集合中的每个元素都是放在内存里的,元素都得先算出来才能成为集合的一部分)。
相比之下,流则是在概念上固定的数据结构(你不能添加或者删除元素),其元素则是按照需要计算的。这对编程很大的好处。在之后中,我们将展示构建一个质数流(2, 3, 5,7.....)有多简单,尽管质数有无穷个。这个思想就是让用户仅仅从流中提取需要的值,而这些值--在用户看不见的地方--只会按需生成。这是一种生产者-消费者的关系。从另一个角度来说,流就像是一个延迟创建的集合:只有在消费者要求的时候才会计算值(用管理学的话说这就是需求驱动,甚至是实时制造)。
与此相反,集合则是急切创建的(供应商驱动:先把仓库装满,再开始卖,就像是那些昙花一现的圣诞玩意儿一样)。以质数为例,要是想创建一个包含所有质数的集合,那这个程序算起来就没完没了,因为总有的新的只算要算,然后把他们加到集合里面去。当然这个集合是永远创建不完的,消费者这辈子都看不见了。
(1).只能遍历一次
请注意,和迭代器类似,流只能遍历一次。遍历完成之后,我们就说这个流已经被消费掉了。你可以从原始数据源那里再获得一个新的流来遍历一遍,就像迭代器一样(这里假设它是集合之类可重复的源,如果是I/O通道就没戏了)。例如以下代码会抛出一个异常,说流已经被消费掉了:
List<String> stringList = Arrays.asList("Java8", "Android", "C艹");
Stream<String> stream = stringList.stream();
stream.forEach(System.out::println); //打印stringList集合中每个字符串
stream.forEach(System.out::println); //抛出java.lang.IllegalStateException异常,提示:stream has already been operated upon or closed
所以要记得,流只能被消费一次!
(2).外部迭代与内部迭代
使用Collection接口需要用户去迭代(比如使用 for-each),这称为外部迭代。相反,Streams库使用内部迭代--它帮你迭代做了,还把得到的流值存在了某个地方,你只要给出一个函数说要干什么就可以了。下面的代码列表说明了这种区别。
A.集合:使用for-each集合循环外部迭代
List<String> names = new ArrayList<>();
for(Dish d:menu){ //显示的迭代菜单列表
names.add(d.getName()); //提取名称并且将其添加到累加器中
}
请注意,for-each还隐藏了迭代中的一些复杂性。for-each结构是一个语法糖,它背后的东西用Iterator对象表达出来更要丑陋得多。
B.集合:用背后的迭代器做外部迭代
List<String> names = new ArrayList<>();
Iterator<Dish> iterator = menu.iterator();
while(iterator.hasNext()){ //显示迭代
names.add(iterator.next().getName());
}
C.流:内部迭代
List<String> names = menu.stream().
map(Dish::getName). //用getName方法参数化map,提取菜名
collect(Collectors.toList()); //开始执行操作流水线;没有迭代!
内部迭代时,项目可以透明地并行处理,或者用更优化的顺序进行处理。要是用Java过去的那种外部迭代方法,这些优化都是很困难的。这似乎有点儿寄到里挑骨头,这差不多就是Java8引入流的理由了--Streams库的内部迭代可以自动选择一种适合你硬件的数据表示和并行实现。与此相反,一档通过写for-each而选择了外部迭代,那你基本上就要自己管理所有问题了(自己管理实际上意味着“某个良辰吉日我们会把它并行化”或者“开始了关于任务和synchromized的漫长而艰苦斗争”。)java8需要一个类似于Collection却没有迭代器的接口,于是就有了stream!如图所示,说明了流(内部迭代)与集合(外部迭代)之间差异。
我们已经说过了,集合与流在概念上的差异,特别是流利用了内部迭代:替你把迭代做了。但是,只有预定义好了能够隐藏迭代的操作列表,例如filter或者map,这个才有用。大多数这类操作都接收Lambda表达式作为参数,因此你可以使用之前介绍的方法来参数化其行为。Java语言的设计者给Stream API配上了一大套用来表达复杂数据处理查询的操作。
4.流操作
java.util.stream.Stream中的stream接口中定义了许多操作。它们可以分为两大类。我们再来看看前面的例子:
List<String> names =
menu.stream(). //从菜单获得流
filter(d -> d.getCalories() > 300). //中间操作
map(Dish::getName). //中间操作
limit(3). //中间操作
collect(Collectors.toList()); //将Stream转换为List
你可以看到两类操作:
A.filter、map和limit可以连成一条流水线
B.collect触发流水线执行并且关闭它。
可以连接起来的流操作称为中间操作,关闭流的操作称为终端操作。下图展示了这两类的操作
(1).中间操作
诸如filter或者sorted等中间操作会返回一个流。这让多个操作可以连接起来形成一个查询。重要的是,除非流水线上触发一个终端操作,否则中间操作不会执行任何处理--它们很懒。这是因为中间操作一般都可以合并起来,在终端操作时一次性全部处理。
&emps;为了搞清楚流水线中到底发生了什么,我们把代码改了一下,让每个Lambda都打印出当前处理的菜肴(就像很多演示和调试技巧一样,这个编程风格要是搁在生成代码里面那就吓死人了,但是学习的时候却可以直接看清楚求的顺序):
List<String> names = menu.stream(). // 从菜单获得流
filter(d -> {
System.out.println("filtering:" + d.getName());
return d.getCalories() > 300;
}). // 打印当前菜肴的名字
map(d -> {
System.out.println("mapping:" + d.getName());
return d.getName();
}). // 提取菜名时打印
limit(3).
collect(Collectors.toList());
System.out.println(names);
此时代码执行时将打印:
filtering:pork
mapping:pork
filtering:beef
mapping:beef
filtering:chicken
mapping:chicken
[pork, beef, chicken]
&emps;你会发现,有好几种优化利用了流的延迟性质。第一,尽管很多菜的热量都高于300卡路里,但只会选出了前三个!这是因为limit操作和一种称为短路的技巧。第二,尽管filter和map是两个独立的操作,但它们合并到同一次遍历中了(我们把这种技术称为循环合并)。
(2).终端操作
终端操作会ongoing流的流水线生成结果。其结果是任何不是流的值,比如List、Integer,甚至void。例如,在下面的流水线中,forEach是一个返回为void的终端操作,它会对源中的每道菜应用一个Lambda。把System.out.println传递给forEach,并且要求打印出由menu生成的流中每一个Dish:
menu.stream().forEach(System.out::println);
(3).使用流
总而言之,流的使用一般包括三件事:
A.一个数据源(如集合)来执行一个查询;
B.一个中间操作链,形成一条流的流水线;
C.一个终端操作,执行流水线,并能生成结果。
流的流水线背后的理念类似于构建起模式。在构建器模式中有一个调用链用来设置一套配置(对流来说这就是一个中间操作链),接着是调用built方法(对流来说就是终端操作)。
为了方便起见,下面两张表总结之前的例子代码中看到的中间留操作和终端流操作。请注意,这并不能涵盖Stream API提供的操作。
中间操作
操作 | 类型 | 返回类型 | 操作参数 | 函数描述符 |
---|---|---|---|---|
filter | 中间 | Stream<T> | Predicate<T> | T -> boolean |
map | 中间 | Stream<R> | Function<T, R> | T -> R |
limit | 中间 | Stream<T> | ||
sorted | 中间 | Stream<T> | Comparator<T> | (T, T) -> int |
distinct | 中间 | Stream<T> |
终端操作
操作 | 类型 | 目的 |
---|---|---|
forEach | 终端 | 消费流中的每个元素并对其应用Lambda。这一操作返回void |
count | 终端 | 返回流中元素的个数。这一操作返回long |
collect | 终端 | 把流归约成成一个集合,比如List、map甚至是Integer。 |