《Java8实战》-第四章读书笔记(引入流Stream)

BitAether
• 阅读 1256

流(Stream)

流是什么

流是Java API的新成员,它允许你以声明性方式处理数据集合(通过查询语句来表达,而不是临时编写一个实现)。就现在来说,你可以把它们看成遍历数据集的高级迭代器。此外,流还可以透明地并行处理,你无需写任何多线程代码了!我会在后面的笔记中详细记录和解释流和并行化是怎么工作的。我们简单看看使用流的好处吧。下面两段代码都是用来返回低热量的菜肴名称的,并按照卡路里排序,一个是用Java7写的,另一个是用Java8的流写的。比较一下。不用太担心Java 8代码怎么写,我们在接下来会对它进行详细的了解。

菜单筛选

使用Java7:

private static List<String> getLowCaloricDishesNamesInJava7(List<Dish> dishes) {
    List<Dish> lowCaloricDishes = new ArrayList<>();
    // 遍历筛选出低于400卡路里的菜,添加到另外一个集合中
    for (Dish d : dishes) {
        if (d.getCalories() < 400) {
            lowCaloricDishes.add(d);
        }
    }

    // 对集合按照卡路里大小进行排序
    List<String> lowCaloricDishesName = new ArrayList<>();
    Collections.sort(lowCaloricDishes, new Comparator<Dish>() {
        @Override
        public int compare(Dish d1, Dish d2) {
            return Integer.compare(d1.getCalories(), d2.getCalories());
        }
    });

    // 遍历将菜名添加到另外一个集合中
    for (Dish d : lowCaloricDishes) {
        lowCaloricDishesName.add(d.getName());
    }
    return lowCaloricDishesName;
}

在上面的代码中,看起来很冗长,我们使用了一个“垃圾变量”lowCaloricDishes。它唯一的作用就是作为一次性的中间容器。 在Java8,实现的细节被放到了它本该归属的库力了。
使用Java8:

private static List<String> getLowCaloricDishesNamesInJava8(List<Dish> dishes) {
    return dishes.stream()
            // 选出400卡路里以下的菜肴
            .filter(d -> d.getCalories() < 400)
            // 按照卡路里排序
            .sorted(comparing(Dish::getCalories))
            // 提取菜名
            .map(Dish::getName)
            // 转为集合
            .collect(toList());
}

太酷了!原本十几行的代码,现在只需要一行就可以搞定,这样的感觉真的是太棒了!还有一个很棒的新特性,为了利用多核架构并行执行代码,我们只需要将stream()改为parallelStream()即可:

private static List<String> getLowCaloricDishesNamesInJava8(List<Dish> dishes) {
    return dishes
            .parallelStream()
            // 选出400卡路里以下的菜肴
            .filter(d -> d.getCalories() < 400)
            // 按照卡路里排序
            .sorted(comparing(Dish::getCalories))
            // 提取菜名
            .map(Dish::getName)
            // 转为集合
            .collect(toList());
}

你可能会想,在调用parallelStream方法时到底发生了什么。用了多少个线程?对性能有多大的提升?不用着急,在后面的读书笔记中会讨论这些问题。现在,你可以看出,从软件工程师的角度来看,新的方法有几个显而易见的好处。

  1. 代码是以声明性的方式写的:说明想要完成什么(筛选热量低的菜肴)而不是说明如何实现一个操作(利用循环和if条件等控制流语句)。
  2. 你可以把几个基础操作链接起来,来表达复杂的数据处理流水线(在 filter 后面接上

sorted 、 map 和 collect 操作),同时保持代码清晰可读。 filter 的结果被传给了 sorted 方法,再传给 map 方法,最后传给 collect 方法。

filter、sorted、map和collect等操作是与具体线程模型无关的高层次构件,所以它们的内部实现可以是单线程的,也可能透明地充分利用你的多核架构!在实践中,这意味着我们用不着为了让某些数据处理任务并行而去操心线程和锁了,Stream API都替你做好了!

现在就来仔细探讨一下怎么使用Stream API。我们会用流与集合做类比,做点儿铺垫。下一
章会详细讨论可以用来表达复杂数据处理查询的流操作。我们会谈到很多模式,如筛选、切片、
查找、匹配、映射和归约,还会提供很多测验和练习来加深你的理解。接下来,我们会讨论如何创建和操纵数字流,比如生成一个偶数流,或是勾股数流。最后,我们会讨论如何从不同的源(比如文件)创建流。还会讨论如何生成一个具有无穷多元素的流,这用集合肯定是搞不定。

流简介

要讨论流,我们首先来谈谈集合,这是最容易上手的方式了。Java8中的集合支持一个新的stream方法,它会返回一个流(接口定义在 java.util.stream.Stream 里)。你在后面会看到,还有很多其他的方法可以得到流,比如利用数值范围或从I/O资源生成流元素。

那么,流到底是什么呢?简短的定义就是“从支持数据处理操作的源生成的元素序列”。让我们一步步剖析这个定义。

  1. 元素序列:就像集合一样,流也提供了一个接口,可以访问特定元素类型的一组有序值。因为集合是数据结构,所以它的主要目的是以特定的时间/空间复杂度存储和访问元素(如ArrayList 与 LinkedList )。但流的目的在于表达计算,比如你前面见到的filter 、 sorted 和 map 。集合讲的是数据,流讲的是计算。
  2. 源:流会使用一个提供数据的源,如集合、数组或输入/输出资源。请注意,从有序集合生成流时会保留原有的顺序。由列表生成的流,其元素顺序与列表一致。
  3. 数据处理操作:流的数据处理功能支持类似于数据库的操作,以及函数式编程语言中的常用操作,如filter、map、reduce、find、match、sort等。流操作可以顺序执行,也可并行执行。

此外,流操作有两个重要的特点。

  1. 流水线:很多流操作本身会返回一个流,这样多个操作就可以链接起来,形成一个大的流水线。
  2. 内部迭代:与使用迭代器显式迭代的集合不同,流的迭代操作是在背后进行的。

让我们来看一段能够体现所有这些概念的代码:

List<Dish> menu = Dish.MENU;
// 从menu获得流
List<String> threeHighCaloricDishNames = menu.stream()
        // 通过链式操作,筛选出高热量的菜肴
        .filter(d -> d.getCalories() > 300)
        // 获取菜名
        .map(Dish::getName)
        .limit(3)
        .collect(Collectors.toList());
// [pork, beef, chicken]
System.out.println(threeHighCaloricDishNames);

看起来很简单,就算不明白也没关系,我们来了解了解,刚刚使用到的一些方法:

  1. filter: 接受Lambda,从流中排除某些元素。在刚刚的代码中,通过传递Lambda表达式 d -> d.getCalories() > 300,选择出热量高于300卡路里的菜肴。
  2. map:接受一个Lambda,将元素转换成其他形式或提取信息。在刚刚的代码中,通过传递方法引用Dish::getName,提取了每道菜的菜名。
  3. limit:截断流,使其元素不超过给定的数量。
  4. collect:将流转换为其他形式。在刚刚的代码中,流被转为一个List集合。

在刚刚解释的这段代码,与遍历处理菜单集合的代码有很大的不同。首先,我们使用了声明性的方式来处理菜单数据。我们并没有去实现筛选(filter)、提取(map)或截断(limit)功能,Stream库已经自带了。因此,StreamAPI在决定如何优化这条流水线时更为灵活。例如,筛选、提取和截断操作可以一次进行,并在找到这三道菜后立即停止。

流与集合

Java现有的集合概念和新的流概念都提供了接口,来配合代表元素型有序值的数据接口。所谓有序,就是说我们一般是按顺序取用值,而不是随机取用的。那这两者有什么区别呢?

打个比方说,我们在看电影的时候,这些视频就是一个流(字节流或帧流),流媒体视频播放器只要提前下载用户观看位置的那几帧就可以了,这样不用等到流中大部分值计算出来。比如,我们在Youtube上看的视频进度条随便拖动到一个位置,你会发现它很快就开始播放了,不需要将整个视频都加载好,而是加载了一段。如果,不按照这种方式的话,我们可以想象一下,视频播放器可能没有将整个流作为集合,保存所需要的内存缓冲区——而且要是非得等到最后一帧出现才能开始看,那等待的时间就太长了,早就没耐心看了。

初略地说,集合与流之间的差异就在于什么时候进行计算。集合是一个内存中的数据结构,它包含数据结构中目前所有的值,集合中的每个元素都得先算出来才能添加到集合中。

相比之下,流则是在概念上固定的数据结构,其元素则是按需计(懒加载)算的。需要多少就给多少。这是一种生产者与消费者的关系。从另一个角度来说,流就像是一个延迟创建的集合:只有在消费者要求的时候才会生成值。与之相反,集合则是急切创建的(就像黄牛囤货一样)。

流只能遍历一次

请注意,和迭代器类似,流只能遍历一次。遍历完之后,我们就说这个流已经被消费掉了。你可以从原始数据源那里再获得一个新的流来重新遍历一遍,就像迭代器一样(这里假设它是集合之类的可重复的源,如果是I/O通道就没戏了)。例如以下代码会抛出一个异常,说流已被消费掉了:

List<String> names = Arrays.asList("Java8", "Lambdas", "In", "Action");
Stream<String> s = names.stream();
s.forEach(System.out::println);
// 再继续执行一次,则会抛出异常
s.forEach(System.out::println);

千万要记住,它只能消费一次!

外部迭代与内部迭代

使用Collection接口需要用用户去做迭代(比如用for-each),这个称为外部迭代。反之,Stream库使用内部迭代,它帮你把迭代做了,还把得到的流值存在了某个地方,你只要给出一个函数说要干什么就可以了。下面的代码说明了这种区别。

集合:使用for-each循环外部迭代:

// 集合:使用for-each循环外部迭代
List<Dish> menu = Dish.MENU;
List<String> names = new ArrayList<>();
for (Dish dish : menu) {
    names.add(dish.getName());
}

请注意, for-each 还隐藏了迭代中的一些复杂性。for-each结构是一个语法糖,它背后的东西用Iterator对象表达出来更要丑陋得多。

集合:用背后的迭代器做外部迭代

List<String> names = new ArrayList<>();
Iterator<String> iterator = menu.iterator();
while(iterator.hasNext()) {
    Dish d = iterator.next();
    names.add(d.getName());
}

流:内部迭代

List<String> names = menu.stream()
                    .map(Dish::getName)
                    .collect(toList());

让我们用一个比喻来解释一下内部迭代的差异和好处吧!比方说你在和你两岁的儿子说话,希望他能把玩家收起来。

你:“儿子,我们把玩家收起来吧。地上还有玩具吗?”
儿子:“有,球。”
你:“好,放进盒子里。还有吗?”
儿子:“有,那是我的飞机。”
你:“好,放进盒子里。还有吗?”
儿子:“有,我的书。”
你:“好,放进盒子里。还有吗?”
儿子:“没了,没有了。”
你:“好,我们收好啦!”

这正是你每天都要对Java集合做的。你外部迭代一个集合,显式地取出每个项目再加以处理。如果,你对儿子说“把地上的所有玩具都放进盒子里收起来”就好了。内部迭代比较好的原因有二:第一,儿子可以选择一只手拿飞机,另一只手拿球第二,他可以决定先拿离盒子最近的那个东西,然后再拿别的。同样的道理,内部迭代时,项目可以透明地并行处理,或者用更优化的顺序进行处理。要是用Java过去的那种外部迭代方法,这些优化都是很困难的。这似乎有点儿鸡蛋里挑骨头,但这差不多就是Java 8引入流的理由了,Stream库的内部迭代可以自动选择一种适合你硬件的数据表示和并行实现。与此相反,一旦通过写 for-each 而选择了外部迭代,那你基本上就要自己管理所有的并行问题了(自己管理实际上意味着“某个良辰吉日我们会把它并行化”或“开始了关于任务和 synchronized 的漫长而艰苦的斗争”)。Java8需要一个类似于Collection 却没有迭代器的接口,于是就有了Stream!下面的图说明了流(内部迭代)与集合(外部迭代)之间的差异。

《Java8实战》-第四章读书笔记(引入流Stream)

我们已经了解过了集合与流在概念上的差异,特别是利用内部迭代:替你把迭代做了。但是,只有你已经预先定义好了能够隐藏迭代的操作集合。例如filter或map,这个才有用。大多数这类操作都接受Lambda表达式作为参数,因此我们可以用前面所了解的知识来参数化其行为。

流操作

java.util.stream.Stream 中的 Stream 接口定义了许多操作。它们可以分为两大类。我们再来看一下前面的例子:

List<String> names = menu.stream()
                // 中间操作
                .filter(d -> d.getCalories() > 300)
                // 中间操作
                .map(Dish::getName)
                // 中间操作
                .limit(3)
                // 将Stream转为List
                .collect(toList());

filter、map和limit可以连成一条线,collect触发流水线执行并关闭它。可以连起来的称为中间操作,关闭流的操作可以称为终端操作。

中间操作

诸如filter和sorted等中间操作会返回一个流。让多个操作可以连接起来形成一个查询。重要的是,除非流水线上触发一个终端操作,否则中间操作不会执行任何处理它们懒得很。这就是因为中间操作一般都可以合并起来,在终端操作时一次性全部处理。

为了搞清楚流水线到底发生了什么,我们把代码改一改,让每个Lambda都打印出当前处理的菜肴(就像很多演示和调试技巧一样,这种编程风格要是搁在生产代码里那就吓死人了,但是学习的时候却可以直接看清楚求值的顺序):

List<String>  names = menu.stream()
        .filter(d -> {
            System.out.println("filtering:" + d.getName());
            return d.getCalories() > 300;
        })
        .map(dish -> {
            System.out.println("mapping:" + dish.getName());
            return dish.getName();
        })
        .limit(3)
        .collect(toList());
System.out.println(names);

执行结果:

filtering:pork
mapping:pork
filtering:beef
mapping:beef
filtering:chicken
mapping:chicken
[pork, beef, chicken]

从上面的打印结果,我们可以发现有好几种优化利用了流的延迟性质。第一,尽管有很多热量都高于300卡路里,但是只会选择前三个!因为limit操作和一种称为短路的技巧,第二,尽管filter和map是两个独立的操作,但是它们合并到同一次便利中了(我们把这种技术叫做循环合并)。

终端操作

终端操作会从流的流水线生产结果。其结果是任何不是流的值,比如List、Integer,甚至是void。例如,在下面的流水线中,foreachh返回的是一个void的终端操作,它对源中的每道菜应用一个Lambda。把System.out.println()传递给foreach,并要求它打印出由menu生成的流中每一个Dish:

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

为了检验一下对终端操作已经中间操作的理解,下面我们一起来看看一个例子:

下面哪些是中间操作哪些是终端操作?

long count = menu.stream()
            .filter(d -> d.getCalories() > 300)
            .distinct()
            .limit(3)
            .count();

答案:流水线中最后一个操作是count,它会返回一个long,这是一个非Stream的值。因此,它是终端操作。

使用流

总而言之,流的使用一般包括三件事:

  1. 一个数据源(比如集合)来执行查询
  2. 一个中间操作链,形成一条流的流水线
  3. 一个终端操作,执行流水线,并能生成结果。

流的流水线背后的理念类似于构建器模式。 在构建器模式中有一个调用链用来设置一套配置(对流来说这就是一个中间操作链),接着是调用built方法(对流来说就是终端操作)。其实,我们目前所看的Stream的例子用到的方法并不是它的全部,还有一些其他的一些操作。

在本章中,我们所接触到的一些中间操作与终端操作:

中间:

操作 类型 返回类型 操作参数 函数描述
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

Stream是一个非常好用的一个新特性,它能帮助我们简化很多冗长的代码,提高我们代码的可读性。

本章总结

  1. 流是“从支持数据处理操作的源生成的一系列元素”。
  2. 流利用内部迭代:迭代通过filter、map、sorted等操作被抽象掉了。
  3. 流操作有两类:中间操作和终端操作。
  4. filter和map等中间操作会返回一个流,并可以链接在一起。可以用它们来设置一条流水线,但并不会生成任何结果。
  5. forEach和count等终端操作会返回一个非流的值,并处理流水线以返回结果。

6.流中的元素是按需计算(懒加载)的。

代码示例

Github: chap4

Gitee: chap4

公众号

如果,你对Java8中的新特性很感兴趣,你可以关注我的公众号或者当前的技术社区的账号,利用空闲的时间看看我的笔记,非常感谢!

《Java8实战》-第四章读书笔记(引入流Stream)

点赞
收藏
评论区
推荐文章
xxkfz xxkfz
3年前
使用Stream流递归实现遍历树形结构
可能平常会遇到一些需求,比如构建菜单,构建树形结构,数据库一般就使用父id来表示,为了降低数据库的查询压力,我们可以使用Java8中的Stream流一次性把数据查出来,然后通过流式处理,我们一起来看看,代码实现为了实现简单,就模拟查看数据库所有数据到List里面。比如现在有一张菜单表,具体数据如下:下面我们就来模拟这一操作,递归组装树形结构:@Autowi
Wesley13 Wesley13
3年前
javaIO2
NIO新的输入/输出(NIO)库是在JDK1.4中引入的,弥补了原来的I/O的不足,提供了高速的、面向块的I/O。1.1流与块I/O与NIO最重要的区别是数据打包和传输的方式,I/O以流的方式处理数据,而NIO以块的方式处理数据。面向流的I/O一次处理一个字节数据:一个输入流产生一个字
Wesley13 Wesley13
3年前
JDK1.8 之Stream API总结
Stream是Java8新增加的类,用来补充集合类。Stream代表数据流,流中的数据元素的数量可能是有限的,也可能是无限的。Stream和其它集合类的区别在于:其它集合类主要关注与有限数量的数据的访问和有效管理(增删改),而Stream并没有提供访问和管理元素的方式,而是通过声明数据源的方式,利用可计算的操作在数据源上执行,当然
Wesley13 Wesley13
3年前
Java学习:Stream流式思想
Stream流Java8API添加了一种新的机制——Stream(流)。Stream和IO流不是一回事。流式思想:像生产流水线一样,一个操作接一个操作。使用Stream流的步骤:数据源→转换成流→操作1→操作2→……数据源(source):可以是集合、数组等。St
Wesley13 Wesley13
3年前
Java 8新特性之Stream 概念
Java8中有两大最为重要的改变。第一个是Lambda表达式;另外一个则是StreamAPI(java.util.stream.\)。Stream是Java8中处理集合的关键抽象概念,它可以指定你希望对集合进行的操作,可以执行非常复杂的查找、过滤和映射数据等操作。使用StreamAPI对集合数据进行操作,就类似于使用SQL执行
Wesley13 Wesley13
3年前
Java8 新特性之集合操作Stream
Java8新特性之集合操作StreamStream简介Java8引入了全新的StreamAPI。这里的Stream和I/O流不同,它更像具有Iterable的集合类,但行为和集合类又有所不同。stream是对集合对象功能的增强,它专注于对集合对象进行各种非常便利、高效的聚合操作,或者大批量数据操作。
Wesley13 Wesley13
3年前
Java8 数据流
一、基本知识\\数据流(stream)\\是对集合(collection)功能的增强,更专注于对集合对象的各种便利、高效的聚合,大批量数据操作。数据流的特点:元素序列流提供了一组特定类型的以顺序方式元素。源流使用集合,数组或I/O资源为输入源。聚合操作数据流支持如filter
Easter79 Easter79
3年前
Trident API(翻译)
TridentAPIOverviewTrident的核心数据模型是“流”(Stream),进行数据处理的时候,将数据作为一系列的batch(批)来进行。流被分割成多个partition分布在集群中的不同节点上来运行,而且对流的操作也是在流的各个partition上并行运行的。Trident中有五类操作
Wesley13 Wesley13
3年前
Java 8 Stream API学习总结
Java8API添加了一个新的抽象称为流Stream,可以让你以一种声明的方式处理数据。StreamAPI可以极大提高Java程序员的生产力,让程序员写出高效率、干净、简洁的代码。这种风格将要处理的元素集合看作一种流,流在管道中传输,并且可以在管道的节点上进行处理,比如筛选,排序,聚合等。元素流在管道中经过中间操作(intermediateo
Wesley13 Wesley13
3年前
Java8并行流写WordCount,并不简单
节前略闲,看了java8并行流,写个了wordCount。本以为易如反掌,结果却折腾了一下午!在本文中wordcount是指以空格作为词的分割符号,统计一个语句中出现的词数如何用java8并行流写WordCount,我开始的想法是先写个串行流的workcount,之后stream.parallel()将流并行化。串行流的wordCout,也
Stream流处理快速上手最佳实践 | 京东物流技术团队
一引言JAVA1.8得益于Lambda所带来的函数式编程,引入了一个全新的Stream流概念Stream流式思想类似于工厂车间的“生产流水线”,Stream流不是一种数据结构,不保存数据,而是对数据进行加工处理。Stream可以看作是流水线上的一个工序。在