๐Ÿš€ HickleSecLab

Why are Java Streams once-off

Why are Java Streams once-off

๐Ÿ“… | ๐Ÿ“‚ Category: Java

Java Streams, a powerful feature introduced in Java 8, provide a functional and declarative way to process collections of data. However, a common point of confusion for developers, especially those new to Streams, is that Java Streams are once-off. This means you can only use a Stream once; attempting to reuse it will result in an IllegalStateException. This behavior is by design and stems from the way Streams are implemented and optimized for performance. In this article, we will delve into the reasons behind this “once-off” characteristic, explore its implications, and discuss best practices for working with Java Streams effectively to avoid common pitfalls. Understanding this fundamental aspect is crucial for writing efficient and bug-free Java code when dealing with data processing pipelines.

Understanding the Statefulness of Stream Operations

The primary reason why Java Streams are once-off lies in their inherent design focused on statefulness and optimization. Stream operations can be categorized into intermediate and terminal operations. Intermediate operations transform the stream (e.g., filter, map), while terminal operations consume the stream and produce a result (e.g., collect, forEach). Terminal operations trigger the actual processing of the stream elements. Once a terminal operation is executed, the stream is considered to be consumed, and its state is no longer valid for further operations. This design choice allows for optimizations such as short-circuiting and lazy evaluation, which significantly improve performance, especially when dealing with large datasets. A Stream is not a data structure that holds data; instead, it provides a pipeline to process data from a source, meaning once the pipeline is completed, the Stream cannot be re-run because the data has already been processed.

Consider a scenario where you want to find the first even number in a list and then print it. You might initially write code that uses the same stream twice: once to find the first even number and another time to print it. However, this will lead to an IllegalStateException because the stream can only be consumed once. To avoid this, you need to refactor your code to either perform both operations within the same stream pipeline or create separate streams for each operation. This constraint enforces a functional programming style, encouraging developers to write pure functions and avoid side effects within stream operations. According to Oracle documentation, the purpose of a Stream is to operate as a “pipeline” that is consumed upon execution of a terminal operation. Oracle Documentation

Furthermore, the “once-off” nature of Java Streams aligns with the principle of immutability. By ensuring that a stream can only be consumed once, the risk of accidentally modifying the underlying data source is minimized. This helps maintain data integrity and prevents unexpected behavior in concurrent or multi-threaded environments. This behavior is also related to the concept of “side-effects”. Side effects within a stream are discouraged because they can lead to unpredictable outcomes when the stream is re-run or modified. Limiting the stream to a single use significantly reduces the potential for these issues.

The Role of Terminal Operations

Terminal operations play a crucial role in enforcing the “once-off” behavior of Java Streams. These operations are designed to consume the stream and produce a result, effectively closing the stream pipeline. Examples of terminal operations include collect, forEach, reduce, count, min, max, and anyMatch. Once one of these operations is invoked on a stream, the stream is considered to be “terminated” and cannot be reused. Attempting to perform any further operations on a terminated stream will result in an IllegalStateException. Understanding the difference between intermediate and terminal operations is essential for effectively working with Streams. Intermediate operations are lazy; they don’t execute until a terminal operation is encountered.

For instance, if you use stream.filter(x -> x > 5).forEach(System.out::println), the forEach operation is terminal. Once it executes, the stream is closed. If you later try to do stream.map(x -> x 2), you’ll get the exception. The stream pipeline is evaluated only when a terminal operation is invoked, allowing for optimizations such as short-circuiting. Short-circuiting enables the stream to stop processing elements as soon as the result is determined, saving computational resources. For example, anyMatch will stop processing as soon as it finds an element that satisfies the condition.

Consider the following example:

  1. Create a stream from a collection.
  2. Apply one or more intermediate operations (e.g., filter, map).
  3. Invoke a terminal operation (e.g., forEach, collect).
  4. The stream is now consumed and cannot be reused.

If you need to perform multiple operations on the same data, you should either combine them into a single stream pipeline or create separate streams for each operation. This approach ensures that you adhere to the “once-off” principle and avoid runtime errors. As stated by Brian Goetz, Java Language Architect at Oracle, “Streams are designed to be consumed, not reused.”

Best Practices for Working with Java Streams

To effectively work with Java Streams and avoid the IllegalStateException caused by reusing a stream, it’s crucial to follow some best practices. First and foremost, always remember that Streams are designed to be consumed only once. Plan your stream pipelines accordingly, ensuring that all necessary operations are performed within a single stream pipeline whenever possible. If you need to perform multiple operations on the same data, consider creating separate streams for each operation or caching the results of the first stream for later use. Caching should be used judiciously, particularly if you are concerned about memory use and the size of the stream. The stream framework uses internal flags to track the state of a stream, and once a terminal operation is complete, the flag will prevent any subsequent operations.

Here are some key guidelines to keep in mind:

  • Combine operations: Try to combine multiple operations into a single stream pipeline to avoid creating multiple streams.
  • Use Supplier for stream creation: If you need to reuse the same data source multiple times, use a Supplier to create a new stream each time.

For example, instead of:

Stream<string> stream = list.stream().filter(x -> x.startsWith("A")); stream.forEach(System.out::println); stream.count(); // This will throw an IllegalStateException </string>

Use:

list.stream().filter(x -> x.startsWith("A")).forEach(System.out::println); long count = list.stream().filter(x -> x.startsWith("A")).count(); 

This approach ensures that each operation is performed on a new stream, avoiding the IllegalStateException. Consider using a Supplier if the stream creation is complex or expensive. A Supplier is a functional interface that can provide a new stream instance each time it’s called. For example: Supplier> streamSupplier = () -> list.stream().filter(x -> x.startsWith(“A”)); Then you can use streamSupplier.get() to obtain a fresh stream each time you need one. This approach is particularly useful when dealing with resources that need to be re-acquired for each stream operation. Baeldung Java Streams Tutorial

Alternatives for Reusing Stream Operations

While Java Streams are once-off, there are alternative approaches you can take to achieve similar results when you need to perform multiple operations on the same data. One common technique is to collect the results of the first stream into a collection and then create a new stream from that collection for subsequent operations. This allows you to reuse the processed data without violating the “once-off” principle. Another approach is to use intermediate collections to store the results of intermediate operations, allowing you to perform further processing on those collections without creating new streams from the original data source. Ensure to consider the performance implications when choosing between these techniques, as creating intermediate collections can have overhead.

For example, instead of trying to reuse a stream:

Stream<integer> stream = IntStream.range(1, 10).boxed(); List<integer> evenNumbers = stream.filter(x -> x % 2 == 0).collect(Collectors.toList()); long count = stream.count(); // IllegalStateException </integer></integer>

You can collect the results and create a new stream:

Stream<integer> stream = IntStream.range(1, 10).boxed(); List<integer> evenNumbers = stream.filter(x -> x % 2 == 0).collect(Collectors.toList()); long count = evenNumbers.stream().count(); </integer></integer>

This approach ensures that each stream operation is performed on a separate stream instance, avoiding the IllegalStateException. Another strategy is to utilize libraries that offer more flexible stream-like operations, such as RxJava or Reactor, which provide reactive streams that can be reused and transformed multiple times. These libraries are particularly useful for handling asynchronous data streams and complex data processing pipelines. However, they come with their own complexities and learning curve, so it’s important to carefully evaluate whether they are the right fit for your specific use case. The choice of approach often depends on the complexity of the operations, the size of the data, and the performance requirements of the application. Oracle Java 8 Streams

Infographic here
FAQ about Java Streams ----------------------
Why do Java Streams throw an IllegalStateException when reused?
Java Streams are designed to be consumed only once because they are stateful and optimized for performance through lazy evaluation and short-circuiting. Once a terminal operation is executed, the stream is considered consumed, and its state is no longer valid for further operations.
What are intermediate and terminal operations in Java Streams?
Intermediate operations transform the stream (e.g., filter, map), while terminal operations consume the stream and produce a result (e.g., collect, forEach). Terminal operations trigger the actual processing of the stream elements.
How can I perform multiple operations on the same data if Java Streams are once-off?
You can either combine all necessary operations into a single stream pipeline or create separate streams for each operation using the same data source. Alternatively, you can collect the results of the first stream into a collection and create a new stream from that collection for subsequent operations.
What is a Supplier and how can it help with reusing stream operations?
A Supplier is a functional interface that can provide a new stream instance each time it's called. It's useful when you need to reuse the same data source multiple times without reusing the same stream instance. For example: Supplier> streamSupplier = () -> list.stream().filter(x -> x.startsWith("A"));
In summary, the "once-off" nature of **Java Streams** might seem restrictive at first, but it's a deliberate design choice that enables significant performance optimizations and promotes functional programming principles. By understanding the reasons behind this behavior and following the best practices outlined above, you can effectively work with Streams and avoid common pitfalls. Remember to combine operations where possible, use Supplier for stream creation when needed, and consider alternative approaches for reusing stream operations when necessary. Explore [advanced stream techniques](https://courthousezoological.com/n7sqp6kh?key=e6dd02bc5dbf461b97a9da08df84d31c) to further enhance your Java programming skills. Experiment with different stream operations and scenarios to solidify your understanding and become a proficient Java Stream user. Your code will be more efficient, easier to read, and less prone to errors.

Question & Answer :
Unlike C#’s IEnumerable, where an execution pipeline can be executed as many times as we want, in Java a stream can be ‘iterated’ only once.

Any call to a terminal operation closes the stream, rendering it unusable. This ‘feature’ takes away a lot of power.

I imagine the reason for this is not technical. What were the design considerations behind this strange restriction?

Edit: in order to demonstrate what I am talking about, consider the following implementation of Quick-Sort in C#:

IEnumerable<int> QuickSort(IEnumerable<int> ints) { if (!ints.Any()) { return Enumerable.Empty<int>(); } int pivot = ints.First(); IEnumerable<int> lt = ints.Where(i => i < pivot); IEnumerable<int> gt = ints.Where(i => i > pivot); return QuickSort(lt).Concat(new int[] { pivot }).Concat(QuickSort(gt)); } 

Now to be sure, I am not advocating that this is a good implementation of quick sort! It is however great example of the expressive power of lambda expression combined with stream operation.

And it can’t be done in Java! I can’t even ask a stream whether it is empty without rendering it unusable.

I have some recollections from the early design of the Streams API that might shed some light on the design rationale.

Back in 2012, we were adding lambdas to the language, and we wanted a collections-oriented or “bulk data” set of operations, programmed using lambdas, that would facilitate parallelism. The idea of lazily chaining operations together was well established by this point. We also didn’t want the intermediate operations to store results.

The main issues we needed to decide were what the objects in the chain looked like in the API and how they hooked up to data sources. The sources were often collections, but we also wanted to support data coming from a file or the network, or data generated on-the-fly, e.g., from a random number generator.

There were many influences of existing work on the design. Among the more influential were Google’s Guava library and the Scala collections library. (If anybody is surprised about the influence from Guava, note that Kevin Bourrillion, Guava lead developer, was on the JSR-335 Lambda expert group.) On Scala collections, we found this talk by Martin Odersky to be of particular interest: Future-Proofing Scala Collections: from Mutable to Persistent to Parallel. (Stanford EE380, 2011 June 1.)

Our prototype design at the time was based around Iterable. The familiar operations filter, map, and so forth were extension (default) methods on Iterable. Calling one added an operation to the chain and returned another Iterable. A terminal operation like count would call iterator() up the chain to the source, and the operations were implemented within each stage’s Iterator.

Since these are Iterables, you can call the iterator() method more than once. What should happen then?

If the source is a collection, this mostly works fine. Collections are Iterable, and each call to iterator() produces a distinct Iterator instance that is independent of any other active instances, and each traverses the collection independently. Great.

Now what if the source is one-shot, like reading lines from a file? Maybe the first Iterator should get all the values but the second and subsequent ones should be empty. Maybe the values should be interleaved among the Iterators. Or maybe each Iterator should get all the same values. Then, what if you have two iterators and one gets farther ahead of the other? Somebody will have to buffer up the values in the second Iterator until they’re read. Worse, what if you get one Iterator and read all the values, and only then get a second Iterator. Where do the values come from now? Is there a requirement for them all to be buffered up just in case somebody wants a second Iterator?

Clearly, allowing multiple Iterators over a one-shot source raises a lot of questions. We didn’t have good answers for them. We wanted consistent, predictable behavior for what happens if you call iterator() twice. This pushed us toward disallowing multiple traversals, making the pipelines one-shot.

We also observed others bumping into these issues. In the JDK, most Iterables are collections or collection-like objects, which allow multiple traversal. It isn’t specified anywhere, but there seemed to be an unwritten expectation that Iterables allow multiple traversal. A notable exception is the NIO DirectoryStream interface. Its specification includes this interesting warning:

While DirectoryStream extends Iterable, it is not a general-purpose Iterable as it supports only a single Iterator; invoking the iterator method to obtain a second or subsequent iterator throws IllegalStateException.

[bold in original]

This seemed unusual and unpleasant enough that we didn’t want to create a whole bunch of new Iterables that might be once-only. This pushed us away from using Iterable.

About this time, an article by Bruce Eckel appeared that described a spot of trouble he’d had with Scala. He’d written this code:

// Scala val lines = fromString(data).getLines val registrants = lines.map(Registrant) registrants.foreach(println) registrants.foreach(println) 

It’s pretty straightforward. It parses lines of text into Registrant objects and prints them out twice. Except that it actually only prints them out once. It turns out that he thought that registrants was a collection, when in fact it’s an iterator. The second call to foreach encounters an empty iterator, from which all values have been exhausted, so it prints nothing.

This kind of experience convinced us that it was very important to have clearly predictable results if multiple traversal is attempted. It also highlighted the importance of distinguishing between lazy pipeline-like structures from actual collections that store data. This in turn drove the separation of the lazy pipeline operations into the new Stream interface and keeping only eager, mutative operations directly on Collections. Brian Goetz has explained the rationale for that.

What about allowing multiple traversal for collection-based pipelines but disallowing it for non-collection-based pipelines? It’s inconsistent, but it’s sensible. If you’re reading values from the network, of course you can’t traverse them again. If you want to traverse them multiple times, you have to pull them into a collection explicitly.

But let’s explore allowing multiple traversal from collections-based pipelines. Let’s say you did this:

Iterable<?> it = source.filter(...).map(...).filter(...).map(...); it.into(dest1); it.into(dest2); 

(The into operation is now spelled collect(toList()).)

If source is a collection, then the first into() call will create a chain of Iterators back to the source, execute the pipeline operations, and send the results into the destination. The second call to into() will create another chain of Iterators, and execute the pipeline operations again. This isn’t obviously wrong but it does have the effect of performing all the filter and map operations a second time for each element. I think many programmers would have been surprised by this behavior.

As I mentioned above, we had been talking to the Guava developers. One of the cool things they have is an Idea Graveyard where they describe features that they decided not to implement along with the reasons. The idea of lazy collections sounds pretty cool, but here’s what they have to say about it. Consider a List.filter() operation that returns a List:

The biggest concern here is that too many operations become expensive, linear-time propositions. If you want to filter a list and get a list back, and not just a Collection or an Iterable, you can use ImmutableList.copyOf(Iterables.filter(list, predicate)), which “states up front” what it’s doing and how expensive it is.

To take a specific example, what’s the cost of get(0) or size() on a List? For commonly used classes like ArrayList, they’re O(1). But if you call one of these on a lazily-filtered list, it has to run the filter over the backing list, and all of a sudden these operations are O(n). Worse, it has to traverse the backing list on every operation.

This seemed to us to be too much laziness. It’s one thing to set up some operations and defer actual execution until you so “Go”. It’s another to set things up in such a way that hides a potentially large amount of recomputation.

In proposing to disallow non-linear or “no-reuse” streams, Paul Sandoz described the potential consequences of allowing them as giving rise to “unexpected or confusing results.” He also mentioned that parallel execution would make things even trickier. Finally, I’d add that a pipeline operation with side effects would lead to difficult and obscure bugs if the operation were unexpectedly executed multiple times, or at least a different number of times than the programmer expected. (But Java programmers don’t write lambda expressions with side effects, do they? DO THEY??)

So that’s the basic rationale for the Java 8 Streams API design that allows one-shot traversal and that requires a strictly linear (no branching) pipeline. It provides consistent behavior across multiple different stream sources, it clearly separates lazy from eager operations, and it provides a straightforward execution model.


With regard to IEnumerable, I am far from an expert on C# and .NET, so I would appreciate being corrected (gently) if I draw any incorrect conclusions. It does appear, however, that IEnumerable permits multiple traversal to behave differently with different sources; and it permits a branching structure of nested IEnumerable operations, which may result in some significant recomputation. While I appreciate that different systems make different tradeoffs, these are two characteristics that we sought to avoid in the design of the Java 8 Streams API.

The quicksort example given by the OP is interesting, puzzling, and I’m sorry to say, somewhat horrifying. Calling QuickSort takes an IEnumerable and returns an IEnumerable, so no sorting is actually done until the final IEnumerable is traversed. What the call seems to do, though, is build up a tree structure of IEnumerables that reflects the partitioning that quicksort would do, without actually doing it. (This is lazy computation, after all.) If the source has N elements, the tree will be N elements wide at its widest, and it will be lg(N) levels deep.

It seems to me – and once again, I’m not a C# or .NET expert – that this will cause certain innocuous-looking calls, such as pivot selection via ints.First(), to be more expensive than they look. At the first level, of course, it’s O(1). But consider a partition deep in the tree, at the right-hand edge. To compute the first element of this partition, the entire source has to be traversed, an O(N) operation. But since the partitions above are lazy, they must be recomputed, requiring O(lg N) comparisons. So selecting the pivot would be an O(N lg N) operation, which is as expensive as an entire sort.

But we don’t actually sort until we traverse the returned IEnumerable. In the standard quicksort algorithm, each level of partitioning doubles the number of partitions. Each partition is only half the size, so each level remains at O(N) complexity. The tree of partitions is O(lg N) high, so the total work is O(N lg N).

With the tree of lazy IEnumerables, at the bottom of the tree there are N partitions. Computing each partition requires a traversal of N elements, each of which requires lg(N) comparisons up the tree. To compute all the partitions at the bottom of the tree, then, requires O(N^2 lg N) comparisons.

(Is this right? I can hardly believe this. Somebody please check this for me.)

In any case, it is indeed cool that IEnumerable can be used this way to build up complicated structures of computation. But if it does increase the computational complexity as much as I think it does, it would seem that programming this way is something that should be avoided unless one is extremely careful.