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
- 21File I/O and Serialization
- 22Debugging: Errors, Stack Traces, the Debugger and Logging
- 23Maven: What It Does and How to Use It
- 24Git for Java Projects: Commits, Branches, Merging and Pull Requests
- 25Testing with JUnit and Mockito: Unit Tests, Mocks and Their Limits
- 26Project 1: Build an Expense Tracker CLI in Plain Java
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
- 36Security Fundamentals and Spring Security: Passwords, JWT, Access Control, CORS and Secrets
- 37Testing Spring Boot Applications: Slice Tests, MockMvc, Real Databases and the Test Pyramid
- 38Production Practices: Configuration, Logging, Docker and Health Checks for Spring Boot
- 39Project 3: The Job Application Tracker API, Assembled and Reviewed
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.
For
Learners who know lambdas and collections and want to use streams confidently and know their limits.
Level
Before you start
Lambdas, Functional Interfaces and Method References (article 15) and the collections articles (11 to 13).
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
#// 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)); }}[Globex, Initech][Globex, Initech]trueThe 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:
- 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. - 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. - 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:
// 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()); } }}pipeline built, nothing has run yetfiltering afiltering bb passed bbresult: BBstream already used: stream has already been operated upon or closedThree 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.
// 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()); }}[[Java, SQL], [Java, Docker, Git], []][Docker, Git, Java, SQL]5map(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 |
|---|---|
| Build a collection, map, string or summary |
| An unmodifiable |
| Perform an action per element (a side effect) |
| Combine all elements into one value |
| The number of elements |
| An |
| A |
| An |
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.
// 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()); }}{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 90What 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 passedTreeMap::newas a map factory so the output is sorted and the example is repeatable. Without it you get aHashMapwith no guaranteed order.partitioningBy(predicate)is a grouping with exactly two groups,trueandfalse, 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 throwsIllegalStateException(shown above). Always think about duplicates, and supply a merge function (hereMath::maxkeeps 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().
// 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()); }}153.0[0, 3, 6, 9, 12, 15, 18]1202Parallel 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 earlyreturnfrom the middle. - You are doing side effects such as updating several variables, mutating objects or collecting into more than one result. (A
forEachthat 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:
// 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))); }}{asha=350, meena=120}{asha=350, meena=120}trueBoth 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
forEachwith side effects to build a result, instead ofcollect. toMapwith duplicate keys and no merge function.- Calling
get()on theOptionalfromfindFirstwithout 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
peekfor 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
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
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
#- Next: Null and Optional explains the
Optionalvalues that streams return. - For the concurrency tools that should replace casual parallel streams: Executors, Futures, Concurrent Collections and Virtual Threads.
- For streams over files: File I/O and Serialization.
Sources & further reading
- Technology
- Software Engineering
- #functional programming
- #Java
- #Java backend
- #Java interview
- #Java streams
- #JPA
- #learn Java
- #modern Java
- #Spring Boot
- #SQL
Connected lessons
Linked from 5 guides
Other pages in the library that point here.
Keep reading
More in Technology →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

