Skip to content

Java 8 Streams: Pipelines, Laziness, and Parallel Processing

Streams are the final and most powerful topic in the Java Collections series. They matter both in interviews and in real day to day code. Almost every modern Java codebase uses streams constantly. Understanding them deeply means you will write cleaner, faster, and more expressive code. So let us build this topic from the ground up, starting with the mental picture that makes everything else click.

What Is a Stream?

Imagine a physical pipeline. Water enters one end, passes through a series of filters or transformations along the way, and comes out the other end changed or counted or collected. A Java Stream is exactly that idea applied to data.

A stream is a pipeline through which your data elements travel. As each element moves through the pipeline, it can be filtered, transformed, sorted, or counted. The original data sitting in your list or array is never touched. The stream creates a separate processing channel, performs all the work there, and hands you back a result without disturbing the source.

This is fundamentally different from using a for loop. With a for loop you write instructions about how to iterate. With a stream you describe what you want done, and the stream figures out the how. That shift from imperative to declarative style is what makes streams so powerful and why Java introduced them in version 8.

Streams are especially useful for bulk data processing. If you have ten or fifteen elements, you might not notice much difference between a for loop and a stream. But when you have millions of records and want to filter, transform, and collect results in the shortest possible time, streams unlock parallel execution across multiple CPU cores in a single method call.

The Three Steps Every Stream Must Follow

Every stream operation in Java follows exactly three sequential steps. You cannot skip the first, you may skip the second, but you must have the third.

Step one is stream creation. You have data living somewhere, maybe in a List, a Set, an array, or some other source. Before you can do anything with streams, you open a stream from that data source. Think of this as placing your data at the entrance of the pipeline.

Step two is intermediate operations. Once the stream is open, you can attach zero or more intermediate operations. These are the filters, transformations, and sorters that live inside the pipeline. Zero or more means you do not have to use any of them. You can open a stream and go straight to step three. But usually you will want at least one.

The defining characteristic of intermediate operations is that each one accepts a stream and outputs another stream. Nothing is changed into a final result here. One stream goes in, one stream comes out, possibly with different elements or in a different order. This is why they are called intermediate: they sit in the middle, transforming without finalizing.

Step three is the terminal operation. This is the operation that actually does the work. When you call a terminal operation, the entire pipeline wakes up, elements start flowing from the source through each intermediate operation in sequence, and eventually a final result is produced. The terminal operation closes the stream permanently. After it runs, that stream instance is gone. You cannot reuse it.

Here is what the three steps look like in code compared to the traditional approach:

java
import java.util.List;

public class StreamVsLoop {
    public static void main(String[] args) {
        List<Integer> salaries = List.of(3000, 4100, 9000, 1000, 3500);

        // Traditional loop approach
        int count1 = 0;
        for (int salary : salaries) {
            if (salary > 3000) count1++;
        }
        System.out.println("Loop count: " + count1); // 3

        // Stream approach: three clear steps
        long count2 = salaries.stream()           // Step 1: create stream
                              .filter(s -> s > 3000)  // Step 2: intermediate op
                              .count();                // Step 3: terminal op
        System.out.println("Stream count: " + count2); // 3
    }
}

Both approaches print 3. But the stream version reads almost like plain English: take the salaries, keep only those greater than 3000, and count them.

Five Ways to Create a Stream

Java gives you five main ways to reach step one and open a stream. Knowing all five matters for interviews.

From a Collection. Any class that implements the Collection interface exposes a .stream() method. This is the most common way you will create streams in practice because most data lives in lists and sets.

From an Array. If your data is in an array instead of a collection, use Arrays.stream(yourArray). This works for both object arrays and primitive arrays. When you pass a primitive int array, you get back an IntStream rather than a Stream&lt;Integer&gt;. That distinction matters and we will come back to it.

From a static factory method. The Stream class has a static method called of that accepts a variable number of values directly. You pass in the elements you want, and Java builds a stream from them on the spot.

From a stream builder. This is useful when you want to programmatically add elements one by one before building the final stream. You call Stream.builder() to get a builder object, call .add() as many times as you want, then call .build() to produce the stream.

From Stream.iterate() for infinite sequences. This is a special creation method that generates an infinite sequence of values. You provide a starting value and a function that says how to compute the next value from the current one. Because this sequence is infinite, you must always pair it with a .limit() call to tell the stream when to stop.

java
import java.util.Arrays;
import java.util.List;
import java.util.stream.IntStream;
import java.util.stream.Stream;

public class StreamCreation {
    public static void main(String[] args) {
        // 1. From a Collection
        List<String> names = List.of("Alice", "Bob", "Carol");
        Stream<String> fromList = names.stream();

        // 2. From an Array (primitive array gives IntStream)
        int[] numbers = {1, 2, 3, 4, 5};
        IntStream fromArray = Arrays.stream(numbers);

        // 3. From Stream.of() static factory
        Stream<String> fromOf = Stream.of("Alpha", "Beta", "Gamma");

        // 4. From a Stream Builder
        Stream.Builder<Integer> builder = Stream.builder();
        builder.add(10).add(20).add(30);
        Stream<Integer> fromBuilder = builder.build();

        // 5. From Stream.iterate() -- MUST use limit() to stop infinite generation
        // Starts at 1000, adds 5000 each time, stops after 5 elements
        Stream.iterate(1000, n -> n + 5000)
              .limit(5)
              .forEach(System.out::println);
        // Prints: 1000, 6000, 11000, 16000, 21000
    }
}

Intermediate Operations: The Pipeline Stages

Intermediate operations are the heart of what makes streams expressive. Each one takes the stream coming from the previous stage, does something to the elements, and passes a new stream forward. You can chain as many as you need.

Before we walk through each operation, understand one crucial fact: intermediate operations split into two categories. Stateless operations can process each element independently without knowing anything about the other elements. Stateful operations need to see some or all of the other elements before they can act. This distinction has deep implications for how streams execute, as you will see in the laziness section.

filter

Filter is the most fundamental intermediate operation. You give it a predicate, which is a function that takes one element and returns true or false. Elements that return true pass through to the next stage. Elements that return false are dropped from the stream.

java
import java.util.List;
import java.util.stream.Collectors;

public class FilterDemo {
    public static void main(String[] args) {
        List<String> words = List.of("hello", "everybody", "how", "are", "you", "doing");

        // Keep only words with 3 or fewer characters
        List<String> shortWords = words.stream()
            .filter(word -> word.length() <= 3)
            .collect(Collectors.toList());

        System.out.println(shortWords); // [how, are, you]
    }
}

Filter uses the Predicate functional interface internally. The predicate's single abstract method accepts one element and returns a boolean. If the boolean is true, filter passes the element downstream. If false, the element is discarded.

map

Where filter decides which elements to keep, map decides how to transform each element. It applies a function to every element and produces a new element, which may even be of a different type. The transformed elements flow into the next stream stage.

java
import java.util.List;
import java.util.stream.Collectors;

public class MapDemo {
    public static void main(String[] args) {
        List<String> words = List.of("Hello", "EVERYBODY", "How", "ARE", "You");

        // Transform every element to lowercase
        List<String> lower = words.stream()
            .map(word -> word.toLowerCase())
            .collect(Collectors.toList());

        System.out.println(lower); // [hello, everybody, how, are, you]
    }
}

Map uses the Function functional interface. The function receives one element and must return one element. The type of the output can be completely different from the type of the input. For example, you could map a Stream&lt;String&gt; to a Stream&lt;Integer&gt; by mapping each string to its length.

flatMap

FlatMap solves a specific problem: what do you do when each element is itself a collection? If you have a List&lt;List&lt;String&gt;&gt; and you want a flat List&lt;String&gt;, you need flatMap.

The way to think about it is this: map replaces each element with one new element. FlatMap replaces each element with zero or more elements by expanding an inner collection into the outer stream.

java
import java.util.List;
import java.util.stream.Collectors;

public class FlatMapDemo {
    public static void main(String[] args) {
        List<List<String>> sentences = List.of(
            List.of("I", "love", "Java"),
            List.of("concepts", "are", "clear"),
            List.of("it's", "very", "easy")
        );

        // Flatten List<List<String>> into a single List<String>
        List<String> words = sentences.stream()
            .flatMap(sentence -> sentence.stream())
            .collect(Collectors.toList());

        System.out.println(words);
        // [I, love, Java, concepts, are, clear, it's, very, easy]

        // You can even chain more intermediate ops inside flatMap
        List<String> lowerWords = sentences.stream()
            .flatMap(sentence -> sentence.stream().map(String::toLowerCase))
            .collect(Collectors.toList());

        System.out.println(lowerWords);
        // [i, love, java, concepts, are, clear, it's, very, easy]
    }
}

FlatMap accepts the same Function interface as map. The difference is that the function must return a Stream rather than a single element. FlatMap then takes all of those inner streams and merges them into one flat stream.

distinct

Distinct removes duplicates. Internally it uses equals() and hashCode() to determine whether two elements are the same. Elements that have already appeared in the stream are dropped when they appear again.

java
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class DistinctDemo {
    public static void main(String[] args) {
        int[] data = {1, 4, 2, 4, 7, 1, 2, 9};

        // Remove duplicates from an int array
        List<Integer> unique = Arrays.stream(data)
            .distinct()
            .boxed()
            .collect(Collectors.toList());

        System.out.println(unique); // [1, 4, 2, 7, 9]
    }
}

sorted

Sorted buffers the entire stream and then emits elements in order. You can call it with no arguments to use natural ordering, or pass a Comparator to sort in any custom order.

java
import java.util.List;
import java.util.stream.Collectors;

public class SortedDemo {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(5, 2, 8, 1, 9, 3);

        // Natural ascending order
        List<Integer> ascending = numbers.stream()
            .sorted()
            .collect(Collectors.toList());
        System.out.println(ascending); // [1, 2, 3, 5, 8, 9]

        // Descending order using Comparator
        List<Integer> descending = numbers.stream()
            .sorted((a, b) -> b - a)
            .collect(Collectors.toList());
        System.out.println(descending); // [9, 8, 5, 3, 2, 1]
    }
}

This operation is stateful. It cannot emit its first element until it has seen every element in the stream. This has a significant impact on how element flow works, which the laziness section will demonstrate concretely.

peek

Peek is the debugging companion of the stream world. It accepts a Consumer, which is a function that receives an element but returns nothing. Peek lets you look at each element as it passes through without changing anything about the stream.

java
import java.util.List;
import java.util.stream.Collectors;

public class PeekDemo {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);

        List<Integer> result = numbers.stream()
            .filter(n -> n > 2)
            .peek(n -> System.out.println("After filter: " + n))
            .map(n -> n * 10)
            .peek(n -> System.out.println("After map: " + n))
            .collect(Collectors.toList());

        System.out.println("Final: " + result);
    }
}

Peek is purely for observing. Because it takes a Consumer that returns void, it cannot modify the element flowing through. Use it to log values during development and remove it in production.

limit

Limit truncates the stream to at most a specified number of elements. Once that many elements have passed through, the stream stops processing, even if there are more elements in the source. This makes limit a short circuiting operation: it can cause the entire pipeline to halt early.

java
import java.util.List;
import java.util.stream.Collectors;

public class LimitDemo {
    public static void main(String[] args) {
        List<Integer> data = List.of(2, 1, 3, 4, 6, 8, 9);

        // Keep only the first 3 elements
        List<Integer> first3 = data.stream()
            .limit(3)
            .collect(Collectors.toList());

        System.out.println(first3); // [2, 1, 3]
    }
}

Limit is especially important when paired with Stream.iterate(). Without limit, an infinite stream never terminates.

skip

Skip is the opposite of limit. It discards the first n elements of the stream and lets everything after them pass through.

java
import java.util.List;
import java.util.stream.Collectors;

public class SkipDemo {
    public static void main(String[] args) {
        List<Integer> data = List.of(2, 1, 3, 4, 6);

        // Drop the first 3 elements
        List<Integer> afterSkip = data.stream()
            .skip(3)
            .collect(Collectors.toList());

        System.out.println(afterSkip); // [4, 6]
    }
}

mapToInt, mapToLong, mapToDouble

These three operations convert a regular Stream&lt;T&gt; into a specialized primitive stream. Working with primitive streams avoids the overhead of boxing and unboxing between int and Integer. Primitive streams also expose additional operations like sum(), average(), and range() that are not available on object streams.

Consider a list of strings that represent numbers. To do arithmetic on them you need to convert to actual numbers first:

java
import java.util.List;

public class MapToIntDemo {
    public static void main(String[] args) {
        List<String> stringNumbers = List.of("2", "1", "4", "7", "10");

        // Convert strings to ints, then sum them
        int total = stringNumbers.stream()
            .mapToInt(s -> Integer.parseInt(s))
            .sum(); // sum() is only available on IntStream, not Stream<Integer>

        System.out.println("Total: " + total); // 24

        // You can also create an IntStream directly from a primitive int array
        int[] primitiveArray = {10, 20, 30};
        int arraySum = java.util.Arrays.stream(primitiveArray).sum();
        System.out.println("Array sum: " + arraySum); // 60
    }
}

Why Intermediate Operations Are Called Lazy

This is one of the most frequently asked interview questions about streams. Understanding laziness separates someone who has heard about streams from someone who truly understands them.

Here is the key insight: when you call intermediate operations, absolutely nothing happens. No element is filtered, no element is mapped, no element is sorted. The stream simply records your intentions. It builds up a description of the pipeline you want.

The work only starts when you call a terminal operation. That terminal operation is the trigger. It says "okay, now actually do everything."

Prove it to yourself with this code:

java
import java.util.List;
import java.util.stream.Stream;

public class LazyProof {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(2, 1, 4, 7, 10);

        System.out.println("About to define the pipeline...");

        // Define the pipeline with intermediate operations
        Stream<Integer> pipeline = numbers.stream()
            .filter(n -> {
                System.out.println("Filter evaluating: " + n);
                return n >= 3;
            })
            .peek(n -> System.out.println("Peek saw: " + n));

        System.out.println("Pipeline defined. Nothing has printed above from filter or peek.");
        System.out.println("Now calling terminal operation...");

        // NOW the work begins
        long count = pipeline.count();
        System.out.println("Count: " + count);
    }
}

When you run this, the lines "Filter evaluating:" and "Peek saw:" do not appear until after you print "Now calling terminal operation." The terminal operation is what pulls elements through the pipeline.

This laziness is not just a technical curiosity. It is a real performance benefit. If you chain a filter that eliminates 90 percent of elements before a map operation, the map only runs on the 10 percent that survived. The eliminated elements never reach the map at all.

How Elements Actually Flow Through the Pipeline

Here is where streams surprise most people. The intuitive assumption is that a stream would work stage by stage: first apply filter to all elements to produce a filtered stream, then apply map to all filtered elements to produce a mapped stream, and so on.

That is not what happens. Streams process element by element.

When the terminal operation fires, the stream picks up the first element from the source and pushes it as far down the pipeline as it can go before picking up the second element. If the first element gets filtered out, the second element starts its journey. If the first element passes all stateless operations, it keeps going all the way until it hits a stateful operation that needs to wait for other elements.

The best way to see this is with a pipeline that includes peek at multiple stages and sorted in the middle:

java
import java.util.List;
import java.util.stream.Collectors;

public class ElementByElementFlow {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(2, 1, 4, 7, 10);

        List<Integer> result = numbers.stream()
            .filter(n -> {
                System.out.println("filter: " + n);
                return n >= 3;
            })
            .peek(n -> System.out.println("peek after filter: " + n))
            .map(n -> {
                System.out.println("map negating: " + n);
                return -n;
            })
            .peek(n -> System.out.println("peek after map: " + n))
            .sorted()   // stateful barrier -- needs all elements before it can proceed
            .peek(n -> System.out.println("peek after sort: " + n))
            .collect(Collectors.toList());

        System.out.println("Final result: " + result);
    }
}

The actual output looks like this:

filter: 2           <- 2 fails filter, drops out immediately
filter: 1           <- 1 fails filter, drops out immediately
filter: 4           <- 4 passes filter
peek after filter: 4
map negating: 4     <- 4 goes straight to map without waiting for 7 or 10
peek after map: -4
filter: 7           <- now 7 gets its turn
peek after filter: 7
map negating: 7
peek after map: -7
filter: 10          <- now 10 gets its turn
peek after filter: 10
map negating: 10
peek after map: -10
peek after sort: -10  <- sorted sees all three, emits them in order
peek after sort: -7
peek after sort: -4
Final result: [-10, -7, -4]

Notice the pattern. Element 4 goes all the way through filter, peek, and map before element 7 even starts. But sorted is different. It collected all three values, minus 4, minus 7, and minus 10, before emitting anything. Sorted is a stateful barrier: it must see all elements before it can determine the correct order.

This element by element behavior has a huge practical benefit. Consider a stream of one million elements with a filter that keeps only ten, followed by a map and a terminal operation that stops after finding one match. The filter, map, and terminal operation all process the same element together. As soon as the terminal finds its match, the entire pipeline stops. The remaining 999 thousand plus elements are never even examined.

Stateless vs Stateful Operations

The distinction between stateless and stateful intermediate operations explains the behavior you just saw.

Stateless operations like filter, map, flatMap, peek, limit, and skip can make a decision about each element independently. They do not need to know anything about previous or future elements. Element number 47 in the stream does not need to wait for elements 1 through 46 to be processed. As soon as element 47 arrives at a stateless operation, that operation acts on it immediately.

Stateful operations like sorted and distinct need to consider relationships between elements. Sorted cannot tell you the smallest element until it has seen all of them. Distinct cannot tell you whether an element is a duplicate until it has seen all the previous elements. So stateful operations act as barriers: they collect everything that arrives, then emit their results.

Understanding this distinction also matters for parallel streams. Stateless operations parallelize beautifully because each element can be processed on a separate CPU core with no coordination. Stateful operations are harder to parallelize because they require communication between threads to track shared state.

Terminal Operations: Triggering the Pipeline

Terminal operations are what make streams do real work. You can have the most elaborate pipeline in the world, but without a terminal operation it is just a description sitting in memory doing nothing.

Every terminal operation does two things: it runs the pipeline, and it permanently closes the stream. After a terminal operation completes, the stream is consumed and cannot be used again.

forEach

ForEach visits every element that survives the pipeline and performs an action on each one. It returns void, meaning it produces no final value. It is similar to peek in that it takes a Consumer, but unlike peek, forEach is the terminal operation that actually fires the pipeline.

java
import java.util.List;

public class ForEachDemo {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(2, 1, 4, 7, 10);

        numbers.stream()
            .filter(n -> n >= 3)
            .forEach(n -> System.out.println("Value: " + n));
        // Prints: 4, 7, 10
    }
}

toArray

ToArray collects all stream elements into an array. Without arguments it returns an Object[]. To get a typed array, pass a method reference that creates an array of the correct type:

java
import java.util.List;

public class ToArrayDemo {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(2, 1, 4, 7, 10);

        // Untyped Object array
        Object[] raw = numbers.stream()
            .filter(n -> n >= 3)
            .toArray();

        // Typed Integer array -- pass a constructor reference for the array type
        Integer[] typed = numbers.stream()
            .filter(n -> n >= 3)
            .toArray(size -> new Integer[size]);

        System.out.println(typed.length); // 3
    }
}

reduce

Reduce performs an associative aggregation on the stream elements. Associative means you combine pairs of values repeatedly until you are left with one result. Sum is the most common example: take the first two elements and add them, then add the third, then the fourth, and keep going.

java
import java.util.List;
import java.util.Optional;

public class ReduceDemo {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(2, 1, 4, 7, 10);

        // Sum using reduce
        // 2 + 1 = 3, then 3 + 4 = 7, then 7 + 7 = 14, then 14 + 10 = 24
        Optional<Integer> sum = numbers.stream()
            .reduce((a, b) -> a + b);

        System.out.println(sum.get()); // 24

        // Product using reduce
        // 2 * 1 = 2, then 2 * 4 = 8, then 8 * 7 = 56, then 56 * 10 = 560
        Optional<Integer> product = numbers.stream()
            .reduce((a, b) -> a * b);

        System.out.println(product.get()); // 560
    }
}

Reduce returns an Optional because if the stream is empty, there is no result to return. The Optional wrapper lets you safely check whether a value exists before trying to use it. Call .isPresent() to check, or .get() to retrieve the value if you know it is there.

collect

Collect is the most versatile terminal operation. It gathers all stream elements into a container you specify, typically a List, Set, or Map. The Collectors utility class provides ready made collectors for the most common use cases.

java
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

public class CollectDemo {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(2, 1, 4, 7, 10, 4, 2);

        // Collect into a List (preserves order, allows duplicates)
        List<Integer> asList = numbers.stream()
            .filter(n -> n >= 3)
            .collect(Collectors.toList());
        System.out.println(asList); // [4, 7, 10, 4]

        // Collect into a Set (removes duplicates automatically)
        Set<Integer> asSet = numbers.stream()
            .filter(n -> n >= 3)
            .collect(Collectors.toSet());
        System.out.println(asSet); // [4, 7, 10] (order not guaranteed)
    }
}

min and max

Min and max find the smallest or largest element according to a comparator. Both return an Optional because the stream might be empty.

The comparator you pass determines the ordering. Passing (a, b) -&gt; a - b establishes ascending order, so min returns the smallest element. Passing (a, b) -&gt; b - a establishes descending order, so min on a descending stream returns what you might think of as the largest element.

java
import java.util.List;
import java.util.Optional;

public class MinMaxDemo {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(2, 1, 4, 7, 10);

        // Natural ascending comparator: min gives the actual smallest
        Optional<Integer> smallest = numbers.stream()
            .filter(n -> n >= 3)  // stream is now 4, 7, 10
            .min((a, b) -> a - b);
        System.out.println(smallest.get()); // 4

        // Reversed comparator: min now gives the "first in descending order"
        Optional<Integer> largest = numbers.stream()
            .filter(n -> n >= 3)
            .min((a, b) -> b - a);
        System.out.println(largest.get()); // 10
    }
}

count

Count returns a long representing how many elements are in the stream after all intermediate operations have run.

java
import java.util.List;

public class CountDemo {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(2, 1, 4, 7, 10);

        long count = numbers.stream()
            .filter(n -> n >= 3)
            .count();

        System.out.println(count); // 3
    }
}

anyMatch, allMatch, noneMatch

These three operations check conditions across the stream and return a boolean. They are all short circuiting, meaning they stop processing as soon as they have enough information to answer the question.

AnyMatch returns true as soon as it finds one element that satisfies the condition. It does not need to look at the rest.

AllMatch returns true only if every element satisfies the condition. It stops and returns false as soon as it finds one element that does not match.

NoneMatch returns true only if no element satisfies the condition. It stops and returns false as soon as it finds one that does.

java
import java.util.List;

public class MatchDemo {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(2, 1, 4, 7, 10);

        boolean any = numbers.stream().anyMatch(n -> n > 3);
        System.out.println("Any above 3: " + any); // true

        boolean all = numbers.stream().allMatch(n -> n > 0);
        System.out.println("All positive: " + all); // true

        boolean none = numbers.stream().noneMatch(n -> n > 100);
        System.out.println("None above 100: " + none); // true
    }
}

Because anyMatch is short circuiting, consider a stream of one million elements where the first element satisfies your condition. AnyMatch returns true after examining exactly one element. The remaining 999 thousand nine hundred and ninety nine elements are never touched. This is a real, measurable performance advantage.

findFirst and findAny

FindFirst returns the first element of the stream wrapped in an Optional. If the stream is empty it returns an empty Optional.

FindAny returns any element from the stream, also as an Optional. In a sequential stream this is typically the first element, but the real purpose of findAny is in parallel streams where any element from any partition might be returned first.

java
import java.util.List;
import java.util.Optional;

public class FindDemo {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(2, 1, 4, 7, 10);

        Optional<Integer> first = numbers.stream()
            .filter(n -> n >= 3)  // stream: 4, 7, 10
            .findFirst();
        System.out.println(first.get()); // 4, always

        Optional<Integer> any = numbers.stream()
            .filter(n -> n >= 3)
            .findAny();
        System.out.println(any.get()); // 4 in sequential, could vary in parallel
    }
}

A Stream Is Single Use

Once a terminal operation has run, the stream is closed. If you try to call any operation on the same stream instance afterward, Java throws IllegalStateException: stream has already been operated upon or closed.

This is not a bug or a limitation. It is intentional. A stream is a description of a processing pipeline, not a data container. Once the pipeline has run, it is done. If you want to process the same data again, go back to your source collection and open a new stream.

java
import java.util.List;
import java.util.stream.Stream;

public class SingleUseDemo {
    public static void main(String[] args) {
        List<String> data = List.of("apple", "banana", "cherry");

        Stream<String> stream = data.stream().filter(s -> s.length() > 5);

        long count = stream.count(); // Terminal op -- stream is now closed

        // This next line throws IllegalStateException:
        // stream.forEach(System.out::println);

        // Correct approach: open a fresh stream from the same source
        data.stream()
            .filter(s -> s.length() > 5)
            .forEach(System.out::println);
    }
}

The rule is simple: one terminal operation per stream instance. When you need the data again, call .stream() on your collection again.

Parallel Streams: Splitting Work Across CPU Cores

Everything described so far uses a sequential stream: elements flow one at a time through the pipeline on a single thread. Java also provides parallel streams that split the work across multiple CPU cores and execute portions of the pipeline concurrently.

In practice, parallel streams are used far less often than sequential ones. For most everyday applications with typical data volumes, sequential streams are fast enough and simpler to reason about. Parallel streams shine when you have genuinely large data sets and operations that are computationally expensive per element and have no shared mutable state between elements.

The API difference is tiny. Instead of .stream(), you call .parallelStream():

java
import java.util.List;

public class ParallelVsSequential {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(11, 22, 33, 44, 55, 66, 77, 88, 99, 110, 121);

        // Sequential: all work on the main thread
        long startSeq = System.currentTimeMillis();
        numbers.stream()
            .map(n -> n * n)
            .forEach(v -> System.out.println(
                Thread.currentThread().getName() + " -> " + v));
        long endSeq = System.currentTimeMillis();
        System.out.println("Sequential time: " + (endSeq - startSeq) + "ms");

        System.out.println("---");

        // Parallel: work distributed across multiple threads
        long startPar = System.currentTimeMillis();
        numbers.parallelStream()
            .map(n -> n * n)
            .forEach(v -> System.out.println(
                Thread.currentThread().getName() + " -> " + v));
        long endPar = System.currentTimeMillis();
        System.out.println("Parallel time: " + (endPar - startPar) + "ms");
    }
}

Run this and observe the thread names. The sequential version will always show main. The parallel version will show names like ForkJoinPool.commonPool worker 1, ForkJoinPool.commonPool worker 2, and so on. Multiple threads are genuinely processing different elements at the same time.

How Parallel Streams Work: Spliterator and ForkJoinPool

Under the hood, parallel streams use two mechanisms: the Spliterator and the ForkJoinPool.

When you call .parallelStream() on a collection, Java does not just hand the whole collection to multiple threads at once. Instead, it calls a Spliterator on the collection. A Spliterator is an interface with a method called trySplit. When trySplit is called on a Spliterator that holds your full collection, it finds the midpoint, splits the collection in half, and returns a new Spliterator covering the second half while it retains the first half.

This splitting happens recursively. The first split gives you two halves. Each half can be split again into quarters. Each quarter can be split into eighths. The recursion continues until the chunks are small enough to be processed efficiently by a single thread.

Once the data is divided into these smaller chunks, the parallel stream hands each chunk to the ForkJoinPool. The ForkJoinPool is a thread pool built into the JVM specifically for this kind of divide and conquer work. Fork means divide the task into smaller subtasks. Join means combine the results from the subtasks back together.

The ForkJoinPool maintains a pool of worker threads, typically one per CPU core. Each worker thread picks up a chunk from a queue, processes it through the pipeline stages, and places its partial result into a collection. If one worker finishes its chunk early, it can steal work from another worker's queue. This work stealing keeps all CPU cores busy even when the chunks are not perfectly balanced.

After all workers finish, the ForkJoinPool joins their partial results into the final output.

Source Collection: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
        |
   parallelStream()
        |
   Spliterator.trySplit()  -> splits at midpoint
       / \
  [1..5]  [6..10]
    |         |
  trySplit  trySplit
  /    \    /    \
[1-2] [3-5] [6-7] [8-10]
  |      |     |      |
 Core1  Core2 Core3  Core4  <- all running concurrently
  |      |     |      |
   \     |    /       |
    \    |   /        |
      JOIN results
          |
      Final output

The ForkJoinPool itself will be covered in depth in the multithreading section of this course. For now, what matters is the conceptual picture: parallel streams automatically split your data, process the pieces concurrently on multiple cores, and combine the results.

Putting It All Together: A Real Pipeline

Here is a realistic example that uses creation, multiple intermediate operations, and a terminal operation to answer a real question from a dataset:

java
import java.util.List;
import java.util.stream.Collectors;

public class RealisticPipeline {
    public static void main(String[] args) {
        List<String> employees = List.of(
            "Alice 85000",
            "Bob 42000",
            "Carol 97000",
            "David 31000",
            "Eve 75000",
            "Frank 67000"
        );

        // Find names of employees earning above 60000, sorted alphabetically
        List<String> highEarners = employees.stream()
            // Step 1: open the stream
            // Step 2: intermediate operations
            .filter(record -> {
                int salary = Integer.parseInt(record.split(" ")[1]);
                return salary > 60000;
            })
            .map(record -> record.split(" ")[0])  // extract just the name
            .sorted()                              // alphabetical order (stateful barrier)
            // Step 3: terminal operation
            .collect(Collectors.toList());

        System.out.println(highEarners); // [Alice, Carol, Eve, Frank]

        // Count how many earn above 60000
        long count = employees.stream()
            .filter(record -> Integer.parseInt(record.split(" ")[1]) > 60000)
            .count();

        System.out.println("High earners: " + count); // 4

        // Check if anyone earns above 90000
        boolean anyTopTier = employees.stream()
            .anyMatch(record -> Integer.parseInt(record.split(" ")[1]) > 90000);

        System.out.println("Anyone above 90k: " + anyTopTier); // true (Carol)
    }
}

Interview Questions and Common Pitfalls

Several interview questions come up repeatedly on the topic of streams. Here are the ones you should be able to answer confidently.

What is a stream? A stream is a pipeline through which data elements pass. It has three steps: creation, zero or more intermediate operations, and exactly one terminal operation. The original data source is never modified.

What does lazy mean in the context of streams? Intermediate operations do not execute when you call them. They just record what should happen. Execution begins only when a terminal operation is invoked. This allows the JVM to optimize the pipeline, for example by combining operations or short circuiting early.

What is the difference between stateless and stateful intermediate operations? Stateless operations like filter, map, and peek process each element independently without needing to see other elements. Stateful operations like sorted and distinct must accumulate some or all elements before they can emit results.

Can a stream be reused? No. Once a terminal operation has run, the stream is permanently closed. Calling any further operation on it throws IllegalStateException. To process the same data again, you must create a new stream from the source.

What is the difference between findFirst and findAny? FindFirst always returns the first element in encounter order. FindAny may return any element and is more efficient in parallel streams because threads do not need to coordinate to determine which element comes first.

What is the difference between map and flatMap? Map transforms each element one for one: one element in, one element out. FlatMap transforms each element into zero or more elements by expanding an inner stream into the outer stream. Use flatMap when each element is a collection and you want to work with individual items.

When should you use a parallel stream? Use parallel streams only when the data set is large, the per element work is computationally significant, and the operations have no shared mutable state. For most everyday work, sequential streams are simpler and fast enough.

What is a Spliterator? A Spliterator is an interface with a trySplit method. It is used by parallel streams to recursively divide a collection into smaller chunks that can be processed concurrently by the ForkJoinPool.

What happens if you call a terminal operation on an empty stream? Operations like count() return zero. Operations like reduce(), min(), max(), findFirst(), and findAny() return an empty Optional. ForEach simply does nothing. No exceptions are thrown for empty streams.

Summary

A Java Stream is a pipeline for processing data without modifying the original source. You create one from any collection, array, or generator, attach zero or more intermediate operations to describe your transformations, and close the pipeline with exactly one terminal operation that triggers all the work.

Intermediate operations are lazy: they record intentions but do nothing until the terminal fires. They fall into stateless operations that process elements independently, and stateful operations like sorted that need to see the whole stream before acting. Elements flow through stateless operations one at a time, which enables short circuiting and early termination.

Terminal operations are eager: they execute the pipeline, produce a result, and permanently close the stream. You get forEach, toArray, reduce, collect, min, max, count, anyMatch, allMatch, noneMatch, findFirst, and findAny, each serving a specific purpose.

Parallel streams divide the data using Spliterator's trySplit method, distribute the chunks across CPU cores via the ForkJoinPool, and combine the results, giving you multi core performance with no threading code of your own.

Streams are not a replacement for loops in all situations. They are a tool for expressing data processing declaratively and enabling parallel execution when the scale demands it. Once you internalize the three step model and the lazy execution model, you will reach for streams naturally and write code that is both cleaner and faster.