Chaturmind
LearnDSASystem DesignDevOpsEngineering GrowthBlog
Start learning
Chaturmind

Structured learning paths for engineers who want to go deep. Written by practitioners.

Learn

  • Java
  • DSA
  • System Design
  • Spring Boot
  • AI / ML
  • DevOps
  • Engineering Growth

Company

  • Blog
  • Contact

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Chaturmind. All rights reserved.

Built for engineers who want to go deep.


← Java Streams & Functional Programming

Lambdas & Functional Interfaces

  • Lambda Expressions
  • Method References

Streams API

  • Streams API
  • Collectors and groupingBy
  • Optional
Chaturmind
← Java Streams & Functional Programming

Lambdas & Functional Interfaces

  • Lambda Expressions
  • Method References

Streams API

  • Streams API
  • Collectors and groupingBy
  • Optional
HomeLearnJavaJava Streams & Functional ProgrammingStreams API
✓ FreeIntermediate· 7 min read

Streams API

filter, map, reduce, collect — transform data without mutation.

Published September 21, 2026


Java Streams API

A stream (Java 8+) is a pipeline for processing a sequence of elements: you describe what should happen to the data ("keep the active users, take their emails, sort them"), and the library works out how to iterate. The source collection is never modified. Each step produces a new, lazily-computed view of the data.

// Imperative: how to loop
List<String> emails = new ArrayList<>();
for (User u : users) {
    if (u.isActive()) emails.add(u.getEmail().toLowerCase());
}
Collections.sort(emails);

// Declarative: what you want
List<String> emails = users.stream()
        .filter(User::isActive)
        .map(u -> u.getEmail().toLowerCase())
        .sorted()
        .toList();

The three parts of every pipeline

source  ──▶  intermediate operations (0 or more)  ──▶  one terminal operation
users.stream()   .filter(...).map(...).sorted()          .toList()
  1. Source: where elements come from. collection.stream(), Arrays.stream(array), Stream.of(a, b, c), IntStream.range(0, 10), Files.lines(path).
  2. Intermediate operations return a new stream and describe a transformation: filter, map, flatMap, sorted, distinct, limit, skip, peek.
  3. Terminal operation produces a result or a side effect and starts the actual work: toList, collect, forEach, reduce, count, findFirst, anyMatch, min/max.

Laziness: nothing happens until the terminal operation

Intermediate operations don't process anything when you call them. They only build up the pipeline. Work starts at the terminal operation, and then each element flows through all the steps one at a time, not stage by stage:

Stream.of("a", "bb", "ccc", "dddd")
      .filter(s -> { System.out.println("filter " + s); return s.length() > 1; })
      .map(s -> { System.out.println("map " + s); return s.toUpperCase(); })
      .findFirst();

// Output:
// filter a
// filter bb
// map bb          ← "ccc" and "dddd" are never touched: findFirst already has its answer

This is what makes short-circuiting operations (findFirst, anyMatch, limit, takeWhile) efficient. It also means a pipeline with no terminal operation does nothing at all. That's a common bug when someone expects peek or map alone to run.

The operations you'll use most

List<Order> orders = ...;

// filter + map + collect
List<String> ids = orders.stream()
        .filter(o -> o.total().compareTo(new BigDecimal("100")) > 0)
        .map(Order::id)
        .toList();

// primitive streams avoid boxing and add numeric terminals
double avg = orders.stream().mapToDouble(o -> o.total().doubleValue()).average().orElse(0);
int totalItems = orders.stream().mapToInt(Order::itemCount).sum();

// search and match
Optional<Order> firstLarge = orders.stream().filter(o -> o.itemCount() > 10).findFirst();
boolean anyUnpaid = orders.stream().anyMatch(o -> !o.paid());

// sorting with a comparator
List<Order> newestFirst = orders.stream()
        .sorted(Comparator.comparing(Order::createdAt).reversed())
        .limit(20)
        .toList();

// flatMap: one-to-many, then flatten
List<String> allSkus = orders.stream()
        .flatMap(o -> o.lines().stream())       // Stream<Order> → Stream<OrderLine>
        .map(OrderLine::sku)
        .distinct()
        .toList();

Grouping and aggregating into maps (groupingBy, partitioningBy, toMap) have their own lesson, Collectors and groupingBy.

reduce: combining elements into one value

int sum = IntStream.rangeClosed(1, 5).reduce(0, Integer::sum);   // 15

BigDecimal revenue = orders.stream()
        .map(Order::total)
        .reduce(BigDecimal.ZERO, BigDecimal::add);

The first argument is the identity: the starting value, which must leave any element unchanged when combined with it (0 for addition, 1 for multiplication). The combining function must be associative, meaning (a+b)+c == a+(b+c). That's what lets the work be split up in a parallel stream.

Stateless vs stateful operations

  • Stateless (filter, map, flatMap) look at one element at a time. They stream through with almost no memory.
  • Stateful (sorted, distinct) must see all (or many) elements before they can emit anything. sorted() buffers the whole stream, so on huge or infinite streams it can use a lot of memory or never finish.

Infinite streams

Stream.iterate(1, n -> n * 2).limit(10).toList();           // 1, 2, 4, ... 512
Stream.iterate(1, n -> n < 1000, n -> n * 2).toList();      // Java 9: with a stop condition
Stream.generate(UUID::randomUUID).limit(5).toList();

An infinite stream is fine as long as something short-circuits it (limit, takeWhile, findFirst).

Rules and gotchas

A stream can be consumed only once:

Stream<String> s = names.stream();
s.count();
s.count();     // IllegalStateException: stream has already been operated upon or closed

Don't mutate the source, and avoid side effects in lambdas:

// ❌ modifying external state from inside a pipeline — breaks in parallel, hard to reason about
List<String> result = new ArrayList<>();
names.stream().filter(n -> n.length() > 3).forEach(result::add);

// ✅ let the terminal operation build the result
List<String> result = names.stream().filter(n -> n.length() > 3).toList();

toList() vs collect(Collectors.toList()): Stream.toList() (Java 16) returns an unmodifiable list. Collectors.toList() makes no promise, and currently returns a mutable ArrayList. Calling add on the result of toList() throws UnsupportedOperationException.

peek is for debugging, not for doing real work. It may not run for every element when the pipeline short-circuits or the size is already known.

Parallel streams: rarely the right answer

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

A parallel stream splits the work across the shared common ForkJoinPool (one thread per CPU core, minus one). It helps only when all of these are true:

  • the data set is large (tens of thousands of elements or more),
  • the work per element is CPU-heavy,
  • the source splits efficiently (ArrayList and arrays do; LinkedList and I/O-backed streams don't),
  • the operations are stateless and free of side effects.

It actively hurts in a web server when the lambdas do blocking I/O (database or HTTP calls). Every parallel stream in the JVM shares that one small pool, so a few slow requests can stall parallel work everywhere. Measure before and after, and default to sequential.

Streams vs loops

Streams read well for transform, filter, aggregate pipelines. A plain loop is often clearer when you need to break out early with complex conditions, update several variables at once, or throw checked exceptions. Checked exceptions are awkward inside lambdas. Use whichever reads better. Performance is usually similar for sequential code.

Follow-up questions this topic invites — and their answers

Q: What's the difference between intermediate and terminal operations? A: Intermediate operations (filter, map, sorted...) return a new stream and are lazy: they only describe work. A terminal operation (toList, count, forEach, reduce...) returns a non-stream result, triggers the whole pipeline, and consumes the stream. Without a terminal operation nothing executes.

Q: What's the difference between map and flatMap? A: map turns each element into exactly one new element, so a Stream<Order> becomes a Stream<List<Line>>. flatMap turns each element into a stream of zero or more elements and flattens them into one stream, so Stream<Order> becomes Stream<Line>. Use flatMap whenever each element expands into several.

Q: Is a stream faster than a for-loop? A: Not inherently. Sequential streams have a small overhead (lambdas, pipeline objects), and the JIT usually makes the difference negligible. Streams win on readability for data transformations. Primitive streams (IntStream) avoid boxing, which matters for numeric work. Parallel streams can be faster for large CPU-bound tasks, and slower or dangerous otherwise.

Q: Why can't a stream be reused? A: A stream is a one-shot pipeline over its source, not a data structure. Once a terminal operation has pulled the elements through, its internal state is used up. If you need to process the data twice, keep the source collection and call .stream() again, or pass around a Supplier<Stream<T>>.

Q: When does sorted() hurt performance? A: sorted() is stateful. It must buffer every element before emitting the first, so it costs O(n log n) time and O(n) memory, and it defeats short-circuiting before it. If you only need the top few, it's still simpler to sorted().limit(k) for moderate sizes, but for very large inputs a bounded PriorityQueue or a database ORDER BY ... LIMIT is better.

Previous

Method References

Next

Collectors and groupingBy

AI Tutor

Lesson: Streams API

Quick actions

AI responses can be inaccurate. Verify critical information.