Is Java 8 stream laziness useless in practice?
Asked Answered
A

9

18

I have read a lot about Java 8 streams lately, and several articles about lazy loading with Java 8 streams specifically: here and over here. I can't seem to shake the feeling that lazy loading is COMPLETELY useless (or at best, a minor syntactic convenience offering zero performance value).

Let's take this code as an example:

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

This will log in the console, because the terminal operation, in this case toArray(), is called, and our stream is lazy and executes only when the terminal operation is called. Of course I can also do this:

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

And nothing will be printed, because the map isn't happening, because I don't need the data. Until I call this:

  int[] myChangedArray = myChangedInts.toArray();

And voila, I get my mapped data, and my console logs. Except I see zero benefit to it whatsoever. I realize I can define the filter code long before I call to toArray(), and I can pass around this "not-really-filtered stream around), but so what? Is this the only benefit?

The articles seem to imply there is a performance gain associated with laziness, for example:

In the Java 8 Streams API, the intermediate operations are lazy and their internal processing model is optimized to make it being capable of processing the large amount of data with high performance.

and

Java 8 Streams API optimizes stream processing with the help of short circuiting operations. Short Circuit methods ends the stream processing as soon as their conditions are satisfied. In normal words short circuit operations, once the condition is satisfied just breaks all of the intermediate operations, lying before in the pipeline. Some of the intermediate as well as terminal operations have this behavior.

It sounds literally like breaking out of a loop, and not associated with laziness at all.

Finally, there is this perplexing line in the second article:

Lazy operations achieve efficiency. It is a way not to work on stale data. Lazy operations might be useful in the situations where input data is consumed gradually rather than having whole complete set of elements beforehand. For example consider the situations where an infinite stream has been created using Stream#generate(Supplier<T>) and the provided Supplier function is gradually receiving data from a remote server. In those kind of the situations server call will only be made at a terminal operation when it's needed.

Not working on stale data? What? How does lazy loading keep someone from working on stale data?


TLDR: Is there any benefit to lazy loading besides being able to run the filter/map/reduce/whatever operation at a later time (which offers zero performance benefit)?

If so, what's a real-world use case?

Angioma answered 7/10, 2018 at 4:46 Comment(5)
"Lazy" here just means "only do work when necessary". Doing unnecessary work is less performant than only doing necessary work. I feel like you're stuck on the fact that everything Stream can do you can also do in loops. But that's irrelevant. Nothing in the quoted documentation states that Streams are better than loops, only that the former is coded to be as efficient as possible.Indochina
The performance benefits of lazy streams are not compared to hand-crafted loops, but to eager streams. That is, streams that load the whole stream, do one operation on all elements and return the resulting complete stream. Comparison of streams versus hand-crafted loops is more a matter of style. Streams can be easier to understand once you get familiar with the standard operations on them.Ruddle
For performance-critical code compilers use loop fusion to achieve such laziness. If you have large dataset than in addition to overhead of memory allocation you might just run out of L1/2/3/...$ and start thrashing. Laziness might allow the computation to operate on local element only and prevent it. Try it even with small array of 8,000,000 integers and compare lazy vs eager evaluation of few .map/.filter in sequence - the difference should be more visible (though prints might still dominate) - at least in C/C++.Illuminative
This question is opinion-based. You don't choose streams because of their laziness. You do it because you write less and simpler code. Laziness has nothing to do here.Loophole
There is nothing about "choosing" streams in the question. The question is whether or not laziness offers anything besides delaying intermediate operation execution.Angioma
G
23

Your terminal operation, toArray(), perhaps supports your argument given that it requires all elements of the stream.

Some terminal operations don't. And for these, it would be a waste if streams weren't lazily executed. Two examples:

//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)

The first stream will print:

0
0
0

That is, intermediate operations will be run just on one element. This is an important optimization. If it weren't lazy, then all the peek() calls would have to run on all elements (absolutely unnecessary as you're interested in just one element). Intermediate operations can be expensive (such as in the second example)

Short-circuiting terminal operation (of which toArray isn't) make this optimization possible.

Gies answered 7/10, 2018 at 5:13 Comment(2)
@Angioma It's not about lazy loading, it's about lazy execution. Look at it as "executing only after an overall plan is created", rather than "executing until one element causes the exit condition to be met" (a loop will run on each element until this one causes the condition exit condition to be met, but streams will only run on the one element, skipping all other - wasteful - executions). I'll edit the answer.Gies
I appreciate the help. Given "but streams will only run on the one element, skipping all other - wasteful - executions", how can the program now which executions are wasteful if it needs to first check a condition to determine that? Sorry, I know I sound nitpicky, but I am not following.Angioma
A
9

Laziness can be very useful for the users of your API, especially when the final result of the Stream pipeline evaluation might be very large!

The simple example is the Files.lines method in the Java API itself. If you don't want to read the whole file into the memory and you only need the first N lines, then just write:

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

List<String> result = stream.limit(N).collect(Collectors.toList()); // read and collect
Asoka answered 7/10, 2018 at 9:45 Comment(0)
C
8

You're right that there won't be a benefit from map().reduce() or map().collect(), but there's a pretty obvious benefit with findAny() findFirst(), anyMatch(), allMatch(), etc. Basically, any operation that can be short-circuited.

Chopfallen answered 7/10, 2018 at 5:10 Comment(7)
I addressed this in the question, I don't understand what this has to do with lazy loading. The same thing can be done in a loop by breaking out of it once a condition is met. How is lazy loading a factor in terminating execution? Items in a collection are always processed one-by-one, with or without lazy loading.Angioma
It was never claimed that laziness gave you an advantage that you couldn’t obtain with conventional control structures. It’s still an advantage over a (hypothetical) eager stream.Process
@Angioma let's think different. you have an eager system, how would source data get passed through different intermediate states of a stream pipeline?Asymptotic
@Eugene: I'm not quite sure what you're getting at, but presumably an eager stream system would "push" elements through the pipeline (with each step exposing an accept method called by the prior step) rather than "pulling" them.Script
@Script well in my understanding if such a thing existed, elements would have to be captured in each intermediate stage of the streamAsymptotic
@Eugene: What does it mean to "capture" an element?Script
@Script if let's say you would have to filter elements, than all filtered elements would have to be captured by the filter itself, so that could be pushed (as opposed to being pulled one at a time) to the next state and so no; but I'm not very sure about thisAsymptotic
L
4

Good question.

Assuming you write textbook perfect code, the difference in performance between a properly optimized for and a stream is not noticeable (streams tend to be slightly better class loading wise, but the difference should not be noticeable in most cases).

Consider the following example.

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

Given the above methods, the next test should show they execute in about the same time.

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

Anyway, we spend most of the time in the doStuff method. Let's say we want to add more threads to the mix.

Adjusting the stream method is trivial (considering your operations meets the preconditions of parallel streams).

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

Achieving the same behavior without streams can be tricky.

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

The tests execute in about the same time again.

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

In conclusion, although we don't squeeze a big performance benefit out of streams (considering you write excellent multi-threaded code in your for alternative), the code itself tends to be more maintainable.

A (slightly off-topic) final note:

As with programming languages, higher level abstractions (streams relative to fors) make stuff easier to develop at the cost of performance. We did not move away from assembly to procedural languages to object-oriented languages because the later offered greater performance. We moved because it made us more productive (develop the same thing at a lower cost). If you are able to get the same performance out of a stream as you would do with a for and properly written multi-threaded code, I would say it's already a win.

Leda answered 7/10, 2018 at 6:5 Comment(6)
@Asymptotic From OP's question: If so, what's a real-world use case?. My example outlines a use case where you gain something out of using the streams with lazy loading (the findFirst method in my example) versus mimicking the same behavior via for loops.Leda
@Asymptotic could you please shed some light on how the answers above shows how a stream is better than a for (as opposed to plainly stating that)?Leda
@Asymptotic that is exactly what **the answer above** already shows. I know you are aware of what the answer above refers to, but the rest of us have access only to what you have written. Is the answer above referring to my answer or is it an answer posted by somebody else?Leda
@Asymptotic From my answer In conclusion, although we don't squeeze a big performance benefit out of streams (considering you write excellent multi-threaded code in your for alternative), the code itself tends to be more maintainable. <- This answers the question on why the stream is better than the loop (and my my example is supposed to point out you get about the same performance but less maintainable code)Leda
this is getting very lenghthy and weird, Ive said my opinion here, now that u have readed, I will remove my commentsAsymptotic
though u still need to explain what that class loading is all about, since personally, I did not understand itAsymptotic
A
4

I have a real example from our code base, since I'm going to simplify it, not entirely sure you might like it or fully grasp it...

We have a service that needs a List<CustomService>, I am suppose to call it. Now in order to call it, I am going to a database (much simpler than reality) and obtaining a List<DBObject>; in order to obtain a List<CustomService> from that, there are some heavy transformations that need to be done.

And here are my choices, transform in place and pass the list. Simple, yet, probably not that optimal. Second option, refactor the service, to accept a List<DBObject> and a Function<DBObject, CustomService>. And this sounds trivial, but it enables laziness (among other things). That service might sometimes need only a few elements from that List, or sometimes a max by some property, etc. - thus no need for me to do the heavy transformation for all elements, this is where Stream API pull based laziness is a winner.

Before Streams existed, we used to use guava. It had Lists.transform( list, function) that was lazy too.

It's not a fundamental feature of streams as such, it could have been done even without guava, but it's s lot simpler that way. The example here provided with findFirst is great and the simplest to understand; this is the entire point of laziness, elements are pulled only when needed, they are not passed from an intermediate operation to another in chunks, but pass from one stage to another one at a time.

Asymptotic answered 7/10, 2018 at 9:17 Comment(3)
Why can't you achieve this with a basic loop? How is the usage of the Stream API better that a basic loop in your case?Leda
@alexrolea I can achieve this in numerous forms, I choose this oneAsymptotic
I think the whole point of OPs question is the reasoning behind choosing the Stream approach (over a basic loop for example).Leda
M
2

One interesting use case that hasn't been mentioned is arbitrary composition of operations on streams, coming from different parts of the code base, responding to different sorts of business or technical requisites.

For example, say you have an application where certain users can see all the data but certain other users can only see part of it. The part of the code that checks user permissions can simply impose a filter on whatever stream is being handed about.

Without lazy streams, that same part of the code could be filtering the already realized full collection, but that may have been expensive to obtain, for no real gain.

Alternatively, that same part of the code might want to append its filter to a data source, but now it has to know whether the data comes from a database, so it can impose an additional WHERE clause, or some other source.

With lazy streams, it's a filter that can be implemented ever which way. Filters imposed on streams from the database can translate into the aforementioned WHERE clause, with obvious performance gains over filtering in-memory collections resulting from whole table reads.

So, a better abstraction, better performance, better code readability and maintainability, sounds like a win to me. :)

Malek answered 7/10, 2018 at 14:39 Comment(0)
S
1

The real benefit of lazy loading is that intermediate collections are not created with every stream operation. It saves memory. If it were an eager stream, every time you call .filter() a new filtered list would be created and loaded in memory. Lazy streams work by combining all predicates and functions from bottom going up, and then the predicates are evaluated and functions executed from top going down on each element that's on the original list. Example:

List<Integer> intList = List.of(1, 2, 3, 4, 5, 8);

intList.stream()
  .filter(integer -> {
       System.out.println("Testing " + integer + " % 2 == 0");
       return integer % 2 == 0;
  })
  .filter(integer -> {
       System.out.println("Testing " + integer + " > 2");
       return integer > 2;
  })
  .map(Object::toString)
  .forEach(string -> result.add(string));

Will print:

Testing 1 % 2 == 0
Testing 2 % 2 == 0
Testing 2 > 2
Testing 3 % 2 == 0
Testing 4 % 2 == 0
Testing 4 > 2
Testing 5 % 2 == 0
Testing 8 % 2 == 0
Testing 8 > 2

Notice that the predicates are evaluated from top to bottom for each element in the original list. Any element that fails a predicate does not get carried over to the evaluation of subsequent predicates. That's why "1" doesn't get tested to see if it's > 2. If this then is the mechanism, we hence do not need to create intermediate collections as would be the case with eager streams. In this example, an eager stream would create a list after every call to .filter(), and carry forward that list to the next .filter() then evaluate the predicate on that filter. In fact we would end up with three intermediate lists for this particular example, since .map() would also create its own list. Finally we would do .forEach() on the list that .map() created.

Swound answered 21/3 at 6:45 Comment(0)
S
0

Non-lazy implementation would process all input and collect output to a new collection on each operation. Obviously, it's impossible for unlimited or large enough sources, memory-consuming otherwise, and unnecessarily memory-consuming in case of reducing and short-circuiting operations, so there are great benefits.

Sickener answered 7/10, 2018 at 16:41 Comment(0)
P
0

Check the following example

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

If it was not behaving as lazy you would expect that all elements would pass through the distinct filtering first. But because of lazy execution it behaves differently. It will stream the minimum amount of elements needed to calculate the result.

The above example will print

after distinct: 0
after distinct: 1

How it works analytically:

First "0" goes until the terminal operation but does not satisfy it. Another element must be streamed.

Second "0" is filtered through .distinct() and never reaches terminal operation.

Since the terminal operation is not satisfied yet, next element is streamed.

"1" goes through terminal operation and satisfies it.

No more elements need to be streamed.

Punctual answered 25/7, 2021 at 8:59 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.