The Streams API

Introduction to Streams

15 min Lesson 1 of 13

Introduction to Streams

The Streams API, introduced in Java 8 and refined ever since, is one of the most significant additions to the language. It lets you express data-processing logic as a declarative pipeline — telling Java what to compute rather than how to iterate. Before going further, it is important to be precise about what a stream actually is, because the word is overloaded in computing.

What Is a Stream?

A Stream<T> in Java is a sequence of elements that supports sequential and parallel aggregate operations. Three things make it distinct from a plain array or list:

  • No storage. A stream does not hold data. It reads from a source (a collection, an array, a file, a generator) and passes elements through the pipeline on demand.
  • Functional in nature. Operations produce new streams or a final result; they never mutate the source.
  • Lazily evaluated. Intermediate operations are not executed until a terminal operation demands a result.
Streams vs. I/O Streams. java.util.stream.Stream is completely unrelated to java.io.InputStream or java.io.OutputStream. Both are called "streams," but they solve different problems. This tutorial covers only java.util.stream.

Streams vs. Collections — The Key Difference

A collection (like ArrayList or HashSet) is a data structure: it stores elements in memory, lets you add and remove them, and lets you iterate over them in any order you like. A stream is a view for processing that data; it is consumed once and then discarded.

Consider a concrete example. Suppose you have a list of product names and you want the names that start with "A", converted to uppercase.

Collection-style (imperative):

List<String> products = List.of("Apple", "Banana", "Avocado", "Cherry", "Apricot"); List<String> result = new ArrayList<>(); for (String name : products) { if (name.startsWith("A")) { result.add(name.toUpperCase()); } } // result: ["APPLE", "AVOCADO", "APRICOT"]

Stream-style (declarative):

List<String> products = List.of("Apple", "Banana", "Avocado", "Cherry", "Apricot"); List<String> result = products.stream() .filter(name -> name.startsWith("A")) .map(String::toUpperCase) .collect(Collectors.toList()); // result: ["APPLE", "AVOCADO", "APRICOT"]

Both produce the same answer. The stream version reads like a specification: filter names that start with A, then map each to uppercase, then collect into a list. There is no explicit loop, no mutation of an accumulator, and no index to manage.

Readability at scale. The advantage of the stream style grows with complexity. When you chain three, four, or five operations the intent stays clear. Equivalent imperative code accumulates loops and temporary variables that obscure the logic.

The Pipeline Model

Every stream expression follows the same three-stage structure:

  1. Source — where elements come from.
  2. Intermediate operations — zero or more transformations that return a new stream.
  3. Terminal operation — triggers execution and produces a result (or a side-effect).
// SOURCE products.stream() // INTERMEDIATE (returns a Stream, lazy) .filter(name -> name.startsWith("A")) .map(String::toUpperCase) // TERMINAL (triggers execution, returns a result) .collect(Collectors.toList());

Nothing runs until the terminal operation is reached. If you call filter(...).map(...) without a terminal operation, Java does nothing — the lambda bodies are never invoked. This laziness is intentional: it lets the runtime fuse operations and avoid creating intermediate collections.

Laziness in Practice

To see laziness clearly, add a print statement inside an intermediate operation:

List<String> names = List.of("Alice", "Bob", "Charlie"); // No output here — pipeline is built but not executed Stream<String> pipeline = names.stream() .filter(n -> { System.out.println("filtering: " + n); return n.length() > 3; }); System.out.println("About to trigger..."); // Terminal operation — NOW the filter lambda runs long count = pipeline.count(); System.out.println("Count: " + count);

Output:

About to trigger... filtering: Alice filtering: Bob filtering: Charlie Count: 2

The word "About to trigger..." prints before any filtering, proving that the intermediate operation was deferred until count() was called.

A Stream Is Single-Use

Once a terminal operation has been called, the stream is consumed. Attempting to reuse it throws IllegalStateException.

Stream<String> s = List.of("a", "b").stream(); s.forEach(System.out::println); // fine s.forEach(System.out::println); // throws IllegalStateException: stream has already been operated upon or closed
Do not store streams. A stream is a one-time pipeline description, not a reusable data container. If you need to process the same data twice, call .stream() on the source collection again — that is cheap.

Quick Summary

  • A Stream<T> is a lazy, single-use pipeline over a data source — it stores nothing.
  • Collections own data; streams process it.
  • Every stream has a source, zero or more intermediate operations, and exactly one terminal operation.
  • Nothing executes until the terminal operation is invoked.

In the next lesson you will see the many ways Java lets you create a stream — from collections, arrays, ranges, files, and generators.