Stream API

A Stream is a lazy, one-shot pipeline for processing sequences of data declaratively — describe what you want, not how to loop for it.

The Stream API (java.util.stream, Java 8+) lets you express computations over sequences of elements as a pipeline of operations — filter, transform, aggregate — instead of hand-written loops. A stream is not a data structure; it is a view over a source that computes results lazily, on demand, and can only be consumed once.

What Is a Stream

A Stream<T> represents a sequence of elements supporting a pipeline of operations. Unlike a Collection, a stream does not store elements — it pulls them from a source (a collection, an array, generator, or I/O channel) and processes them on demand. Streams are declarative: you describe the transformation, and the library decides how to iterate.

The pipeline reads as a description of the result: keep names longer than 2 chars, uppercase them, collect into a list.

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

List<String> names = List.of("Ana", "Bo", "Cy", "Dee", "El");

List<String> result = names.stream()
    .filter(n -> n.length() > 2)
    .map(String::toUpperCase)
    .collect(Collectors.toList());

System.out.println(result); // [ANA, DEE]

The Pipeline: Source, Intermediate, Terminal

Every stream pipeline has three parts: a source (where elements come from — list.stream(), Stream.of(...), Files.lines(path), IntStream.range(...)), zero or more intermediate operations (filter, map, sorted, ...) that each return a new stream and are lazy, and exactly one terminal operation (collect, forEach, reduce, count, ...) that triggers actual processing and produces a result or side effect.

Without a terminal operation, nothing runs at all — intermediate operations just build up a description of work to do.

source          intermediate ops (lazy)         terminal op (eager)
list.stream() -> .filter(...) -> .map(...) -> .collect(...)
                 ^--- no work happens here ---^  ^-- work happens here

Laziness

Intermediate operations are lazy: calling .filter() or .map() merely records the operation in the pipeline description. Nothing is evaluated until a terminal operation is invoked, and then the pipeline processes elements one at a time through the whole chain (element-by-element, not stage-by-stage) — this is sometimes called vertical execution.

Each element flows through filter -> map -> forEach before the next element starts — laziness means work only happens as pulled by the terminal op, and can short-circuit (see limit/findFirst) without processing every element.

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

nums.stream()
    .filter(n -> {
        System.out.println("filtering " + n);
        return n % 2 == 0;
    })
    .map(n -> {
        System.out.println("mapping " + n);
        return n * n;
    })
    .forEach(n -> System.out.println("result " + n));
// filtering 1
// filtering 2
// mapping 2
// result 4
// filtering 3
// filtering 4
// mapping 4
// result 16
// filtering 5

Streams Are Single-Use

A stream can be traversed only once. Once a terminal operation has run, the stream is considered consumed; reusing it throws IllegalStateException. If you need to process the same data again, create a fresh stream from the source.

var s = List.of(1, 2, 3).stream();
s.forEach(System.out::println);
s.forEach(System.out::println); // IllegalStateException: stream has already been operated upon or closed

Stateless vs Stateful Intermediate Operations

Stateless operations (filter, map) process each element independently, with no memory of prior elements — they can be evaluated fully lazily and pipelined element-by-element. Stateful operations (sorted, distinct, limit in some cases) need information about other elements (the whole stream, or elements seen so far) before they can produce output, which can force buffering and limits how well they interleave with the rest of the pipeline, especially in parallel streams.

Stateless : filter, map, mapToInt, flatMap, peek, unordered
Stateful  : sorted, distinct, limit, skip

Non-Interference

The source of a stream should not be modified while the stream is being traversed. Modifying the underlying collection during a pipeline's execution (e.g. adding/removing elements from a List you are streaming) can throw ConcurrentModificationException or produce undefined, inconsistent results. This is called the non-interference contract.

import java.util.ArrayList;
import java.util.List;

List<Integer> list = new ArrayList<>(List.of(1, 2, 3));
list.stream().forEach(n -> {
    if (n == 2) list.remove(Integer.valueOf(2)); // interference!
}); // throws ConcurrentModificationException (typical behavior)

Side Effects

Lambdas passed to stream operations (especially map, filter, reduce) should ideally be free of side effects — they should compute a result from their input without mutating shared state. Side-effecting behavior is unsafe with parallel streams (race conditions on shared mutable state) and makes pipelines harder to reason about even sequentially. forEach is the one place side effects are the point (it exists to perform an action per element), but even there, avoid mutating shared state from multiple threads.

// Risky: mutating a shared list from inside a stream operation
List<Integer> squares = new ArrayList<>();
List.of(1, 2, 3).stream().map(n -> n * n).forEach(squares::add); // works sequentially, unsafe in parallel()

// Better: let the stream build the result
List<Integer> squares2 = List.of(1, 2, 3).stream()
    .map(n -> n * n)
    .collect(java.util.stream.Collectors.toList());

Sequential vs Parallel Streams

collection.stream() produces a sequential stream; collection.parallelStream() (or calling .parallel() on any stream) requests a parallel stream, which may split the source and process chunks concurrently using the common ForkJoinPool. Parallelism can help for large data sets with CPU-bound, independent, stateless operations — but it adds overhead (splitting, thread coordination, merging) that can make it *slower* than sequential for small or cheap workloads.

Parallel streams require operations to be stateless, non-interfering, and associative (for reduce/collect) to produce correct results — side effects and shared mutable state turn parallel execution into a source of race conditions and non-deterministic bugs.

long count = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
    .parallelStream()
    .filter(n -> n % 2 == 0)
    .count();
System.out.println(count); // 5

Streams vs Collections

Collections store data; streams describe computation over data. A List supports repeated iteration, random access, and mutation; a stream supports exactly one traversal, no indexing, and (by convention) no mutation of the source. Getting a stream from a collection (.stream()) doesn't copy data — it wraps the collection with a computation pipeline.

Collection            Stream
----------            ------
stores elements       computes elements on demand
reusable/iterable      single-use, one traversal
random access (List)  no random access
eager                 lazy until terminal op

Creating Streams

Streams can come from many sources: collections (.stream()), arrays (Arrays.stream(arr)), static factories (Stream.of(1, 2, 3), Stream.empty()), ranges (IntStream.range(0, 10)), or infinite generators (Stream.iterate, Stream.generate, usually paired with .limit()).

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

Stream<String> s1 = Stream.of("a", "b", "c");
IntStream s2 = IntStream.range(1, 5); // 1, 2, 3, 4
Stream<Integer> s3 = Stream.iterate(1, n -> n * 2).limit(5); // 1, 2, 4, 8, 16

Primitive Streams

IntStream, LongStream, and DoubleStream specialize Stream<T> for primitive values, avoiding boxing overhead and adding numeric-only methods (sum, average, range). Convert between them with mapToInt/mapToObj/boxed.

int total = List.of("a", "bb", "ccc").stream()
    .mapToInt(String::length)
    .sum();
System.out.println(total); // 6

Mental Model

Think of a stream pipeline as an assembly line, not a container: the source is a conveyor belt feeding items one at a time, each intermediate operation is a station the item passes through, and nothing moves until the terminal operation switches the belt on. Because the belt only runs once, you build a new pipeline for each pass over the data.

Key Takeaways

A stream is a lazy, single-use pipeline over a source, not a data structure.

Intermediate operations are lazy and build up a description; only a terminal operation triggers execution.

Streams process element-by-element through the whole pipeline, which enables short-circuiting.

Avoid interfering with the source and avoid side effects in lambdas, especially with parallel streams.

Parallel streams can help for large, CPU-bound, stateless, associative workloads — and hurt for small or side-effecting ones.

Stream<Integer> s = list.stream(); s.forEach(System.out::println); long count = s.count(); // reuse after terminal op long count = list.stream().count(); // build a fresh stream per traversal list.stream().forEach(System.out::println);

A stream is consumed by its terminal operation; reusing it throws IllegalStateException. Create a new stream for each pass.

list.stream().filter(x -> x > 0); // does nothing, result discarded

List<Integer> positives = list.stream().filter(x -> x > 0).collect(Collectors.toList());

Intermediate operations are lazy no-ops without a terminal operation — the filtered stream here is never consumed, so no work happens and the line is silently useless.

list.parallelStream().forEach(x -> sharedList.add(x)); // race condition

List<Integer> result = list.parallelStream().collect(Collectors.toList());

Mutating shared mutable state from a parallel stream's lambda is a data race. Let a proper collector build the result instead.

What are the three parts of a stream pipeline?

A source (where elements come from), zero or more lazy intermediate operations, and exactly one terminal operation that triggers processing and produces a result or side effect.

Why can a stream only be traversed once?

A stream is not a data structure holding elements for repeated access — it's a one-shot computation pipeline. Once a terminal operation consumes it, it's considered closed; reusing it throws IllegalStateException.

What does it mean that intermediate operations are lazy?

Calling filter/map/etc. only records the operation; no element is actually processed until a terminal operation is invoked, at which point elements flow through the whole pipeline one at a time.

Why is mutating the stream's source collection during traversal dangerous?

It violates the non-interference contract and can throw ConcurrentModificationException or produce inconsistent results, because the stream expects the source to remain stable during the traversal.

Why should lambdas passed to stream operations avoid side effects?

Side effects (mutating shared state) make sequential pipelines harder to reason about and are actively unsafe with parallel streams, where multiple threads may run the lambda concurrently, causing race conditions.

When might a parallel stream be slower than a sequential one?

When the data set is small, the per-element work is cheap, or the operations aren't easily splittable — the overhead of splitting, scheduling across threads, and merging results can exceed the benefit of parallel execution.

What's the difference between a stream and a collection?

A collection stores elements and supports repeated iteration and random access; a stream computes elements on demand from a source, supports only a single traversal, and has no notion of indexing.

Write code that creates a stream from a List<String>, filters out blank strings, converts the rest to uppercase, and prints each one.

List<String> words = List.of("hi", "", "java", " ", "code"); words.stream() .filter(w -> !w.isBlank()) .map(String::toUpperCase) .forEach(System.out::println);

Prints HI, JAVA, CODE (blank/whitespace-only strings are filtered out before mapping to uppercase).

What happens when this code runs?

The first count() call consumes the stream and prints 3. The second call throws java.lang.IllegalStateException: stream has already been operated upon or closed, because streams are single-use.

Write a stream pipeline using IntStream.range that computes the sum of squares of even numbers from 1 to 10 (inclusive).

int sumOfSquares = IntStream.rangeClosed(1, 10) .filter(n -> n % 2 == 0) .map(n -> n * n) .sum(); System.out.println(sumOfSquares); // 4+16+36+64+100 = 220

220 — the even numbers 2,4,6,8,10 squared are 4,16,36,64,100, which sum to 220.

Given laziness and short-circuiting, how many times does 'peek' print, and why?

It prints only once ('peek 1'), because findFirst() short-circuits: as soon as an element satisfying the filter is found (1 < 3), the pipeline stops pulling further elements from the source, so peek never runs for 2, 3, 4, or 5.

Stream<Integer> s = Stream.of(1, 2, 3);
System.out.println(s.count());
System.out.println(s.count());
List<Integer> nums = List.of(1, 2, 3, 4, 5);
nums.stream()
    .peek(n -> System.out.println("peek " + n))
    .filter(n -> n < 3)
    .findFirst();