实际上,Java 8流惰性是没有用的吗?

时间:2018-10-07 04:46:33

标签: java java-stream lazy-loading

最近,我读了很多关于Java 8流的文章,特别是关于用Java 8流进行延迟加载的几篇文章:hereover here。我似乎无法撼动这种感觉,即惰性加载完全没有用(或者充其量只是语法上的便利,提供零性能值)。

让我们以以下代码为例:

int[] myInts = new int[]{1,2,3,5,8,13,21};

IntStream myIntStream = IntStream.of(myInts);

int[] myChangedArray = myIntStream
                        .peek(n -> System.out.println("About to square: " + n))
                        .map(n -> (int)Math.pow(n, 2))
                        .peek(n -> System.out.println("Done squaring, result: " + n))
                        .toArray();

这将登录控制台,因为调用了terminal operation,在这种情况下为toArray(),并且我们的流是惰性的,仅在调用终端操作时才执行。我当然也可以这样做:

  IntStream myChangedInts = myIntStream
    .peek(n -> System.out.println("About to square: " + n))
    .map(n -> (int)Math.pow(n, 2))
    .peek(n -> System.out.println("Done squaring, result: " + n));

什么也不会打印,因为地图没有发生,因为我不需要数据。直到我这样称呼:

  int[] myChangedArray = myChangedInts.toArray();

瞧,我得到了映射的数据和控制台日志。除了我看到的任何零收益。我意识到我可以在调用toArray()之前定义过滤器代码,并且可以绕过“未经过真正过滤的流”,但是那又是什么呢?受益吗?

这些文章似乎暗示与懒惰有关的性能提高,例如:

  

在Java 8 Streams API中,中间操作是惰性的,并且对其内部处理模型进行了优化,以使其能够以高性能处理大量数据。

  

Java 8 Streams API借助短路操作优化了流处理。短路方法在满足其条件后立即终止流处理。通常,短路操作是指一旦满足条件,就会中断流水线之前的所有中间操作。某些中间操作和终端操作都具有这种行为。

从字面上看,这听起来像是跳出了循环,完全没有惰性。

最后,第二篇文章中有这条困惑的线:

  

惰性操作可提高效率。这是一种不处理陈旧数据的方法。在逐渐消耗输入数据而不是事先拥有完整的元素集的情况下,惰性操作可能很有用。例如,考虑以下情况:使用Stream#generate(Supplier )创建了无限流,并且提供的Supplier函数正在逐渐从远程服务器接收数据。在这种情况下,服务器调用只会在需要时在终端操作上进行。

无法处理过时的数据吗?什么?延迟加载如何使某人无法处理过时的数据?


TLDR:延迟加载除了能够在以后运行filter / map / reduce /无论执行什么操作(提供零性能优势)之外,还有什么好处?

如果是,那么实际的用例是什么?

8 个答案:

答案 0 :(得分:15)

您的终端操作toArray()可能支持您的论点,因为它需要流中的所有元素。

某些终端操作没有。对于这些,如果不懒惰地执行流,那将是浪费。两个例子:

//example 1: print first element of 1000 after transformations
IntStream.range(0, 1000)
    .peek(System.out::println)
    .mapToObj(String::valueOf)
    .peek(System.out::println)
    .findFirst()
    .ifPresent(System.out::println);

//example 2: check if any value has an even key
boolean valid = records.
    .map(this::heavyConversion)
    .filter(this::checkWithWebService)
    .mapToInt(Record::getKey)
    .anyMatch(i -> i % 2 == 0)

第一个流将打印:

0
0
0

也就是说,中间操作将仅在一个元素上运行。这是一个重要的优化。如果不是很懒,那么所有peek()调用都必须在所有元素上运行(绝对不必要,因为您只对一个元素感兴趣)。中间操作可能会很昂贵(例如在第二个示例中)

短路端子操作(不是toArray)使这种优化成为可能。

答案 1 :(得分:5)

我有一个来自我们代码库的真实示例,因为我要简化它,所以不能完全确定您可能会喜欢它还是完全理解它...

我们有一个需要List<CustomService>的服务,我想称之为。现在,为了调用它,我要去一个数据库(比现实要简单得多)并获得一个List<DBObject>;为了从中获得List<CustomService>,需要进行一些繁重的转换。

这是我的选择,进行原位转换并传递列表。很简单,但可能不是那么理想。第二种选择,重构服务,以接受List<DBObject>Function<DBObject, CustomService>。这听起来很琐碎,但它使 laziness (以及其他功能)成为可能。该服务有时可能只需要该列表中的几个元素,或者有时是某个属性的max等。因此,我不需要对所有元素进行繁重的转换,这Stream API基于拉动的懒惰是赢家。

在Streams存在之前,我们曾经使用guava。它的Lists.transform( list, function)也很懒。

它不是流的基本特征,即使没有番石榴也可以完成,但是这样更简单。 findFirst提供的示例很好,最容易理解;这是整个惰性的问题,仅在需要时才拉动元素,它们不会从中间操作大块地传递到另一个操作,而是一次从一个阶段传递到另一个阶段。

答案 2 :(得分:4)

您是对的,map().reduce()map().collect()不会带来任何好处,但是findAny() findFirst(),{{1} },anyMatch()等。基本上,任何可以短路的操作。

答案 3 :(得分:4)

惰性对您的API用户非常有用,尤其是在Stream管道评估的最终结果可能非常大的情况下!

简单的示例是Java API本身中的Files.lines方法。如果您不想将整个文件读入内存,而只需要前N行,则只需编写:

Stream<String> stream = Files.lines(path); // lazy operation

List<String> result = stream.limit(N).collect(Collectors.toList()); // read and collect

答案 4 :(得分:1)

好问题。

假设您编写的是教科书上的完美代码,那么经过适当优化的forstream之间的性能差异并不明显(在类加载方面,流通常会稍好一些,但差异不应如此)在大多数情况下都很明显)。

请考虑以下示例。

// Some lengthy computation
private static int doStuff(int i) {
    try { Thread.sleep(1000); } catch (InterruptedException e) { }
    return i;
}

public static OptionalInt findFirstGreaterThanStream(int value) {
    return IntStream
            .of(MY_INTS)
            .map(Main::doStuff)
            .filter(x -> x > value)
            .findFirst();
}

public static OptionalInt findFirstGreaterThanFor(int value) {
    for (int i = 0; i < MY_INTS.length; i++) {
        int mapped = Main.doStuff(MY_INTS[i]);
        if(mapped > value){
            return OptionalInt.of(mapped);
        }
    }
    return OptionalInt.empty();
}

鉴于上述方法,下一次测试应显示它们在大约相同的时间执行。

public static void main(String[] args) {
    long begin;
    long end;

    begin = System.currentTimeMillis();
    System.out.println(findFirstGreaterThanStream(5));
    end = System.currentTimeMillis();
    System.out.println(end-begin);

    begin = System.currentTimeMillis();
    System.out.println(findFirstGreaterThanFor(5));
    end = System.currentTimeMillis();
    System.out.println(end-begin);
}
  

OptionalInt [8]

     

5119

     

OptionalInt [8]

     

5001

无论如何,我们大部分时间都花在doStuff方法中。假设我们要添加更多线程。

调整流方法很简单(考虑到您的操作满足并行流的先决条件)。

public static OptionalInt findFirstGreaterThanParallelStream(int value) {
    return IntStream
            .of(MY_INTS)
            .parallel()
            .map(Main::doStuff)
            .filter(x -> x > value)
            .findFirst();
}

在没有流的情况下实现相同的行为可能很棘手。

public static OptionalInt findFirstGreaterThanParallelFor(int value, Executor executor) {
    AtomicInteger counter = new AtomicInteger(0);

    CompletableFuture<OptionalInt> cf = CompletableFuture.supplyAsync(() -> {
        while(counter.get() != MY_INTS.length-1);
        return OptionalInt.empty();
    });

    for (int i = 0; i < MY_INTS.length; i++) {
        final int current = MY_INTS[i];
        executor.execute(() -> {
            int mapped = Main.doStuff(current);
            if(mapped > value){
                cf.complete(OptionalInt.of(mapped));
            } else {
                counter.incrementAndGet();
            }
        });
    }

    try {
        return cf.get();
    } catch (InterruptedException | ExecutionException e) {
        e.printStackTrace();
        return OptionalInt.empty();
    }
}

测试大约在同一时间再次执行。

public static void main(String[] args) {
    long begin;
    long end;

    begin = System.currentTimeMillis();
    System.out.println(findFirstGreaterThanParallelStream(5));
    end = System.currentTimeMillis();
    System.out.println(end-begin);

    ExecutorService executor = Executors.newFixedThreadPool(10);
    begin = System.currentTimeMillis();
    System.out.println(findFirstGreaterThanParallelFor(5678, executor));
    end = System.currentTimeMillis();
    System.out.println(end-begin);

    executor.shutdown();
    executor.awaitTermination(10, TimeUnit.SECONDS);
    executor.shutdownNow();
}
  

OptionalInt [8]

     

1004

     

OptionalInt [8]

     

1004

最后,尽管我们不会从流中获得巨大的性能收益(考虑到您用for替代方法编写了出色的多线程代码),但是代码本身往往会更易于维护。

最后的注释:

与编程语言一样,更高级别的抽象(相对于streams而言,fors)使开发人员更容易以性能为代价。我们没有从汇编语言,过程语言到面向对象语言,因为后者提供了更高的性能。我们之所以搬家是因为它提高了我们的生产力(以更低的成本开发相同的产品)。如果您能够像使用for并正确编写多线程代码那样从流中获得相同的性能,那么我想这已经是一个胜利。

答案 5 :(得分:1)

一个未被提及的有趣用例是流中任意操作的组合,它们来自代码库的不同部分,可以响应不同种类的业务或技术要求。

例如,假设您有一个应用程序,其中某些用户可以看到所有数据,但某些其他用户只能看到其中的一部分。代码中检查用户权限的部分可以简单地对正在处理的流进行过滤。

没有惰性流,代码的同一部分可能会过滤已经实现的完整集合,但是获取它可能会很昂贵,而没有真正的收获。

或者,代码的同一部分可能希望将其过滤器附加到数据源,但是现在它必须知道数据是否来自数据库,因此它可以附加一个WHERE子句或其他一些源。

对于惰性流,它是可以以任何方式实现的过滤器。施加到数据库流上的过滤器可以转换为上述WHERE子句,与过滤整个表读取所导致的内存中集合相比,性能明显提高。

因此,更好的抽象,更好的性能,更好的代码可读性和可维护性听起来对我来说是一个胜利。 :)

答案 6 :(得分:0)

非惰性实现将处理所有输入,并将输出收集到每个操作的新集合中。显然,对于无限或足够大的源来说是不可能的,否则会消耗内存,并且在减少和短路操作的情况下不必要地消耗内存,因此有很大的好处。

答案 7 :(得分:0)

检查下面的例子

Stream.of("0","0","1","2","3","4")
                .distinct()
                .peek(a->System.out.println("after distinct: "+a))
                .anyMatch("1"::equals);

如果它不是懒惰的行为您会期望所有元素首先通过 distinct 过滤。但是由于懒惰的执行,它的行为有所不同。它将流式传输计算结果所需的最少元素。

上面的例子将打印

after distinct: 0
after distinct: 1

分析工作原理:

第一个 "0" 一直到终端操作但不满足它。必须流式传输另一个元素。

第二个 "0" 通过 .distinct() 过滤,永远不会到达终端操作。

由于终端操作尚未满足,下一个元素被流式传输。

"1" 通过终端操作并满足。

不再需要流式传输元素。