Java 8 Stream API详解( 一)——基本概念

297 阅读12分钟

本系列主要用于记录关于Stream API的一些学习笔记,用于自己以后复习,也希望可以帮助到其他人。 本文主要记录一些基本概念,后面会记录一些常用的流操作和API案例。

Stream创建

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

空Stream

在创建空流时,应该使用empty()方法:

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

通常情况下,在创建流时使用empty()方法来避免对没有元素的流返回null:

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

集合Stream

可以依据任何类型的集合(Collection,List,Set)来创建流:

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

数组Stream

数组也可以是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);

Stream.builder()

当使用builder时,应该指定所需的类型,否则build()方法将创建_Stream_的实例:

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

Stream.generate()

generate()方法接受Supplier来生成元素。由于产生的流是无限的,开发人员应该显式指定所需的大小,否则generate()方法将一直工作,直到超出内存限制:

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

上面的代码会生成包含10个字符串“element”的序列。

Stream.iterate()

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

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

函数的第一个参数就是结果Stream中的第一个元素,后面每个元素都是使用给定的函数结合上一个元素计算得出的。在上面的例子中,第二个元素就是42。

基本类型Stream

Java 8提供了从三种基本类型(int,long和double)中创建流的方式。 由于Stream 是泛型接口,无法将基本类型用作泛型的类型参数,因此创建了三个新的特殊接口:IntStream,LongStream和DoubleStream。 使用新接口减少了不必要的自动装箱,从而提高了效率:

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

range(int startInclusive, int endExclusive) 方法会创建一个从第一个参数到第二个参数的有序流,该方法以步长为1逐步增加后续元素的值。最终的结果Stream中不包含第二个参数,该参数只用于表示序列的上界。 _**rangeClosed(int startInclusive, int endInclusive) **_方法作用是相同的,只有一点区别——结果Stream中包含第二个参数。这两个方法可以用于生成基本类型的流。 从Java 8开始,Random类为生成基本类型流提供了一些方法。例如,以下代码创建一个DoubleStream,其中包含三个元素:

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

字符串Stream

字符串也可以作为创建流的源。 通过使用String类中的chars()方法可以得到一个字符流。由于JDK中没有定义_CharStream_接口,可以使用_IntStream_来代替字符流。

IntStream streamOfChars = "abc".chars();

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

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

文件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()_方法的参数。

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();

由于IllegalStateException是RuntimeException,编译器也就不会发现该问题。 因此,记住Java 8中流不可重用是非常重要的。 这种行为是合乎逻辑的,因为流的设计目的是提供一种能力,以函数式风格将有限的操作序列应用到数据源,但是其本身并不存储元素。 因此,为使先前的代码正常工作,应进行一些更改:

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();

Stream Pipeline

要在数据源的元素上执行一系列操作并聚合其结果,需要三个部分:源、中间操作和终结操作。 中间操作会返回新的修改后的流。 例如,要创建一个现有流的新流而摒除其中的几个元素,则可以使用_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表达式作为参数。 流本身是没有价值的,用户真正感兴趣的是终结操作的结果,终结操作可以是某种类型的值,也可以是应用于流的每个元素的操作。每个流只能使用一个终结操作。 使用流的正确和最方便的方法是通过流管道,流管道是流数据源、中间操作和终结操作组成的链式操作。例如:

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

延迟调用

中间操作是惰性的。这意味着只有在执行终端操作时才需要调用它们。 为了演示这一点,假设我们有一个方法_wasCalled()_,每次调用它时都会对内部计数器进行加一操作:

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()方法会调用三次,而_counter_变量的值会是3。但是运行代码的结果并没有改变_counter,它的值仍然是0。因此,_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()_方法只被调用了一次。之所以如此,是因为管道是垂直执行的。在我们的示例中,流中的第一个元素不满足filter的断言,然后对第二个元素执行filter()方法,而且该元素满足断言。接下来我们没有为第三个元素调用_filter()_方法,而是让第二个元素通过管道到达_map()_方法。 仅需要一个元素就可以满足findFirst()方法,因此,在这个特定的例子中,惰性执行可以避免两个方法的调用:_filter()_方法和_map()_方法。

执行顺序

从性能的角度来看,正确的顺序是流管道中链式操作最重要的考察方面之一

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

执行此代码会将计数器的值加3,这表明该流的_map()_方法被调用了三次,但是size的值是1。也就是说,结果流中只有一个元素,我们无缘无故地多执行了两次_map()_操作。 如果我们更改_skip()_和_map()_方法的顺序,计数器只会增加1,说明_map()_方法只被调用了一次:

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

由此引出一个规则:能够减少流大小的中间操作应该放在应用于流中元素的操作之前,因此,在流管道中应该把_skip()_、filter()、_distinct()_放在顶部。

Stream Reduction

API中有很多终结操作可以将流聚合为一个类或一个基本类型。例如,count()、max()、min()、sum(),但是这些操作是根据预定义的实现而工作的。如果开发人员需要自定义流的归集机制呢?有两个方法可以实现:_reduce()_和_collect()_方法。

reduce()方法

该方法有三个变体,区别在于其签名和返回类型。方法可以接收下列参数: identity——累加器的初始值,或者当流数据为空无法累加时返回的默认值; accumulator——指定聚合元素逻辑的函数。由于累加器对每一步操作都会创建新值,新值的数量等于流的大小,只有最后一个值是有用的。这对于性能不是很好。 combiner——聚合累加器结果的函数。Combiner仅在并行模式下调用,以汇总来自不同线程的累加器的结果。 我们来看一下这三个方法:

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),并且没有日志输出,表明combiner没有被调用。要想使combiner生效,流需要是并行化的:

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)而且combiner被调用了两次,这里的归集过程是通过以下算法进行的:累加器运行了三次,将流中的每个元素都增加了identity。这些操作是并行的,因此结果为(10 + 1 = 11; 10 + 2 = 12; 10 + 3 = 13;)。之后,combiner会合并这三个结果,需要进行两次迭代操作(12 + 13 = 25; 25 + 11 = 36)。

collect()方法

流的归集也可以通过另一个终结操作来完成——collect()方法。它接收一个Collector类型的参数,该参数会声明数据归集机制。系统已经为大多数的常见操作创建了预定义的收集器,可以通过Collectors类的帮助来访问这些方法。 在这一小节,我们使用下面的List作为整个流的数据源:

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")
);

将流转换为集合(Collection, List or Set):

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

归集为String

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

joiner()方法可以接收一到三个参数(delimiter, prefix, suffix),分别表示分隔符、前缀和后缀。使用joiner()方法最方便的地方在于——开发者不需要检查流是否已经结束,以决定要使用分隔符还是后缀。Collector会对其进行处理。 计算流中所有数字元素的平均值:

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

计算流中所有数字元素的总和:

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

averagingXX(), summingXX() 和 _summarizingXX()_三个方法可以处理基本类型(int、long、double)及其包装类型(Integer、Long和Double)。这些方法有一个更强大的特性是提供映射操作,这样开发者就不需要在_collect()_之前执行额外的_map()_操作了。 收集流元素的统计信息:

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()_可以分别获得对应的统计信息,所有这些值都可以从一个管道中提取。 根据指定函数对流中元素进行分组:

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

上面的示例中,流被归集为一个按照价格对产品进行分组的Map。 根据断言将流中元素分类:

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

推动Collector执行额外转换操作:

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

在这个例子中,Collector将流转换为Set,然后在其基础上创建了一个不可修改的Set。 自定义Collector: 如果出于某些原因,需要自定义一个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);

这个例子中,最终会得到一个_LinkedList_。

并行流

在Java8之前,并行化很复杂。ExecutorService和ForkJoin的出现简化了开发人员的工作,但他们仍需要关注如何创建特定的Executor,以及如何运行它等等。 Java 8中引入了一种以函数式风格实现并行的方法。 该API可用于创建并行流,以并行模式执行操作。当流的数据源是集合或数组时,可以借助parallelStream() 方法实现:

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

如果流的数据源不是集合或数组,应该使用parallel() 方法:

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

在底层,Stream API会自动通过ForkJOIN框架并发执行操作。默认情况下,会使用公共线程池,而且无法(至少目前是这样)为其分配自定义的线程池。可以使用自定义的并行Collector来解决这个问题。 在并行模式下使用流时,要避免阻塞操作;尽量在执行任务的耗时接近时使用并发模式(如果某个任务耗时比其它任务长很多,它会拖慢整个应用中的工作流)。 通过使用_sequential()_方法可以将并行模式的流转换回串行模式:

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

总结

Stream API是一组功能强大但易于理解的处理元素序列的工具。它帮助我们减少大量的样板代码,创建更可读的程序,并提高应用程序的工作效率。 在本文中显示的大多数代码示例中,流都没有被使用(我们没有应用close()方法或终端操作)。在实际应用中,不要让一个实例化的流未被使用,因为这会导致内存泄漏。