Streams API

Learning goals

By the end of this chapter you should be able to:

  • Build a stream pipeline: source → intermediate ops → terminal op
  • Use map, filter, flatMap, sorted, distinct, limit
  • Collect with toList, Collectors.groupingBy, Collectors.toMap
  • Know when streams help readability vs when a simple loop is clearer

What is a Stream?

A Stream<T> is a lazy sequence of elements supporting bulk operations. It is not a data structure — it reads from a source (list, array, IO lines, generator) once.

List<String> names = List.of("ana", "bob", "carl");

List<String> result = names.stream()
    .filter(n -> n.length() > 3)
    .map(String::toUpperCase)
    .toList();
// [BOB, CARL]

Lazy: nothing runs until a terminal operation (toList, count, forEach) executes.

Single-use: after terminal op, stream is consumed — create a new one from the source.


Pipeline anatomy

Stage Examples Notes
Source list.stream(), Arrays.stream, Stream.of, IntStream.range
Intermediate filter, map, flatMap, sorted, peek, distinct return new stream; lazy
Terminal toList, collect, reduce, count, forEach, findFirst triggers execution
long count = users.stream()
    .filter(User::active)
    .map(User::department)
    .distinct()
    .count();

Order matters for performance: filter before map when the predicate is cheaper than the mapping.


map and flatMap

map — one output per input:

List<Integer> lengths = words.stream()
    .map(String::length)
    .toList();

flatMap — one-to-many, then flatten:

List<String> tokens = lines.stream()
    .flatMap(line -> Arrays.stream(line.split("\\s+")))
    .toList();

Use flatMap when each element expands to a stream (optional values, nested lists, parsing).


Primitive streams

Avoid boxing overhead with IntStream, LongStream, DoubleStream:

int sum = IntStream.rangeClosed(1, 100).sum();

OptionalDouble avg = scores.stream()
    .mapToInt(Integer::intValue)
    .average();

Convert back with boxed() when you need a Stream<Integer>.


Collecting results

Map<String, List<User>> byDept = users.stream()
    .collect(Collectors.groupingBy(User::department));

Map<String, User> byId = users.stream()
    .collect(Collectors.toMap(User::id, Function.identity()));

Stream.toList() (Java 16+) — unmodifiable list. Use collect(Collectors.toList()) when you need a mutable ArrayList.

List<String> mutable = stream.collect(Collectors.toCollection(ArrayList::new));

reduce and optional aggregates

Optional<String> longest = words.stream()
    .reduce((a, b) -> a.length() >= b.length() ? a : b);

int total = numbers.stream()
    .reduce(0, Integer::sum);  // with identity

min/max/sum on primitive streams are often clearer than manual reduce.


Parallel streams

long count = hugeList.parallelStream()
    .filter(this::expensiveCheck)
    .count();

Use sparingly. Parallelism helps CPU-bound work on large in-memory sources with cheap splitting. Wrong cases: small lists, IO-bound tasks, order-dependent side effects, shared mutable state.

The common ForkJoinPool backs parallelStream() — heavy parallel work can starve other parts of the app.


Side effects and peek

forEach is for side effects at the end. peek debugs intermediate stages — do not rely on it for business logic.

Bad:

List<String> acc = new ArrayList<>();
stream.filter(...).peek(acc::add).count();  // fragile, order-dependent

Good:

List<String> acc = stream.filter(...).toList();

Common mistakes

  1. Missing terminal operation — pipeline does nothing; looks like a bug.
  2. Reusing a consumed streamIllegalStateException.
  3. Modifying source during stream — undefined behavior for most sources.
  4. Collectors.toMap without merge functionIllegalStateException on duplicate keys.
  5. Parallel streams with synchronized blocks — defeats parallelism; fix data structure or stay sequential.
  6. Using stream for tiny collections — loop may be shorter and faster.

Practice checkpoint

  1. Flatten List<List<Integer>> into one list of integers — which op? Answer: flatMap(List::stream) or flatMap(Collection::stream).

  2. stream.filter(x -> x > 0).count() then reuse same stream — what happens? Answer: IllegalStateException — stream already consumed.

  3. Group employees by department into lists — which collector? Answer: Collectors.groupingBy(Employee::department).

  4. When is parallel stream a bad idea? Answer: small data, IO-bound ops, ordering requirements, or mutating shared state.


Interview angles

  • Lazy evaluation: intermediate ops fuse; one pass when possible.
  • Stream vs Collection: stream is algorithm; collection is storage.
  • toList vs collect: unmodifiable vs customizable mutable result.
  • flatMap + Optional: opt.stream() pattern for chaining nullable steps (often clearer with Optional methods directly).

Next: /java/learn/modern-java/optional/