Skip to content
SparkTime, home
Java From Zero to Job-Ready
Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
12 · Interviews and Readiness
Lesson 16 of 41

Lesson 16 · Functional Java

Streams in Practice (and When Not to Use Them)

How to process collections declaratively with Java Streams: filter, map, flatMap, sorted, reduce and collect, grouping and partitioning, why streams are lazy and single-use, what parallel streams really cost, and an honest guide to when a plain loop is the better choice.

On this page

Key takeaways

  • A stream is a pipeline, not a data structure: a source, zero or more lazy intermediate operations such as filter and map, and one terminal operation that runs the whole thing. A stream can be consumed only once.
  • Collectors such as groupingBy, partitioningBy, counting, joining and toMap turn a stream into a map, a count or a string. toMap throws on duplicate keys unless you give a merge function.
  • Keep stream operations free of side effects and shared mutable state. Use primitive streams (IntStream and friends) for numbers to avoid boxing.
  • Parallel streams are not a free speed-up. They help only for large, CPU-bound, independent work on a splittable source, and they can make code slower or wrong.
  • Do not use a stream when a loop is clearer: complex control flow, early exits with side effects, checked exceptions, index access or a pipeline too long to read at a glance. Readability decides.

Look at this familiar piece of work: from a list of job applications, find the companies with an interview stage, remove duplicates, and sort them. In a loop version you create a result list, a set to track duplicates, an if for the stage, a sort at the end, and a mental note to keep them all consistent. The what (companies, interview stage, unique, sorted) is buried under the how (index, temporary variables, add and check).

Streams let you write the what as a pipeline, and let the library handle the how. Used well, they make data-processing code shorter and clearer. Used carelessly, they produce unreadable chains that are harder to debug than loops. This article teaches both the technique and the judgement.

What you will learn

#
  • What a stream pipeline is, and why streams are lazy and single-use.
  • The core operations: filter, map, flatMap, sorted, reduce, collect.
  • How collectors group, partition, join and build maps.
  • Primitive streams.
  • Why parallel streams are not a default.
  • When a plain loop is the better tool.

From loop to pipeline

#
Java
// File: Main.javaimport java.util.ArrayList;import java.util.List;import java.util.TreeSet;public class Main {    enum Stage { APPLIED, INTERVIEW, OFFER }    record Application(String company, Stage stage, int daysOld) {}    public static void main(String[] args) {        List<Application> apps = List.of(                new Application("Globex", Stage.INTERVIEW, 5),                new Application("Acme", Stage.APPLIED, 20),                new Application("Initech", Stage.INTERVIEW, 9),                new Application("Globex", Stage.INTERVIEW, 2),                new Application("Umbrella", Stage.OFFER, 30));        // The loop version: the "how" dominates        TreeSet<String> unique = new TreeSet<>();        for (Application app : apps) {            if (app.stage() == Stage.INTERVIEW) {                unique.add(app.company());            }        }        List<String> fromLoop = new ArrayList<>(unique);        // The stream version: the "what" reads in order        List<String> fromStream = apps.stream()                .filter(app -> app.stage() == Stage.INTERVIEW)                .map(Application::company)                .distinct()                .sorted()                .toList();        System.out.println(fromLoop);        System.out.println(fromStream);        System.out.println(fromLoop.equals(fromStream));    }}
Code
[Globex, Initech][Globex, Initech]true

The stream reads almost like the sentence in the first paragraph. Read the pipeline top to bottom: take the applications, keep those in the interview stage, extract the company, remove duplicates, sort, collect into a list.

The mental model

#

The documentation puts the three key facts about streams clearly: a stream is not a data structure that stores elements, it conveys elements from a source through a pipeline of operations; an operation on a stream produces a result but does not modify its source; and the elements of a stream are visited only once.

A pipeline has three parts:

  1. A source: a collection (list.stream()), an array (Arrays.stream(array)), a range (IntStream.range(0, 10)), a generator (Stream.iterate) or a file's lines.
  2. Zero or more intermediate operations, each returning a new stream: filter, map, flatMap, distinct, sorted, limit, skip, peek. They are lazy: calling them does nothing yet.
  3. One terminal operation that produces a result or a side effect and starts the whole pipeline: collect, toList, forEach, reduce, count, findFirst, anyMatch.

Laziness is easy to forget and important to understand, so watch it happen:

Java
// File: Main.javaimport java.util.List;import java.util.stream.Stream;public class Main {    public static void main(String[] args) {        Stream<String> pipeline = List.of("a", "bb", "ccc", "dd").stream()                .peek(s -> System.out.println("filtering " + s))                .filter(s -> s.length() >= 2)                .peek(s -> System.out.println("  passed " + s))                .map(String::toUpperCase);        System.out.println("pipeline built, nothing has run yet");        String first = pipeline.findFirst().orElse("none");     // terminal: starts the work        System.out.println("result: " + first);        try {            pipeline.count();                                    // a second use of the same stream        } catch (IllegalStateException e) {            System.out.println("stream already used: " + e.getMessage());        }    }}
Code
pipeline built, nothing has run yetfiltering afiltering bb  passed bbresult: BBstream already used: stream has already been operated upon or closed

Three things stand out. Nothing ran until findFirst. The elements were processed one at a time through the whole pipeline (each element goes through filter and map before the next starts), not stage by stage. And findFirst is short-circuiting: once it had its answer ("BB"), it stopped, so "ccc" and "dd" were never examined. Finally, a stream can be consumed once; reusing it throws IllegalStateException. If you need the data twice, keep the source and create a new stream each time.

(peek is meant for debugging a pipeline like this. Do not use it to do real work.)

The core operations

#

map and filter

#

filter(predicate) keeps elements that satisfy a Predicate. map(function) transforms each element into another. Together they cover most of what loops do.

flatMap: flattening

#

map turns each element into one element. What if each element produces a list of results and you want a single flat sequence? flatMap takes a function that returns a stream for each element and concatenates them all.

Java
// File: Main.javaimport java.util.List;public class Main {    record Candidate(String name, List<String> skills) {}    public static void main(String[] args) {        List<Candidate> candidates = List.of(                new Candidate("Asha", List.of("Java", "SQL")),                new Candidate("Ravi", List.of("Java", "Docker", "Git")),                new Candidate("Meena", List.of()));        System.out.println(candidates.stream().map(Candidate::skills).toList());          // a list of lists        System.out.println(candidates.stream().flatMap(c -> c.skills().stream()).distinct().sorted().toList());        System.out.println(candidates.stream().mapToInt(c -> c.skills().size()).sum());    }}
Code
[[Java, SQL], [Java, Docker, Git], []][Docker, Git, Java, SQL]5

map(Candidate::skills) gives a list of lists, which is rarely what you want. flatMap flattens it into one stream of skills. Use it whenever you would otherwise write nested loops.

Terminal operations

#

Operation

Result

collect(collector)

Build a collection, map, string or summary

toList()

An unmodifiable List (Java 16)

forEach(consumer)

Perform an action per element (a side effect)

reduce(identity, combiner)

Combine all elements into one value

count()

The number of elements

min(comparator), max(comparator)

An Optional of the extreme element

anyMatch, allMatch, noneMatch

A boolean, short-circuiting

findFirst(), findAny()

An Optional of an element

Terminal operations that may find nothing (findFirst, min, max) return an Optional, which article 17 explains.

reduce

#

reduce combines elements with a function. reduce(0, Integer::sum) starts at 0 and adds each element. The function should be associative and stateless, which is what allows a reduction to be run in parallel. For sums and averages of numbers, prefer the dedicated methods on primitive streams below.

Collectors: building results

#

collect takes a Collector, and the Collectors class provides the useful ones. These are the ones you will use most.

Java
// File: Main.javaimport java.util.List;import java.util.Map;import java.util.TreeMap;import java.util.stream.Collectors;public class Main {    enum Stage { APPLIED, INTERVIEW, OFFER }    record Application(String company, Stage stage, int daysOld, int salaryLakhs) {}    public static void main(String[] args) {        List<Application> apps = List.of(                new Application("Globex", Stage.INTERVIEW, 5, 18),                new Application("Acme", Stage.APPLIED, 20, 12),                new Application("Initech", Stage.INTERVIEW, 9, 15),                new Application("Globex", Stage.APPLIED, 2, 20),                new Application("Umbrella", Stage.OFFER, 30, 25));        Map<Stage, Long> countByStage = apps.stream()                .collect(Collectors.groupingBy(Application::stage, TreeMap::new, Collectors.counting()));        System.out.println(countByStage);        Map<Stage, List<String>> companiesByStage = apps.stream()                .collect(Collectors.groupingBy(Application::stage, TreeMap::new,                        Collectors.mapping(Application::company, Collectors.toList())));        System.out.println(companiesByStage);        Map<String, Double> avgSalaryByCompany = apps.stream()                .collect(Collectors.groupingBy(Application::company, TreeMap::new,                        Collectors.averagingInt(Application::salaryLakhs)));        System.out.println(avgSalaryByCompany);        Map<Boolean, List<String>> stale = apps.stream()                .collect(Collectors.partitioningBy(a -> a.daysOld() > 14,                        Collectors.mapping(Application::company, Collectors.toList())));        System.out.println(stale);        System.out.println(apps.stream().map(Application::company).distinct().sorted().collect(Collectors.joining(", ", "[", "]")));        Map<String, Integer> topSalaryByCompany = apps.stream()                .collect(Collectors.toMap(Application::company, Application::salaryLakhs, Math::max, TreeMap::new));        System.out.println(topSalaryByCompany);        try {            apps.stream().collect(Collectors.toMap(Application::company, Application::salaryLakhs));        } catch (IllegalStateException e) {            System.out.println("toMap without a merge function: " + e.getMessage());        }        var stats = apps.stream().mapToInt(Application::salaryLakhs).summaryStatistics();        System.out.println(stats.getMin() + " " + stats.getMax() + " " + stats.getAverage() + " " + stats.getSum());    }}
Code
{APPLIED=2, INTERVIEW=2, OFFER=1}{APPLIED=[Acme, Globex], INTERVIEW=[Globex, Initech], OFFER=[Umbrella]}{Acme=12.0, Globex=19.0, Initech=15.0, Umbrella=25.0}{false=[Globex, Initech, Globex], true=[Acme, Umbrella]}[Acme, Globex, Initech, Umbrella]{Acme=12, Globex=20, Initech=15, Umbrella=25}toMap without a merge function: Duplicate key Globex (attempted merging values 18 and 20)12 25 18.0 90

What each collector does:

  • groupingBy(classifier) groups elements into a map from key to list. With a downstream collector as a second argument (counting(), mapping(...), averagingInt(...), summingInt(...)) it summarises each group instead of listing it. I passed TreeMap::new as a map factory so the output is sorted and the example is repeatable. Without it you get a HashMap with no guaranteed order.
  • partitioningBy(predicate) is a grouping with exactly two groups, true and false, both always present.
  • joining(separator, prefix, suffix) concatenates strings, with no trailing-separator problem.
  • toMap(key, value, merge, factory) builds a map. If two elements produce the same key and you gave no merge function, it throws IllegalStateException (shown above). Always think about duplicates, and supply a merge function (here Math::max keeps the larger salary) when they are possible.
  • summaryStatistics() on a primitive stream computes the count, sum, minimum, average and maximum in one pass.

Primitive streams

#

Stream<Integer> boxes each number into an object. For numeric work use IntStream, LongStream and DoubleStream, which hold raw primitives and provide sum(), average(), min(), max() and range. Convert with mapToInt, mapToLong, mapToDouble, and back with boxed().

Java
// File: Main.javaimport java.util.stream.IntStream;public class Main {    public static void main(String[] args) {        System.out.println(IntStream.rangeClosed(1, 5).sum());        System.out.println(IntStream.rangeClosed(1, 5).average().getAsDouble());        System.out.println(IntStream.range(0, 20).filter(n -> n % 3 == 0).boxed().toList());        System.out.println(IntStream.rangeClosed(1, 5).reduce(1, (a, b) -> a * b));        System.out.println("hello".chars().filter(c -> c == 'l').count());    }}
Code
153.0[0, 3, 6, 9, 12, 15, 18]1202

Parallel streams: not a free lunch

#

Changing stream() to parallelStream() splits the work across multiple CPU cores, and the package documentation shows that the only difference is how the stream is created. It is tempting to treat this as a free speed-up. It is not.

Parallel streams help only when all of these hold:

  • the data set is large enough that splitting pays for its overhead;
  • the work per element is CPU-bound and substantial;
  • the source splits well (arrays and ArrayLists do, linked lists and I/O do not);
  • the operations are stateless, non-interfering and associative, as the documentation requires;
  • you do not need the encounter order in the result.

Parallel streams run on a shared pool (the common fork-join pool), so one slow task can hold up unrelated parts of the program, and blocking calls inside them are especially harmful. Side effects such as adding to a shared ArrayList from inside the pipeline cause lost updates and corrupted data. The documentation warns that side effects in stream operations are discouraged, and that stateful operations can produce nondeterministic or incorrect results. The honest rule: write a normal stream first, measure, and consider parallel only when you have evidence that it helps and the work is independent. For I/O-heavy concurrency, the tools in article 20 are the right ones.

When NOT to use streams

#

Streams are good at transforming and summarising data. They are poor at some things, and using them anyway produces code that is harder to read and debug than the loop it replaced.

Prefer a loop when:

  • The control flow is complicated: several conditions, break, continue, or early return from the middle.
  • You are doing side effects such as updating several variables, mutating objects or collecting into more than one result. (A forEach that adds to an outside list is a loop in disguise, and a worse one.)
  • You need the index of each element.
  • Checked exceptions are involved, because lambdas and checked exceptions fit badly (article 15).
  • The loop is already clear and short. A stream is not automatically better.
  • You need to debug step by step. Setting a breakpoint inside a long pipeline is harder than in a loop.

Signs that a pipeline has become unreadable: more than about five stages, nested streams inside lambdas, multi-line lambdas, anonymous lambdas that repeat, or a reader who must mentally unpack it to understand it. Compare these two versions that compute the same result:

Java
// File: Main.javaimport java.util.List;import java.util.Map;import java.util.TreeMap;import java.util.stream.Collectors;public class Main {    record Order(String customer, List<Integer> itemPrices, boolean cancelled) {}    // Hard to read: everything inline    static Map<String, Integer> dense(List<Order> orders) {        return orders.stream().filter(o -> !o.cancelled() && o.itemPrices().stream().anyMatch(p -> p > 100))                .collect(Collectors.groupingBy(Order::customer, TreeMap::new,                        Collectors.summingInt(o -> o.itemPrices().stream().filter(p -> p > 100).mapToInt(Integer::intValue).sum())));    }    // Same result, with names for the ideas    static boolean isActive(Order order) {        return !order.cancelled();    }    static int premiumTotal(Order order) {        return order.itemPrices().stream().filter(price -> price > 100).mapToInt(Integer::intValue).sum();    }    static Map<String, Integer> readable(List<Order> orders) {        return orders.stream()                .filter(Main::isActive)                .filter(order -> premiumTotal(order) > 0)                .collect(Collectors.groupingBy(Order::customer, TreeMap::new,                        Collectors.summingInt(Main::premiumTotal)));    }    public static void main(String[] args) {        List<Order> orders = List.of(                new Order("asha", List.of(50, 150, 200), false),                new Order("ravi", List.of(20, 30), false),                new Order("asha", List.of(500), true),                new Order("meena", List.of(120), false));        System.out.println(dense(orders));        System.out.println(readable(orders));        System.out.println(dense(orders).equals(readable(orders)));    }}
Code
{asha=350, meena=120}{asha=350, meena=120}true

Both versions produce the same answer. The second extracts named methods (isActive, premiumTotal) so that the pipeline reads as intent. When a stream gets complicated, extract methods and give things names, or switch to a loop. The measure of a good pipeline is whether a colleague can read it quickly and correctly.

Common mistakes

#
  • Reusing a stream after a terminal operation.
  • Forgetting the terminal operation, so nothing happens at all.
  • Modifying the source collection during the pipeline.
  • Using forEach with side effects to build a result, instead of collect.
  • toMap with duplicate keys and no merge function.
  • Calling get() on the Optional from findFirst without checking it.
  • Parallel streams everywhere, and with shared mutable state.
  • Streams of boxed numbers where a primitive stream fits.
  • Long unreadable chains that should have been loops or named methods.
  • Using peek for business logic, since it may not run if the terminal operation short-circuits.

Exercises

#

Exercise 1

Translate

#

Rewrite as a stream: int total = 0; for (String s : words) { if (s.length() > 3) { total += s.length(); } }.

Exercise 2: Predict the laziness

#

What is printed? Stream.of(1, 2, 3, 4).filter(n -> { System.out.print("f" + n + " "); return n % 2 == 0; }).map(n -> { System.out.print("m" + n + " "); return n * 10; }).findFirst();

Exercise 3: Group and count

#

Given a list of words, build a Map<Integer, Long> from word length to the number of words with that length, sorted by length.

Exercise 4: Fix the bug

#

A developer wrote List<String> result = new ArrayList<>(); names.parallelStream().filter(n -> !n.isBlank()).forEach(result::add); and sometimes the list has missing elements or throws an exception. Explain, and fix it.

Exercise 5: Loop or stream?

#

For each task, choose a stream or a loop and justify: (a) "list the distinct upper-case initials of all names", (b) "scan until the first invalid record, logging each valid one, and stop", (c) "total price of all non-cancelled orders".

Show solutionHide solution
Java
int total = words.stream()        .filter(s -> s.length() > 3)        .mapToInt(String::length)        .sum();

filter keeps the long words, mapToInt converts to a primitive stream of lengths, and sum adds them. Using mapToInt avoids boxing and gives you sum() directly.

Exercise 2

Show solutionHide solution

It prints f1 f2 m2 . Elements flow through the pipeline one at a time. 1 is filtered out (f1). 2 passes the filter (f2) and is mapped (m2). findFirst has its answer, so it stops: 3 and 4 are never examined. This shows both laziness and short-circuiting.

Exercise 3

Show solutionHide solution
Java
Map<Integer, Long> byLength = words.stream()        .collect(Collectors.groupingBy(String::length, TreeMap::new, Collectors.counting()));

TreeMap::new makes the keys sorted. The downstream collector counting() replaces the default list of words per group with the count.

Exercise 4

Show solutionHide solution

ArrayList is not thread-safe, and a parallel stream calls add from several threads at once, so updates can be lost or the list can be corrupted. The stateful side effect also violates the documentation's rule for stream operations. Fix it by letting the stream build the result: List<String> result = names.stream().filter(n -> !n.isBlank()).toList();, and use parallelStream() only if measurement justifies it, in which case collect/toList are still thread-safe because the framework combines partial results.

Exercise 5

Show solutionHide solution

(a) Stream: names.stream().map(n -> n.substring(0, 1).toUpperCase()).distinct().toList() reads naturally. (b) Loop: it needs a side effect (logging), and an early stop at the first invalid record, which is awkward in a pipeline. (c) Stream: orders.stream().filter(o -> !o.cancelled()).mapToLong(Order::total).sum() is a clear filter-and-sum.

Where to go next

#

Sources & further reading

  1. docs.oracle.com
  • Guide · Technology

    Heaps and Priority Queues

  • Guide · Technology

    Dynamic Programming Practice: From 1D to 2D

  • Course · Technology

    DSA From Zero to Job-Ready: A Practical Guide with Java