关于后端:延迟执行与不可变系统讲解JavaStream数据处理

55次阅读

共计 10301 个字符,预计需要花费 26 分钟才能阅读完成。

最近在公司写业务的时候,突然想不起来 Stream 中的累加应该怎么写?

无奈只能面向谷歌编程,破费了我贵重的三分钟之后,学会了,很简略。

自从我用上 JDK8 当前,Stream 就是我最罕用的个性,各种流式操作用的飞起,然而这次事当前我突然感觉 Stream 对我真的很生疏。

可能大家都一样,对最罕用到的货色,也最容易将其疏忽,哪怕你要筹备面试预计也必定想不起来要看一下 Stream 这种货色。

不过我既然留神到了,就要从新梳理一遍它,也算是对我的整体常识体系的查漏补缺。

花了很多功夫来写这篇 Stream,心愿大家和我一块重新认识并学习一下 Stream,理解 API 也好,理解外部个性也罢,怕什么真谛无穷,进一步有进一步的欢喜。

在本文中我将 Stream 的内容分为以下几个局部:

初看这个导图大家可能对转换流操作和终结流操作这两个名词有点蒙,其实这是我将 Stream 中的所有 API 分成两类,每一类起了一个对应的名字(参考自 Java8 相干书籍,见文末):

  • 转换流操作:例如 filter 和 map 办法,将一个 Stream 转换成另一个 Stream,返回值都是 Stream。
  • 终结流操作:例如 count 和 collect 办法,将一个 Stream 汇总为咱们须要的后果,返回值都不是 Stream。

其中转换流操作的 API 我也分了两类,文中会有具体例子阐明,这里先看一下定义,有一个大略印象:

  1. 无状态:即此办法的执行无需依赖后面办法执行的后果集。
  2. 有状态:即此办法的执行须要依赖后面办法执行的后果集。

因为 Stream 内容过多,所以我将 Stream 拆成了高低两篇,本篇是第一篇,内容翔实,用例简略且丰盛。

第二篇的主题尽管只有一个终结操作,然而终结操作 API 比较复杂,所以内容也翔实,用例也简略且丰盛,从篇幅上来看两者差不多,敬请期待。


:因为我本机的电脑是 JDK11,而且写的时候忘了切换到 JDK8,所以在用例中大量呈现的List.of() 在 JDK8 是没有的,它等同于 JDK8 中的Arrays.asList()

1. 为什么要应用 Stream?

所有还要源于 JDK8 的公布,在那个函数式编程语言热火朝天的时代,Java 因为它的臃肿而饱受诟病(强面向对象),社区迫切需要 Java 能退出函数式语言特点改善这种状况,终于在 2014 年 Java 公布了 JDK8。

在 JDK8 中,我认为最大的新个性就是退出了函数式接口和 lambda 表达式,这两个个性取自函数式编程。

这两个特点的退出使 Java 变得更加简略与优雅,用函数式反抗函数式,坚固 Java 老大哥的位置,几乎是师夷长技以制夷。

而 Stream,就是 JDK8 又依靠于下面的两个个性为汇合类库做的 一个类库,它能让咱们通过 lambda 表达式更简明扼要的以流水线的形式去解决汇合内的数据,能够很轻松的实现诸如:过滤、分组、收集、归约这类操作,所以我愿将 Stream 称为函数式接口的最佳实际。

1.1 更清晰的代码构造

Stream 领有更清晰的代码构造,为了更好的解说 Stream 怎么就让代码变清晰了,这里假如咱们有一个非常简单的需要:在一个汇合中找到所有大于 2 的元素

先来看看没应用 Stream 之前:

        List<Integer> list = List.of(1, 2, 3);
        
        List<Integer> filterList = new ArrayList<>();
        
        for (Integer i : list) {if (i > 2) {filterList.add(i);
            }
        }
        
        System.out.println(filterList);

下面的代码很好了解,我就不过多解释了,其实也还好了,因为咱们的需要比较简单,如果需要再多点呢?

每多一个要求,那么 if 外面就又要加一个条件了,而咱们开发中往往对象上都有很多字段,那么条件可能有四五个,最初可能会变成这样:

        List<Integer> list = List.of(1, 2, 3);

        List<Integer> filterList = new ArrayList<>();

        for (Integer i : list) {if (i > 2 && i < 10 && (i % 2 == 0)) {filterList.add(i);
            }
        }

        System.out.println(filterList);

if 外面塞了很多条件,看起来就变得乱哄哄了,其实这也还好,最要命的是我的项目中往往有很多相似的需要,它们之间的区别只是某个条件不一样,那么你就须要复制一大坨代码,改吧改吧就上线了,这就导致代码里有大量反复的代码。

如果你 Stream,所有都会变得清晰易懂:

        List<Integer> list = List.of(1, 2, 3).stream()
                .filter(i -> i > 2)
                .filter(i -> i < 10)
                .filter(i -> i % 2 == 0)
                .collect(toList());

这段代码你只须要关注咱们最关注的货色:筛选条件就够了,filter 这个办法名能让你分明的晓得它是个过滤条件,collect 这个办法名也能看进去它是一个收集器,将最终后果收集到一个 List 外面去。

同时你可能发现了,为什么下面的代码中不必写循环?

因为 Stream 会帮忙咱们进行隐式的循环,这被称为:外部迭代,与之对应的就是咱们常见的内部迭代了。

所以就算你不写循环,它也会进行一遍循环。

1.2 不用关怀变量状态

Stream 在设计之初就被设计为 不可变的,它的不可变有两重含意:

  1. 因为每次 Stream 操作都会生成一个新的 Stream,所以 Stream 是不可变的,就像 String。
  2. 在 Stream 中只保留原汇合的援用,所以在进行一些会批改元素的操作时,是通过原元素生成一份新的新元素,所以 Stream 的任何操作都不会影响到原对象。

第一个含意能够帮忙咱们进行链式调用,实际上咱们应用 Stream 的过程中往往会应用链式调用,而第二个含意则是函数式编程中的一大特点:不批改状态。

无论对 Stream 做怎么样的操作,它最终都不会影响到原汇合,它的返回值也是在原汇合的根底上进行计算得来的。

所以在 Stream 中咱们不用关怀操作原对象汇合带来的种种副作用,用就完了。

对于函数式编程能够查阅阮一峰的函数式编程初探。

1.3 提早执行与优化

Stream 只在遇到 终结操作 的时候才会执行,比方:

        List.of(1, 2, 3).stream()
                .filter(i -> i > 2)
                .peek(System.out::println);

这么一段代码是不会执行的,peek 办法能够看作是 forEach,这里我用它来打印 Stream 中的元素。

因为 filter 办法和 peek 办法都是转换流办法,所以不会触发执行。

如果咱们在前面退出一个 count 办法就能失常执行:

        List.of(1, 2, 3).stream()
                .filter(i -> i > 2)
                .peek(System.out::println)
                .count();

count 办法是一个终结操作,用于计算出 Stream 中有多少个元素,它的返回值是一个 long 型。

Stream 的这种没有终结操作就不会执行的个性被称为 提早执行

与此同时,Stream 还会对 API 中的无状态办法进行名为 循环合并 的优化,具体例子详见第三节。

2. 创立 Stream

为了文章的完整性,我思来想去还是加上了创立 Stream 这一节,这一节次要介绍一些创立 Stream 的罕用形式,Stream 的创立个别能够分为两种状况:

  1. 应用 Steam 接口创立
  2. 通过汇合类库创立

同时还会讲一讲 Stream 的并行流与连贯,都是创立 Stream,却具备不同的特点。

2.1 通过 Stream 接口创立

Stream 作为一个接口,它在接口中定义了定义了几个静态方法为咱们提供创立 Stream 的 API:

    public static<T> Stream<T> of(T... values) {return Arrays.stream(values);
    }

首先是 of 办法,它提供了一个泛型可变参数,为咱们创立了带有泛型的 Stream 流,同时在如果你的参数是根本类型的状况下会应用主动包装对根本类型进行包装:

        Stream<Integer> integerStream = Stream.of(1, 2, 3);

        Stream<Double> doubleStream = Stream.of(1.1d, 2.2d, 3.3d);

        Stream<String> stringStream = Stream.of("1", "2", "3");

当然,你也能够间接创立一个空的 Stream,只须要调用另一个静态方法——empty(),它的泛型是一个 Object:

        Stream<Object> empty = Stream.empty();

以上都是咱们让咱们易于了解的创立形式,还有一种形式能够创立一个无限度元素数量的 Stream——generate():

    public static<T> Stream<T> generate(Supplier<? extends T> s) {Objects.requireNonNull(s);
        return StreamSupport.stream(new StreamSpliterators.InfiniteSupplyingSpliterator.OfRef<>(Long.MAX_VALUE, s), false);
    }

从办法参数上来看,它承受一个函数式接口——Supplier 作为参数,这个函数式接口是用来创建对象的接口,你能够将其类比为对象的创立工厂,Stream 将从此工厂中创立的对象放入 Stream 中:

        Stream<String> generate = Stream.generate(() -> "Supplier");

        Stream<Integer> generateInteger = Stream.generate(() -> 123);

我这里是为了不便间接应用 Lamdba 结构了一个 Supplier 对象,你也能够间接传入一个 Supplier 对象,它会通过 Supplier 接口的 get() 办法来结构对象。

2.2 通过汇合类库进行创立

相较于下面一种来说,第二种形式更较为罕用,咱们经常对汇合就行 Stream 流操作而非手动构建一个 Stream:

        Stream<Integer> integerStreamList = List.of(1, 2, 3).stream();
        
        Stream<String> stringStreamList = List.of("1", "2", "3").stream(); 

在 Java8 中,汇合的顶层接口 Collection 被退出了一个新的接口默认办法——stream(),通过这个办法咱们能够不便的对所有汇合子类进行创立 Stream 的操作:

        Stream<Integer> listStream = List.of(1, 2, 3).stream();
        
        Stream<Integer> setStream = Set.of(1, 2, 3).stream();

通过查阅源码,能够发先 stream() 办法实质上还是通过调用一个 Stream 工具类来创立 Stream:

    default Stream<E> stream() {return StreamSupport.stream(spliterator(), false);
    }

2.3 创立并行流

在以上的示例中所有的 Stream 都是串行流,在某些场景下,为了最大化压迫多核 CPU 的性能,咱们能够应用并行流,它通过 JDK7 中引入的 fork/join 框架来执行并行操作,咱们能够通过如下形式创立并行流:

        Stream<Integer> integerParallelStream = Stream.of(1, 2, 3).parallel();

        Stream<String> stringParallelStream = Stream.of("1", "2", "3").parallel();

        Stream<Integer> integerParallelStreamList = List.of(1, 2, 3).parallelStream();

        Stream<String> stringParallelStreamList = List.of("1", "2", "3").parallelStream();

是的,在 Stream 的静态方法中没有间接创立并行流的办法,咱们须要在结构 Stream 后再调用一次 parallel()办法能力创立并行流,因为调用 parallel()办法并不会从新创立一个并行流对象,而是在原有的 Stream 对象下面设置了一个并行参数。

当然,咱们还能够看到,Collection 接口中能够间接创立并行流,只须要调用与 stream() 对应的parallelStream() 办法,就像我方才讲到的,他们之间其实只有参数的不同:

    default Stream<E> stream() {return StreamSupport.stream(spliterator(), false);
    }

    default Stream<E> parallelStream() {return StreamSupport.stream(spliterator(), true);
    }

不过个别状况下咱们并不需要用到并行流,在 Stream 中元素不过千的状况下性能并不会有太大晋升,因为将元素扩散到不同的 CPU 进行计算也是有老本的。

并行的益处是充分利用多核 CPU 的性能,然而应用中往往要对数据进行宰割,而后扩散到各个 CPU 下来解决,如果咱们应用的数据是数组构造则能够很轻易的进行宰割,然而如果是链表构造的数据或者 Hash 构造的数据则宰割起来很显著不如数组构造不便。

所以只有当 Stream 中元素过万甚至更大时,选用并行流能力带给你更显著的性能晋升。

最初,当你有一个并行流的时候,你也能够通过sequential() 将其不便的转换成串行流:

        Stream.of(1, 2, 3).parallel().sequential();

2.4 连贯 Stream

如果你在两处结构了两个 Stream,在应用的时候心愿组合在一起应用,能够应用 concat():

        Stream<Integer> concat = Stream
                .concat(Stream.of(1, 2, 3), Stream.of(4, 5, 6));

如果是两种不同的泛型流进行组合,主动推断会主动的推断出两种类型雷同的父类:

        Stream<Integer> integerStream = Stream.of(1, 2, 3);

        Stream<String> stringStream = Stream.of("1", "2", "3");

        Stream<? extends Serializable> stream = Stream.concat(integerStream, stringStream);

3. Stream 转换操作之无状态办法

无状态办法:即此办法的执行无需依赖后面办法执行的后果集。

在 Stream 中无状态的 API 咱们罕用的大略有以下三个:

  1. map()办法:此办法的参数是一个 Function 对象,它能够使你对汇合中的元素做自定义操作,并保留操作后的元素。
  2. filter()办法:此办法的参数是一个 Predicate 对象,Predicate 的执行后果是一个 Boolean 类型,所以此办法只保留返回值为 true 的元素,正如其名咱们能够应用此办法做一些筛选操作。
  3. flatMap()办法:此办法和 map()办法一样参数是一个 Function 对象,然而此 Function 的返回值要求是一个 Stream,该办法能够将多个 Stream 中的元素聚合在一起进行返回。

先来看看一个 map()办法的示例:

        Stream<Integer> integerStreamList = List.of(1, 2, 3).stream();

        Stream<Integer> mapStream = integerStreamList.map(i -> i * 10);

咱们领有一个 List,想要对其中的每个元素进行 乘 10 的操作,就能够采纳如上写法,其中的 i 是对 List 中元素的变量名, 前面的逻辑则是要对此元素进行的操作,以一种十分简洁明了的形式传入一段代码逻辑执行,这段代码最初会返回一个蕴含操作后果的新 Stream。

这里为了更好的帮忙大家了解,我画了一个简图:


接下来是 filter()办法示例:

        Stream<Integer> integerStreamList = List.of(10, 20, 30).stream();

        Stream<Integer> filterStream = integerStreamList.filter(i -> i >= 20);

在这段代码中会执行i >= 20 这段逻辑,而后将返回值为 true 的后果保留在一个新的 Stream 中并返回。

这里我也有一个简略的图示:


flatMap() 办法的形容在上文我曾经形容过,然而有点过于形象,我在学习此办法中也是搜寻了很多示例才有了较好的了解。

依据官网文档的说法,此办法是为了进行一对多元素的平展操作:

        List<Order> orders = List.of(new Order(), new Order());

        Stream<Item> itemStream = orders.stream()
                .flatMap(order -> order.getItemList().stream());

这里我通过一个订单示例来阐明此办法,咱们的每个订单中都蕴含了一个商品 List,如果我想要将两个订单中所有商品 List 组成一个新的商品 List,就须要用到 flatMap()办法。

在下面的代码示例中能够看到每个订单都返回了一个商品 List 的 Stream,咱们在本例中只有两个订单,所以也就是最终会返回两个商品 List 的 Stream,flatMap()办法的作用就是将这两个 Stream 中元素提取进去而后放到一个新的 Stream 中。

老规矩,放一个简略的图示来阐明:

图例中我应用青色代表 Stream,在最终的输入中能够看到 flatMap()将两个流变成了一个流进行输入,这在某些场景中十分有用,比方我下面的订单例子。


还有一个很不罕用的无状态办法peek()

    Stream<T> peek(Consumer<? super T> action);

peek 办法承受一个 Consumer 对象做参数,这是一个无返回值的参数,咱们能够通过 peek 办法做些打印元素之类的操作:

        Stream<Integer> peekStream = integerStreamList.peek(i -> System.out.println(i));

然而如果你不太熟悉的话,不倡议应用,某些状况下它并不会失效,比方:

        List.of(1, 2, 3).stream()
                .map(i -> i * 10)
                .peek(System.out::println)
                .count();

API 文档下面也注明了此办法是用于 Debug,通过我的教训,只有当 Stream 最终须要从新生产元素时,peek 才会执行。

下面的例子中,count 只须要返回元素个数,所以 peek 没有执行,如果换成 collect 办法就会执行。

或者如果 Stream 中存在过滤办法如 filter 办法和 match 相干办法,它也会执行。

3.1 根底类型 Stream

上一节提到了三个 Stream 中最罕用的三个无状态办法,在 Stream 的无状态办法中还有几个和 map()与 flatMap()对应的办法,它们别离是:

  1. mapToInt
  2. mapToLong
  3. mapToDouble
  4. flatMapToInt
  5. flatMapToLong
  6. flatMapToDouble

这六个办法首先从办法名中就可以看进去,它们只是在 map()或者 flatMap()的根底上对返回值进行转换操作,按理说没必要单拎进去做成一个办法,实际上它们的关键在于返回值:

  1. mapToInt 返回值为IntStream
  2. mapToLong 返回值为LongStream
  3. mapToDouble 返回值为DoubleStream
  4. flatMapToInt 返回值为IntStream
  5. flatMapToLong 返回值为LongStream
  6. flatMapToDouble 返回值为DoubleStream

在 JDK5 中为了使 Java 更加的面向对象,引入了包装类的概念,八大根底数据类型都对应着一个包装类,这使你在应用根底类型时能够无感的进行主动拆箱 / 装箱,也就是主动应用包装类的转换方法。

比方,在最前文的示例中,我用了这样一个例子:

        Stream<Integer> integerStream = Stream.of(1, 2, 3);

我在创立 Stream 中应用了根本数据类型参数,其泛型则被主动包装成了 Integer,然而咱们有时可能疏忽主动拆装箱也是有代价的,如果咱们想在应用 Stream 中疏忽这个代价则能够应用 Stream 中转为根底数据类型设计的 Stream:

  1. IntStream:对应 根底数据类型中的 int、short、char、boolean
  2. LongStream:对应根底数据类型中的 long
  3. DoubleStream:对应根底数据类型中的 double 和 float

在这些接口中都能够和上文的例子一样通过 of 办法结构 Stream,且不会主动拆装箱。

所以上文中提到的那六个办法实际上就是将一般流转换成这种根底类型流,在咱们须要的时候能够领有更高的效率。

根底类型流在 API 方面领有 Stream 一样的 API,所以在应用方面只有明确了 Stream,根底类型流也都是一样的。

:IntStream、LongStream 和 DoubleStream 都是接口,但并非继承自 Stream 接口。

3.2 无状态办法的循环合并

说完无状态的这几个办法咱们来看一个前文中的例子:

        List<Integer> list = List.of(1, 2, 3).stream()
                .filter(i -> i > 2)
                .filter(i -> i < 10)
                .filter(i -> i % 2 == 0)
                .collect(toList());

在这个例子中我用了三次 filter 办法,那么大家感觉 Stream 会循环三次进行过滤吗?

如果换掉其中一个 filter 为 map,大家感觉会循环几次?

        List<Integer> list = List.of(1, 2, 3).stream()
                .map(i -> i * 10)
                .filter(i -> i < 10)
                .filter(i -> i % 2 == 0)
                .collect(toList());

从咱们的直觉来看,须要先应用 map 办法对所有元素做解决,而后再应用 filter 办法做过滤,所以须要执行三次循环。

但回顾无状态办法的定义,你能够发现其余这三个条件能够放在一个循环外面做,因为 filter 只依赖 map 的计算结果,而不用依赖 map 执行完后的后果集,所以只有保障先操作 map 再操作 filter,它们就能够在一次循环内实现,这种优化形式被称为 循环合并

所有的无状态办法都能够放在同一个循环内执行,它们也能够不便的应用并行流在多个 CPU 上执行。

4. Stream 转换操作之有状态办法

后面说完了无状态办法,有状态办法就比较简单了,只看名字就能够晓得它的作用:

办法名 办法后果
distinct() 元素去重。
sorted() 元素排序,重载的两个办法,须要的时候能够传入一个排序对象。
limit(long maxSize) 传入一个数字,代表只取前 X 个元素。
skip(long n) 传入一个数字,代表跳过 X 个元素,取前面的元素。
takeWhile(Predicate predicate) JDK9 新增,传入一个断言参数当第一次断言为 false 时进行,返回后面断言为 true 的元素。
dropWhile(Predicate predicate) JDK9 新增,传入一个断言参数当第一次断言为 false 时进行,删除后面断言为 true 的元素。

以上就是所有的有状态办法,它们的办法执行都必须依赖后面办法执行的后果集能力执行,比方排序办法就须要依赖后面办法的后果集能力进行排序。

同时 limit 办法和 takeWhile 是两个短路操作方法,这象征效率更高,因为可能外部循环还没有走完时就曾经选出了咱们想要的元素。

所以有状态的办法不像无状态办法那样能够在一个循环内执行,每个有状态办法都要经验一个独自的外部循环,所以编写代码时的程序会影响到程序的执行后果以及性能,心愿各位读者在开发过程中留神。

5. 总结

本文次要是对 Stream 做了一个概览,并讲述了 Stream 的两大特点:

  1. 不可变:不影响原汇合,每次调用都返回一个新的 Stream。
  2. 提早执行:在遇到终结操作之前,Stream 不会执行。

同时也将 Stream 的 API 分成了转换操作和终结操作两类,并解说了所有罕用的转换操作,下一章的次要内容将是终结操作。

在看 Stream 源码的过程中发现了一个乏味的事件,在 ReferencePipeline 类中(Stream 的实现类),它的办法程序从上往下正好是:无状态办法 → 有状态办法 → 聚合办法。

好了,学完本篇后,我想大家对 Stream 的整体曾经很清晰了,同时对转换操作的 API 应该也曾经把握了,毕竟也不多😂,Java8 还有很多弱小的个性,咱们下次接着聊~


同时,本文在写作过程中也参考了以下书籍:

  • 写给大忙人看的 Java SE 8
  • Java 8 函数式编程
  • Java 8 实战

这三本书都十分好,第一本是 Java 核心技术的作者写的,如果你想全面的理解 JDK8 的降级能够看这本。

第二本能够说是一个小册子,只有一百多页很短,次要讲了一些函数式的思维。

如果你只能看一本,那么我这里举荐第三本,豆瓣评分高达 9.2,内容和品质都当属上乘。

正文完
 0