Skip to content

Stream API Interview Questions & Answers

21 questions Updated 2026-06-20 Share:

Java Stream API interview questions — intermediate vs terminal operations, laziness and short-circuiting, map/filter/reduce, flatMap, stream sources, primitive streams, and parallel streams.

Read the in-depth guideJava Stream API — Pipelines, Laziness & the Core Operations Explained(opens in new tab)
21 of 21

A Stream is not a data structure — it's a pipeline that carries elements from a source (a collection, array, generator, file…) through a series of operations and produces a result. It stores nothing; it just describes a computation over the source.

Key differences from a collection:

Collection Stream
Stores elements in memory Holds no data — pulls from a source
Eagerly built Lazily evaluated
Iterated externally (you loop) Iterated internally (the stream loops)
Reusable Single-use (consumed once)
Can be modified Source is never mutated

So you use a collection to hold data and a stream to process it declaratively (filtermapcollect) instead of writing explicit loops.

Streams come from many sources:

list.stream();                       // from any Collection
Stream.of("a", "b", "c");            // from explicit values
Arrays.stream(new int[]{1, 2, 3});   // from an array
Stream.iterate(1, n -> n * 2);       // infinite: 1,2,4,8,...
Stream.generate(Math::random);       // infinite: repeated supplier calls
IntStream.range(0, 5);               // 0,1,2,3,4 (range)
IntStream.rangeClosed(1, 5);         // 1,2,3,4,5 (inclusive)
Files.lines(Path.of("data.txt"));    // lazy stream of lines
"abc".chars();                       // IntStream of char codes

Stream.iterate/generate are infinite — always pair them with a short-circuiting op like limit(n). Files.lines returns a stream backed by an open file, so close it (use try-with-resources).

A stream pipeline is exactly zero-or-more intermediate operations followed by one terminal operation.

Intermediate Terminal
Returns another Stream a value / side-effect (not a stream)
Evaluation lazy — does nothing yet eager — triggers the work
Count per pipeline any number exactly one
Examples filter, map, sorted, distinct, limit, peek collect, forEach, reduce, count, findFirst, toArray
list.stream()
    .filter(s -> s.length() > 3)   // intermediate — lazy
    .map(String::toUpperCase)      // intermediate — lazy
    .collect(Collectors.toList()); // terminal — runs the pipeline

Without a terminal operation nothing executes — the intermediate steps are just recorded.

Laziness means intermediate operations are not evaluated when they are called — they only run when a terminal operation demands elements. The pipeline then processes elements one at a time, vertically (each element flows through all stages before the next starts), not stage-by-stage over the whole collection.

Stream<String> s = list.stream()
    .filter(x -> { System.out.println("filter " + x); return true; })
    .map(x -> { System.out.println("map " + x); return x; });
// nothing printed yet — no terminal op
s.forEach(x -> {});   // NOW it runs, interleaving "filter a", "map a", ...

Laziness enables two big wins: fusion (multiple ops run in one pass) and short-circuiting (the pipeline can stop early without touching every element).

A short-circuiting operation can produce a result (or stop the pipeline) without processing every element. Combined with laziness, this lets streams work on infinite sources and quit as soon as the answer is known.

Short-circuiting terminal ops: findFirst, findAny, anyMatch, allMatch, noneMatch. Short-circuiting intermediate op: limit.

Stream.iterate(1, n -> n + 1)   // infinite
      .filter(n -> n % 7 == 0)
      .findFirst();             // stops at 7 — never runs forever

boolean any = list.stream().anyMatch(s -> s.isEmpty()); // stops at first match

allMatch stops at the first element that fails, anyMatch at the first that passes — they rarely scan the whole stream.

filter is an intermediate operation that keeps only elements matching a Predicate (a function returning boolean); the rest are dropped. It does not change the element type — only how many pass through.

List<String> longNames = names.stream()
    .filter(n -> n.length() > 4)   // keep names longer than 4 chars
    .collect(Collectors.toList());

filter is stateless and lazy, so several filters fuse into one pass. Rule of thumb: filter decides whether an element survives; map decides what it becomes.

map is an intermediate operation that applies a Function to each element and replaces it with the result — a one-to-one transformation. It can change the element type (Stream<String>Stream<Integer>).

List<Integer> lengths = names.stream()
    .map(String::length)           // String -> Integer
    .collect(Collectors.toList());

map never changes the number of elements (N in, N out); it only transforms each one. When the mapping produces another collection or stream per element and you want them flattened, you need flatMap instead.

flatMap maps each element to a stream and then flattens all those streams into one. Use it when the mapping function yields multiple values per element (a list, an Optional, a nested stream) and you want a single flat stream rather than a stream-of-streams.

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

// map gives Stream<Stream<Integer>> — wrong shape
nested.stream().map(List::stream);

// flatMap flattens to Stream<Integer> -> [1, 2, 3, 4]
List<Integer> flat = nested.stream()
    .flatMap(List::stream)
    .collect(Collectors.toList());

Mental model: use map for one-to-one (N→N), flatMap for one-to-many that you want merged (N→M). Splitting sentences into words is the classic flatMap case.

distinct removes duplicates (by equals/hashCode); sorted orders elements (natural order, or a supplied Comparator). Both are intermediate but stateful — they must see elements before they can emit.

nums.stream()
    .distinct()                          // drop duplicates
    .sorted(Comparator.reverseOrder())   // sort descending
    .collect(Collectors.toList());

sorted is a full barrier: it buffers the entire stream before producing any output, so it cannot short-circuit and breaks on infinite streams. Both add memory and ordering overhead, so apply filter before them to shrink the work.

limit(n) truncates the stream to the first n elements; skip(n) discards the first n and keeps the rest. Together they give pagination-style slicing.

Stream.iterate(1, x -> x + 1)
      .limit(10)            // first 10: 1..10
      .skip(3)              // drop 1,2,3 -> 4..10
      .collect(Collectors.toList());

limit is short-circuiting — it's what tames infinite streams. On an ordered stream both are deterministic; on an unordered/parallel stream they may pick any n elements, and limit on a parallel stream can actually hurt performance because of the ordering constraint.

peek is an intermediate operation that runs a side-effecting action on each element as it flows past, then passes the element through unchanged. Its intended use is debugging — logging what moves through each stage.

list.stream()
    .peek(x -> System.out.println("before filter: " + x))
    .filter(x -> x > 2)
    .peek(x -> System.out.println("after filter: " + x))
    .collect(Collectors.toList());

It's controversial because (1) it's lazy, so elements skipped by short-circuiting never reach peek, and (2) using it to mutate state is an anti-pattern. Rule of thumb: use peek only for observation, never for logic.

reduce combines all elements into a single result by repeatedly applying a binary operator. It comes in three overloads:

// 1. accumulator only -> Optional (stream may be empty)
Optional<Integer> sum = nums.stream().reduce((a, b) -> a + b);

// 2. identity + accumulator -> a plain value (identity if empty)
int total = nums.stream().reduce(0, Integer::sum);

// 3. identity + accumulator + combiner -> for parallel / type change
int len = words.stream()
    .reduce(0, (acc, w) -> acc + w.length(), Integer::sum);

The identity must be a true no-op (0 for sum, 1 for product). The combiner merges partial results from parallel sub-streams, so it's required when the accumulator's result type differs from the element type.

count() returns the number of elements as a long. min and max take a Comparator and return an Optional (empty if the stream is empty).

long n = list.stream().filter(s -> s.startsWith("a")).count();

Optional<String> longest = list.stream()
    .max(Comparator.comparingInt(String::length));

int smallest = nums.stream().min(Integer::compareTo).orElse(0);

min/max return Optional precisely because there's no sensible value for an empty stream — handle it with orElse/orElseThrow. (Note: since Java 9 the JVM may skip the pipeline for count() if it can compute the size directly.)

All three are short-circuiting terminal operations that take a Predicate and return a boolean:

  • anyMatchtrue if at least one element matches (stops at the first match).
  • allMatchtrue if every element matches (stops at the first failure).
  • noneMatchtrue if no element matches.
nums.stream().anyMatch(n -> n < 0);   // any negatives?
nums.stream().allMatch(n -> n > 0);   // all positive?
nums.stream().noneMatch(n -> n == 0); // no zeros?

Watch the empty-stream edge cases (vacuous truth): on an empty stream allMatch and noneMatch return true, while anyMatch returns false.

Both return an Optional with some element (or empty), and both short-circuit. The difference matters only in parallel streams:

  • findFirst — returns the first element in encounter order.
  • findAny — returns any element, whichever a worker thread finds first. It frees the runtime from honoring order, so it can be faster in parallel.
Optional<Integer> a = nums.stream().filter(n -> n > 10).findFirst();
Optional<Integer> b = nums.parallelStream().filter(n -> n > 10).findAny();

On a sequential stream they behave identically. Use findAny when you genuinely don't care which match you get and want maximum parallel performance.

The main terminal operation is collect with a Collector. There are also direct helpers:

List<String> list = s.collect(Collectors.toList()); // mutable-ish, classic
List<String> imm  = s.toList();                      // Java 16+, unmodifiable
Set<String>  set  = s.collect(Collectors.toSet());

String[]     arr  = s.toArray(String[]::new);        // typed array
Object[]     objs = s.toArray();                     // Object[]
int[]        ints = intStream.toArray();             // primitive array

toList() (Java 16+) is the concise modern choice but returns an unmodifiable list; use Collectors.toList() if you need to mutate the result. Pass a generator (String[]::new) to toArray to get a typed array instead of Object[]. Deeper collector recipes (grouping, joining) live on the Collectors page.

They are specialized primitive streams that avoid the boxing overhead of Stream<Integer>/Stream<Long>/Stream<Double>. Because elements are raw primitives, they add numeric terminal ops that the object stream lacks.

int sum      = IntStream.rangeClosed(1, 100).sum();
double avg   = IntStream.of(1, 2, 3).average().orElse(0); // OptionalDouble
IntSummaryStatistics st = nums.stream()
    .mapToInt(Integer::intValue)   // Stream<Integer> -> IntStream
    .summaryStatistics();          // count, sum, min, max, average at once

Convert with mapToInt/mapToLong/mapToDouble to enter a primitive stream, boxed() or mapToObj(...) to go back to an object stream. Prefer primitive streams for heavy numeric work — they're faster and offer sum/average/summaryStatistics for free.

A stateless operation processes each element independently of the others (filter, map, flatMap, peek) — it needs no memory of what came before. A stateful operation must consider other elements to produce its output (distinct, sorted, limit, skip).

stream.filter(x -> x > 0)   // stateless — each element judged alone
      .sorted()             // stateful — needs ALL elements buffered
      .distinct();          // stateful — must remember what it has seen

Why it matters: stateful ops may buffer the stream (extra memory), can act as barriers that prevent short-circuiting (sorted on an infinite stream hangs), and are harder to parallelize. Keep pipelines stateless where you can.

A stream can be traversed only once. After a terminal operation runs (or even after some intermediate ops link onto it), the stream is consumed; touching it again throws IllegalStateException: stream has already been operated upon or closed.

Stream<String> s = list.stream();
s.count();          // terminal — consumes the stream
s.forEach(...);     // IllegalStateException!

Streams are single-use because they hold no data and may be backed by I/O or infinite generators — re-traversal isn't generally possible. If you need to process the data twice, re-create the stream from the source (list.stream() again) or use a Supplier<Stream<T>> that builds a fresh one on demand.

A parallel stream splits its source and processes chunks concurrently on the common ForkJoinPool, then merges the partial results. You opt in with collection.parallelStream() or .parallel() on an existing stream.

long count = bigList.parallelStream()
                    .filter(this::isExpensive)
                    .count();

They help when: the data set is large, the per-element work is genuinely expensive (CPU-bound), the source splits cheaply (arrays, ArrayList), and operations are stateless. They hurt when: the data is small, the source is hard to split (LinkedList, I/O streams), elements are cheap (split/merge cost dominates), or you rely on order. Rule of thumb: stay sequential by default and only parallelize after measuring a real win.

Stream operations should be pure — depend only on their input and not mutate shared state. A stateful lambda that reads or writes outside variables breaks under parallelism and even under reordering, producing non-deterministic or corrupt results.

// BROKEN: shared mutable state, not thread-safe in parallel
List<Integer> out = new ArrayList<>();
nums.parallelStream().forEach(out::add);   // data race / lost updates

// CORRECT: let the framework collect for you
List<Integer> safe = nums.parallelStream()
                         .collect(Collectors.toList());

Also avoid modifying the stream's source during iteration (ConcurrentModificationException). Rule of thumb: never accumulate into an external collection from forEach/peek — express the result with collect or reduce, which are designed to be safe even in parallel.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel