《JAVA8开发指南》使用流式操作
为什么需要流式操作
集合API是Java API中最重要的部分。基本上每一个java程序都离不开集合。尽管很重要,但是现有的集合处理在很多方面都无法满足需要。
一个原因是,许多其他的语言或者类库以声明的方式来处理特定的数据模型,比如SQL语言,你可以从表中查询,按条件过滤数据,并且以某种形式将数据分组,而不必需要了解查询是如何实现的——数据库帮你做所有的脏活。这样做的好处是你的代码很简洁。很遗憾,Java没有这种好东西,你需要用控制流程自己实现所有数据查询的底层的细节。
其次是你如何有效地处理包含大量数据的集合。理想情况下,为了加快处理过程,你会利用多核架构。但是并发程序不太好写,而且很容易出错。
Stream API很好的解决了这两个问题。它抽象出一种叫做流的东西让你以声明的方式处理数据,更重要的是,它还实现了多线程:帮你处理底层诸如线程、锁、条件变量、易变变量等等。
例如,假定你需要过滤出一沓发票找出哪些跟特定消费者相关的,以金额大小排列,再取出这些发票的ID。如果用Stream API,你很容易写出下面这种优雅的查询:
List ids
= invoices.stream()
.filter(inv ->
inv.getCustomer() == Customer.ORACLE)
.sorted(comparingDouble(Invoice::getAmount))
.map(Invoice::getId)
.collect(Collectors.toList());
本章后面,你将了解到这些代码流程的细节。
什么是流
说了这么多,到底什么是流?通俗地讲,你可以认为是支持类似数据库操作的“花哨的迭代器”。技术上讲,它是从某个数据源获得的支持聚合操作的元素序列。下面着重介绍一下正式的定义:
元素序列
针对特定元素类型的有序集合流提供了一个接口。但是流不会存储元素,只会根据要求对其做计算。
数据源
流所用到的数据源来自集合、数组或者I/O。
聚合操作
流支持类似数据库的操作以及函数式语言的基本操作,比如filter,map,reduce,findFirst,allMatch,sorted等待。
此外,流操作还有两种额外的基础属性根据不同的集合区分:
管道连接
许多流操作返回流本身,这种操作可以串联成很长的管道,这种方式更加有利于像延迟加载,短路,循环合并等操作。
内部迭代器
不像集合依赖外部迭代器,流操作在内部帮你实现了迭代器。
流操作
流接口在java.util.stream.Stream定义了许多操作,这些可以分为以下两类:
- 像filter,sorted和map一样的可以被连接起来形成一个管道的操作。
- 像collect,findFirst和allMatch一样的终止管道并返回数据的操作。
可以被连接起来的操作被称为中间操作,它们能被连接起来是因为都返回流。中间操作都“很懒”并且可以被优化。终止一个流管道的操作被叫做结束操作,它们从流管道返回像List,Integer或者甚至是void等非流类型的数据。
下面我们介绍一下流里面的一些方法,完整的方法列表可以在java.util.stream.Stream找到。
Filter
有好几个方法可以用来从流里面过滤出元素:
filter
通过传递一个预期匹配的对象作为参数并返回一个包含所有匹配到的对象的流。
distinct
返回包含唯一元素的流(唯一性取决于元素相等的实现方式)。
limit
返回一个特定上限的流。
skip
返回一个丢弃前n个元素的流。
List expensiveInvoices
= invoices.stream()
.filter(inv -> inv.getAmount() > 10_000)
.limit(5)
.collect(Collectors.toList());
Matching
匹配是一个判断是否匹配到给定属性的普遍的数据处理模式。你可以用anyMatch,allMatch和noneMatch来匹配数据,它们都需要一个预期匹配的对象作为参数并返回一个boolen型的数据。例如,你可以用allMatch来检查是否所有的发票流里面的元素的值都大于1000:
boolean expensive =
invoices.stream()
.allMatch(inv -> inv.getAmount() > 1_000);
Finding
此外,流接口还提供了像findFirst和findAny等从流中取出任意的元素。它们能与像filter方法相连接。findFirst和findAny都返回一个可选对象(我们已经在第一章中讨论过)。
Optional =
invoices.stream()
.filter(inv ->
inv.getCustomer() == Customer.ORACLE)
.findAny();
Mapping
流支持映射方法,传递一个函数对象作为方法,把流中的元素转换成另一种类型。这种方法应用于单个元素,将其映射成新元素。
例如,你有可能想用它来提取流中每个元素的信息。下面这段代码从一列发票中返回一列ID:
List ids
= invoices.stream()
.map(Invoice::getId)
.collect(Collectors.toList());
Reducing
另一个常用的模式是把数据源中的所有元素结合起来提供单一的值。例如,“计算最高金额的发票” 或者 “计算所有发票的总额”。 这可以应用流中的reduce方法反复应用于每个元素直到返回最后数据。
下面是reduce模式的例子,能帮你了解如何用for循环来计算一列数据的和:
int sum = 0;
for (int x : numbers) {
sum += x;
}
对一列数据的每一个元素的值反复应用加法运算符获得结果,最终将一列值减少到一个值。这段代码用到两个参数:初始化总和变量,这里是0;用来结合所有列表里面元素的操作方法,这里是加法操作。
在流上应用reduce方法,可以把流里面的所有元素相加,如下:int sum = numbers.stream().reduce(0, (a, b) -> a + b);
reduce方法需要两个参数:
- 初始值,这里是0
- 一个BinaryOperator方法连接两个元素产生一个新元素。reduce方法本质上是抽象了重复方法模式。其他查询像“计算总和” 或者“计算最大值” 都是reduce方法的特殊用例,比如:
int product = numbers.stream().reduce(1, (a, b) -> a * b);
int max = numbers.stream().reduce(Integer.MIN_VALUE,
Integer::max);
Collectors
目前为止你所了解的方法都是返回另一个流或者一个像boolean,int类型的值,或者返回一个可选对象。相比之下,collect方法是一个结束操作,它可以使流里面的所有元素聚集到汇总结果。
传递给collect方法参数是一个java.util.stream.Collector类型的对象。Collector对象实际上定义了一个如何把流中的元素聚集到最终结果的方法。最开始,工厂方法Collectors.toList()被用来返回一个描述了如何把流转变成一个List的Collector对象。后来Collectors类又内建了很多相似的collectors变量。例如,你可以用Collectors.groupingBy方法按消费者把发票分组,如下:
Map<Customer, List> customerToInvoices
= invoices.stream().collect(Collectors.group
ingBy(Invoice::getCustomer));
Putting It All Together
下面是一个手把手的例子你可以练习如何把老式代码用Stream API重构。下面代码的用途是按照特定消费者过滤出的与训练有关的发票,以金额高低排序,最后提取出最高的前5张发票的ID:
List oracleAndTrainingInvoices = new ArrayList();
List ids = new ArrayList();
List firstFiveIds = new ArrayList();
for(Invoice inv: invoices) {
if(inv.getCustomer() == Customer.ORACLE) {
if(inv.getTitle().contains("Training")) {
oracleAndTrainingInvoices.add(inv);
}
}
}
Collections.sort(oracleAndTrainingInvoices,
new Comparator() {
@Override
public int compare(Invoice inv1, Invoice inv2) {
return Double.compare(inv1.getAmount(), inv2.getA
mount());
}
});
for(Invoice inv: oracleAndTrainingInvoices) {
ids.add(inv.getId());
}
for(int i = 0; i < 5; i++) {
firstFiveIds.add(ids.get(i));
}
接下来,你将用Stream API一步一步地重构这些代码。首先,你或者注意到代码中用到了一个中间容器来存储那些消费者是Customer.ORACLE并且title中含有“Training”字段的发票。这正是应用filter方法的地方:
Stream oracleAndTrainingInvoices
= invoices.stream()
.filter(inv ->
inv.getCustomer() == Customer.ORACLE)
.filter(inv ->
inv.getTitle().contains("Training"));
接下来,你需要按照数量来把这些发票排序,你可以用新的工具方法Comparator.comparing结合sorted方法来实现:
Stream sortedInvoices
= oracleAndTrainingInvoices.sorted(comparingDou
ble(Invoice::getAmount));
下面,你需要提取ID,这是map方法的用途:
Stream ids
= sortedInvoices.map(Invoice::getId);
最后,你只对前5张发票感兴趣。你可以用limit方法截取这5张发票。当你整理一下代码,再用collect方法,最终的代码如下:
List firstFiveIds
= invoices.stream()
.filter(inv ->
inv.getCustomer() == Customer.ORACLE)
.filter(inv ->
inv.getTitle().contains("Training"))
.sorted(comparingDouble(Invoice::getAmount))
.map(Invoice::getId)
.limit(5)
.collect(Collectors.toList());
当你观察一下老式的代码你会发现每一个本地变量只被存储了一次,被下一段代码用了一次。当用Stream API之后,就完全消除了这个本地变量。
Parallel Streams
Stream API 支持方便的数据并行。换句话说,你可以明确地让流管道以并行的方式运行而不用关心底层的具体实现。在这背后,Stream API使用了Fork/Join框架充分利用了你机器的多核架构。
你所需要做的无非是用parallelStream()方法替换stream()方法。例如,下面代码显示如何并行地过滤金额高的发票:
List expensiveInvoices
= invoices.parallelStream()
.filter(inv -> inv.getAmount() > 10_000)
.collect(Collectors.toList());
此外,你可以用并行方法将现有的Stream转换成parallel Stream:
Stream expensiveInvoices
= invoices.stream()
.filter(inv -> inv.getAmount() > 10_000);
List result
= expensiveInvoices.parallel()
.collect(Collectors.toList());
然而,并不是所有的地方都可以用parallel Stream,从性能角度考虑,有几点你需要注意:
Splittability
parallel streams的内部实现依赖于将数据结构划分成可以让不同线程使用的难易程度。像数组这种数据结构很容易划分,而像链表或者文件这种数据结构很难划分。
Cost per element
越是计算流中单个元素花费的资源最高,应用并行越有意义。
Boxing
如果可能的话尽量用原始数据类型,这样可以占用更少的内存,也更缓存命中率也更高。
Size
流中元素的数据量越大越好,因为并行的成本会分摊到所有元素,并行节省的时间相对会更多。当然,这也跟单个元素计算的成本相关。
Number of cores
一般来说,核越多越好。
在实践中,如果你想提高代码的性能,你应该检测你代码的指标。Java Microbenchmark Harness (JMH) 是一个Oracle维护的流行的框架,你可以用它来帮你完成代码分析检测。如果不检测的话,简单的应用并行,代码的性能或许更差。
Summary
下面是本章的重点内容:
- 流是一列支持聚合操作的来自于不同数据源的元素列表
- 流有两种类型的操作方法:中间方法和终结方法
- 中间方法可以被连接起来形成管道
- 中间方法包括filter,map,distinct和sorted
- 终结方法处理流管道并返回一个结果
- 终结方法包括allMatch,collect和forEach
- Collectors是一个第应以了如何将流中的元素聚集到最终结果的方法,包括像List和Map一样的容器
- 流管道可以被并行地计算
- 当应用parallel stream 来提高性能时有很多个方面需要考虑,包括数据结构划分的难易程度,计算每个元素花费的高低,装箱的难易,数据量的多少和可用核的数量。