Stream Basics

Understand what a stream is, how a source, intermediate steps and one terminal operation form a lazy pipeline, and when a plain loop is still the better tool.

SyntaxList<R> result = source.stream() .filter(predicate) .map(mapper) .toList();

A stream is a pipeline that reads values from a source, passes them through a series of steps and produces one result. It is not a collection: it stores nothing, it never changes the list it came from, and it can be consumed only once.

Three parts of a pipeline

  • Source: list.stream(), Stream.of(...), Arrays.stream(array), IntStream.range(0, n) or Files.lines(path).
  • Intermediate operations: filter, map, sorted, distinct, limit. Each returns a new stream and does no work yet.
  • Terminal operation: toList(), collect, count, forEach, reduce, findFirst. This starts the pipeline and closes the stream.
List<String> paidIds = orders.stream()
    .filter(o -> o.status().equals("PAID"))
    .map(Order::id)
    .toList();

Lazy, one element at a time

Nothing runs until the terminal operation is called. Then each element travels through the whole chain before the next one starts, so a filter followed by map does not build an intermediate list. This is also why findFirst or limit can stop early without touching the rest of the source.

Mistakes people make

  • Storing a stream in a field or variable and using it twice. The second terminal call throws IllegalStateException. Keep the collection and call stream() again.
  • Adding to the result of toList(). Since Java 16 it returns an unmodifiable list, so add throws UnsupportedOperationException.
  • Using forEach to fill another list. Ask the stream for the result with toList() or a collector instead.
  • Boxing numbers needlessly. mapToInt gives an IntStream with sum, average and max, without creating an Integer per element.

Where streams fit in a service

Most back-end stream code turns rows from a repository into response objects, totals a cart, or picks the records a request asked for. Keep each pipeline short and free of side effects so it reads as a description of the result. When the logic needs early returns, checked exceptions or several mutable counters, a plain for loop is often clearer, and that is fine. A stream that reads a file or a database cursor holds a resource, so open it in try-with-resources.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Turn the rows a repository returns into the list of ids a REST endpoint sends back.
  • Total the paid orders for a customer dashboard with mapToInt and sum.
  • Stop reading a large result as soon as the first matching record is found with findFirst.

More examples

Nothing runs until the terminal operation

Building the pipeline prints nothing; the work starts at toList(). Each name goes through filter and then map before the next name is read, and cy never reaches map.

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

void main() {
    Stream<String> pipeline = Stream.of("ada", "bob", "cy")
        .filter(s -> {
            IO.println("filter " + s);
            return s.length() > 2;
        })
        .map(s -> {
            IO.println("map " + s);
            return s.toUpperCase();
        });
    IO.println("pipeline built");

    List<String> result = pipeline.toList();
    IO.println(result);
}
// Output:
// pipeline built
// filter ada
// map ada
// filter bob
// map bob
// filter cy
// [ADA, BOB]

A stream can only be used once

The second count() on the same stream throws. A Supplier that builds a fresh stream each time, or simply calling stream() again on the list, avoids the problem.

Example · java
import java.util.List;
import java.util.function.Supplier;
import java.util.stream.Stream;

void main() {
    List<String> emails = List.of("[email protected]", "[email protected]", "[email protected]");

    Stream<String> stream = emails.stream();
    IO.println(stream.count());
    try {
        stream.count();
    } catch (IllegalStateException e) {
        IO.println(e.getMessage());
    }

    Supplier<Stream<String>> comAddresses =
        () -> emails.stream().filter(email -> email.endsWith(".com"));
    IO.println(comAddresses.get().count());
    IO.println(comAddresses.get().toList());
}
// Output:
// 3
// stream has already been operated upon or closed
// 2
// [[email protected], [email protected]]

toList() returns a read-only list

Stream.toList() gives an unmodifiable list, which is usually what a response body or a cached value should be. Copy it into an ArrayList when you really need to add to it.

Example · java
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;

void main() {
    List<String> names = Stream.of("ada", "bob").toList();
    try {
        names.add("cy");
    } catch (UnsupportedOperationException e) {
        IO.println("read-only: " + e.getClass().getSimpleName());
    }

    List<String> editable = new ArrayList<>(names);
    editable.add("cy");
    IO.println(editable);
}
// Output:
// read-only: UnsupportedOperationException
// [ada, bob, cy]

Discussion

  • Be the first to comment on this lesson.