Java 8 Stream API 简介

本文转载与本人个人博客

1.概述

在本文中,我们将介绍从创建到并行执行的Java 8 Streams的实际用法。

要理解本文,读者需要具备Java 8(lambda表达式,Optional,方法引用)和Stream API 的基本知识。

2.创建Stream

有许多方法可以创建不同源的Stream实例。一旦创建,实例将不会修改其源,因此允许从单个源创建多个实例。

2.1 空Stream

如果创建空Stream,则应使用empty()方法:

Stream<String> streamEmpty = Stream.empty();

通常情况是在创建时使用empty()方法以避免为没有元素的Stream返回null:

public Stream<String> streamOf(List<String> list) {
    return list == null || list.isEmpty() ? Stream.empty() : list.stream();
}

2.2 集合Stream

Stream也可以由任何类型的Collection(Collection,List,Set)创建:

Collection<String> collection = Arrays.asList("a", "b", "c");
Stream<String> streamOfCollection = collection.stream();

2.3 数组Stream

Array也可以是Stream的源:

Stream<String> streamOfArray = Stream.of("a", "b", "c");

它们也可以从现有数组或数组的一部分创建:

String[] arr = new String[]{"a", "b", "c"};
Stream<String> streamOfArrayFull = Arrays.stream(arr);
Stream<String> streamOfArrayPart = Arrays.stream(arr, 1, 3);

2.4 Stream.builder()

使用构建器时,应在语句的右侧部分另外指定所需类型,否则build()方法将创建Stream的实例:

Stream<String> streamBuilder =
  Stream.<String>builder().add("a").add("b").add("c").build();

2.5 Stream.generate()

generate()方法接受一个Supplier<T>生成。由于结果Stream是无限的,应指定所需的大小,否则generate()方法将一直有效,直到达到内存限制:

Stream<String> streamGenerated =
  Stream.generate(() -> "element").limit(10);

上面的代码创建了一个包含十个字符串的序列,其值为“element”。

2.6 Stream.iterate()

创建无限Stream的另一种方法是使用iterate()方法:

Stream<Integer> streamIterated = Stream.iterate(40, n -> n + 2).limit(20);

生成的Stream的第一个元素是iterate()方法的第一个参数。为了创建每个后续元素,指定的函数将应用于前一个元素。在上面的示例中,第二个元素将是42。

2.7 基本类型的Stream

Java 8提供了使用三种基本类型创建Stream的可能性:int,long和double。由于Stream 是一个泛型接口,并且无法使用基本类型作为泛型的类型参数,因此创建了三个新的特殊接口:IntStream,LongStream,DoubleStream。

使用新的接口减轻了不必要的自动装箱,提高了效率:

IntStream intStream = IntStream.range(1, 3);
LongStream longStream = LongStream.rangeClosed(1, 3);

range(int startInclusive,int endExclusive)方法创建从其第一参数到第二参数的有序Stream。它以1为步长增加后续元素的值。结果不包括最后一个参数,它只是序列的上限。

rangeClosed(int startInclusive,int endInclusive)方法和上述方法只有一个差别——其第二参数被包括。这两种方法可用于生成三种基本类型Stream中的任何一种。

从Java 8开始,Random类为生成基本类型Stream提供了更多的方法。例如,以下代码创建一个DoubleStream,它有三个元素:

Random random = new Random();
DoubleStream doubleStream = random.doubles(3);

2.8 字符串Stream

String也可以用作创建Stream的源。

借助String类的chars()方法。由于JDK没有接口CharStream,我们使用IntStream代替字符Stream的表示。

IntStream streamOfChars = "abc".chars();

以下示例根据指定的RegEx将String拆分为子字符串:

Stream<String> streamOfString =
  Pattern.compile(", ").splitAsStream("a, b, c");

2.9 文件Stream

Java NIO类Files允许通过lines()方法生成文本文件的Stream 。文本的每一行都成为Stream的一个元素:

Path path = Paths.get("C:\\file.txt");
Stream<String> streamOfStrings = Files.lines(path);
Stream<String> streamWithCharset = 
  Files.lines(path, Charset.forName("UTF-8"));

CharSet可以被指定为lines()方法的参数。

3.引用Stream

只要只调用中间操作,就可以实例化一个Stream并具有对它的可访问引用。执行终端操作会使Stream不可访问。

为了证明这一点,我们将暂时忘记最好的方法是链序操作。除了不必要的冗长之外,从技术上讲,以下代码是有效的:

Stream<String> stream = 
  Stream.of("a", "b", "c").filter(element -> element.contains("b"));
Optional<String> anyElement = stream.findAny();

但是在调用终端操作后尝试重用相同的引用将触发IllegalStateException

Optional<String> firstElement = stream.findFirst();

由于IllegalStateExceptionRuntimeException,编译器不会发出有关问题的信号。因此,记住Java 8 Stream不能重用是非常重要的。

这种行为是合乎逻辑的,因为Stream被设计为能够将有限的操作序列函数式地应用于元素源但不存储元素。

因此,为了使以前的代码正常工作,应该进行一些更改:

List<String> elements =
  Stream.of("a", "b", "c").filter(element -> element.contains("b"))
    .collect(Collectors.toList());
Optional<String> anyElement = elements.stream().findAny();
Optional<String> firstElement = elements.stream().findFirst();

4.Stream管道

要对数据源的元素执行一系列操作并聚合它们的结果,需要三个部分: 源、中间操作和终端操作。

中间操作返回新的修改Stream。例如,要基于一个已经存在的Stream创建一个没有前几个元素新Stream,应使用skip()方法:

Stream<String> onceModifiedStream =
  Stream.of("abcd", "bbcd", "cbcd").skip(1);

如果需要多个修改,则可以链接中间操作。假设我们还需要用前几个字符的子字符串替换当前Stream 的每个元素。这将通过链接skip()和map()方法来完成:

Stream<String> twiceModifiedStream =
  stream.skip(1).map(element -> element.substring(0, 3));

map()方法将lambda表达式作为参数。

Stream本身是没有价值的,用户真正感兴趣的是终端操作的结果,它可以是某种类型的值或者是应用于Stream的每个元素的动作。每个Stream只能使用一个终端操作。

使用Stream的正确和最方便的方式是Stream管道,它是Stream源,中间操作和终端操作的链。例如:

List<String> list = Arrays.asList("abc1", "abc2", "abc3");
long size = list.stream().skip(1)
  .map(element -> element.substring(0, 3)).sorted().count();

5.惰性调用

中间操作是lazy的。这意味着只有在执行终端操作时才会调用它们。

为了证明这一点,假设我们有方法wasCalled(),它在每次调用时其内部计数器会自增1:

private long counter;
  
private void wasCalled() {
    counter++;
}

让我们在filter()里调用wasCalled()方法 :

List<String> list = Arrays.asList(“abc1”, “abc2”, “abc3”);
counter = 0;
Stream<String> stream = list.stream().filter(element -> {
    wasCalled();
    return element.contains("2");
});

由于我们有三个元素的源,我们可以假设方法filter()将被调用三次,计数器变量的值将是3。但运行此代码根本不会改变计数器,它仍然为零,所以,filter()方法甚至没有被调用过一次。原因是缺少终端操作。

让我们通过添加map()操作以及终端操作——findFirst()来重写这段代码。我们还将添加一种在日志记录的帮助下跟踪方法调用顺序的功能:

Optional<String> stream = list.stream().filter(element -> {
    log.info("filter() was called");
    return element.contains("2");
}).map(element -> {
    log.info("map() was called");
    return element.toUpperCase();
}).findFirst();

结果日志显示filter()方法被调用两次而map()方法只调用一次。这是因为管道垂直执行。在我们的示例中,Stream的第一个元素不满足filter的谓词,然后为传递给过滤器的第二个元素调用了filter()方法。在这里并没有为第三个元素调用filter(),我们通过管道进入map()方法。findFirst()方法仅仅一个元素满足即可。因此,在这个特定的例子中,惰性调用避免两次方法调用 —— 一次用于filter(),一次用于map()。

6.执行顺序

从性能的角度来看,正确的顺序是Stream管道中链接操作的最重要方面之一:

long size = list.stream().map(element -> {
    wasCalled();
    return element.substring(0, 3);
}).skip(2).count();

执行此代码会将计数器的值增加3。这意味着Stream的map()方法被调用了三次。但是最终的结果的size是1。因此,结果Stream只有一个元素,我们执行代价更高的map()操作,三次操作中有两次是没有意义的。

如果我们改变map()和skip()方法的顺序,该计数器将只会增加1。因此,方法map()将只调用一次:

long size = list.stream().skip(2).map(element -> {
    wasCalled();
    return element.substring(0, 3);
}).count();

这使我们了解规则:减少Stream大小的中间操作应放在应用于每个元素的操作之前。因此,在Stream管道的顶部保留skip(),filter(),distinct()等方法。

7.Stream Reduce

API有许多终端操作,它们将Stream聚合到某种类型或基本类型,例如count(),max(),min(),sum(),但这些操作只会根据预定义的实现来工作。如果我们需要来定制Stream的reduce机制该如何做呢?有两种方法可以实现这一点——reduce()和collect()方法。

7.1 reduce()方法

这种方法有三种变体,它们的签名和返回类型不同。它们可以具有以下参数:

  • identity:accumulator的初始值或者如果Stream为空且没有任何可累积元素时的默认值;
  • accumulator:一个指定了元素聚合逻辑的函数。当累加器为每个reduce步骤创建一个新值时,新值的数量等于Stream的大小,只有最后一个值是有用的。这对性能不是很好。
  • combiner:聚合累加器结果的函数。仅在并行模式下调用组合器以reduce来自不同线程的累加器的结果。

那么,让我们看看这三种方法:

OptionalInt reduced =
  IntStream.range(1, 4).reduce((a, b) -> a + b);
  
//reduced = 6(1 + 2 + 3)
int reducedTwoParams =
  IntStream.range(1, 4).reduce(10, (a, b) -> a + b);
//reducedTwoParams = 16(10 + 1 + 2 + 3)
int reducedParams = Stream.of(1, 2, 3)
  .reduce(10, (a, b) -> a + b, (a, b) -> {
     log.info("combiner was called");
     return a + b;
  });

结果与前面的例子(16)中的结果相同,并且没有输出日志,这意味着没有调用该组合器。要使组合器工作,Stream应该是并行的:

int reducedParallel = Arrays.asList(1, 2, 3).parallelStream()
    .reduce(10, (a, b) -> a + b, (a, b) -> {
       log.info("combiner was called");
       return a + b;
    });

这里的结果是不同的(36),并且组合器被调用两次。在这里,还原的工作方式如下算法:累加器把Stream的每一个元素加到identity,这些操作并行进行,这三次操作是(10 + 1 = 11; 10 + 2 = 12; 10 + 3 = 13;)。然后组合器合并这三个结果,它需要两次迭代(12 + 13 = 25; 25 + 11 = 36)。

7.2 collect()方法

还可以通过另一个终端操作——collect()方法来执行Stream的reduce。它接受Collector类型的参数,该参数指定reduce的机制。JDK已经为大多数常见操作创建了预定义的收集器。我们可以通过collectors类的使用它们。

在本节中,我们将使用以下List作为所有Stream的源:

List<Product> productList = Arrays.asList(new Product(23, "potatoes"),
  new Product(14, "orange"), new Product(13, "lemon"),
  new Product(23, "bread"), new Product(13, "sugar"));

将Stream转换为集合(集合,列表或集):

List<String> collectorCollection = 
  productList.stream().map(Product::getName).collect(Collectors.toList());

reduce到字符串:

String listToString = productList.stream().map(Product::getName)
  .collect(Collectors.joining(", ", "[", "]"));

joining()方法可以有一至三个参数(定界符,前缀,后缀)。使用joining()的最方便的事情——我们不需要检查Stream是否到达它的末尾以应用后缀而不是应用分隔符。collector将负责这一点。

处理Stream的所有数字元素的平均值:

double averagePrice = productList.stream()
  .collect(Collectors.averagingInt(Product::getPrice));

处理Stream的所有数字元素的总和:

int summingPrice = productList.stream()
  .collect(Collectors.summingInt(Product::getPrice));

方法averagingXX(),summingXX()和summarizingXX()可以像基本类型(int,long,double)一样工作,就像它们的包装类(Integer,Long,Double)一样。这些方法的另一个强大功能是提供映射。因此,我们不需要在collect()方法之前使用额外的map()操作。

收集有关Stream元素的统计信息:

IntSummaryStatistics statistics = productList.stream()
  .collect(Collectors.summarizingInt(Product::getPrice));

通过使用IntSummaryStatistics类型产生的实例,我们可以通过应用toString()方法创建统计报告。结果将是这个“IntSummaryStatistics {count = 5,sum = 86,min = 13,average = 17,200000,max = 23}”类似的字符串。

通过应用方法getCount(),getSum(),getMin(),getAverage(),getMax(),可以很容易地从此对象中提取count,sum,min,average的单独值。所有这些值都可以从单个管道中提取。

根据指定的函数对Stream的元素进行分组:

Map<Integer, List<Product>> collectorMapOfLists = productList.stream()
  .collect(Collectors.groupingBy(Product::getPrice));

在上面的示例中,Stream被reduce为Map,按价格对所有产品进行分组。

根据某些谓词将Stream的元素分成组:

Map<Boolean, List<Product>> mapPartioned = productList.stream()
  .collect(Collectors.partitioningBy(element -> element.getPrice() > 15));

让收集器执行额外的转换:

Set<Product> unmodifiableSet = productList.stream()
  .collect(Collectors.collectingAndThen(Collectors.toSet(),
  Collections::unmodifiableSet));

在这种特殊情况下,收集将Stream转换为Set,然后从中创建不可修改的Set。

定制Collector:

出于某种原因,有时候需要创建自定义收集器,最简单且不那么冗长的方法是使用Collector类的方法of()

Collector<Product, ?, LinkedList<Product>> toLinkedList =
  Collector.of(LinkedList::new, LinkedList::add, 
    (first, second) -> { 
       first.addAll(second); 
       return first; 
    });
 
LinkedList<Product> linkedListOfPersons =
  productList.stream().collect(toLinkedList);

在此示例中,收集器的实例已reduce为LinkedList。

并行Stream

在Java 8之前,并行化很复杂。新ExecutorService的和ForkJoin 使我们非常容易创建并发的线程,但我们仍然需要牢记如何创建一个具体的executor以及如何使用它等。Java 8引入了一种在函数式的实现并行的方法。

API允许创建并行Stream,以并行模式执行操作。当Stream的源是Collection或数组时,可以使用parallelStream()方法实现并行化:

Stream<Product> streamOfCollection = productList.parallelStream();
boolean isParallel = streamOfCollection.isParallel();
boolean bigPrice = streamOfCollection
  .map(product -> product.getPrice() * 12)
  .anyMatch(price -> price > 200);

如果Stream的源不是Collection或数组,则应使用parallel()方法:

IntStream intStreamParallel = IntStream.range(1, 150).parallel();
boolean isParallel = intStreamParallel.isParallel();

在底层中,Stream API自动使用ForkJoin框架并行执行操作。默认情况下,将使用公共线程池,并且无法(至少现在)为其分配一些自定义线程池。

在并行模式下使用Stream时,避免阻塞操作并在任务需要相同的执行时间时使用并行模式(如果一个任务比另一个任务持续时间长,则可能会减慢整个应用程序的工作Stream程)。

可以使用sequential()方法将并行模式的Stream转换回顺序模式:

IntStream intStreamSequential = intStreamParallel.sequential();
boolean isParallel = intStreamSequential.isParallel();

结论

Stream API是一个功能强大但易于理解的工具集,用于处理元素序列。它允许我们减少大量样板代码,创建更易读的程序,并在正确使用时提高应用程序的工作效率。

在本文中显示的大多数代码示例中,Stream是未消耗的(我们没有应用close()方法或终端操作)。在真实的应用程序中,不要留下未实例化的Stream,因为这将导致内存泄漏。

猜你喜欢

转载自blog.csdn.net/Tales_/article/details/83046720