Java Stream API
Stop writing loops to filter, transform, and summarise data. Learn to build readable stream pipelines that say what you want, not how to loop for it.
Learn Java Stream API in our free Java course — a beginner-friendly interactive lesson with worked examples, a practice exercise and a quick reference.
Part of the free Java course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
Before You Start
You should be comfortable with Collections ( List , Map ) and the basics of lambda expressions — the short x -> x * 2 functions you pass to stream operations. A stream is built on top of a collection, so if you can create a List < String > you're ready.
What You'll Learn in This Lesson
🏭 Real-World Analogy: A Factory Assembly Line
A stream pipeline works exactly like a factory assembly line. Raw materials (your collection) enter at one end. They pass through a series of stations — one keeps only the good parts ( filter ), one reshapes each part ( map ), one puts them in order ( sorted ). At the very end, a worker boxes up the finished goods ( collect ).
💡 The key insight: the conveyor belt does not move until someone at the end asks for a finished product. The middle stations (intermediate operations) are just instructions written on a clipboard. Only the final terminal operation switches the belt on. That is lazy evaluation , and it is what makes streams efficient.
1️⃣ Creating a Stream
A stream is a one-shot sequence of elements you push through a pipeline. You don't store data in a stream — you flow data through one. There are three common ways to start a stream:
- collection.stream() — the usual starting point, from any List < T > , Set , etc.
- Stream.of(a, b, c) — when you have loose values, not a collection.
- IntStream.range(0, n) — a stream of ints, replacing index-based for loops. The end is exclusive ; use rangeClosed to include it.
2️⃣ Intermediate Operations (the Recipe)
Intermediate operations each take a stream and return a new stream, so you can chain them. They are lazy — calling them just records a step; no data moves yet. The ones you'll reach for constantly:
Operation
What it does
Example
filter
Keep elements matching a test
.filter(n -> n > 10)
map
Transform each element
.map(String::toUpperCase)
sorted
Order the elements
.sorted()
distinct
Drop duplicates
.distinct()
limit
Keep only the first N
.limit(3)
3️⃣ Terminal Operations & Collectors (the Finished Product)
A terminal operation is what finally runs the pipeline and produces a result. After it runs, the stream is consumed. The common ones:
- collect(...) — gather elements into a collection, driven by a Collector .
- toList() — a shortcut (Java 16+) for collecting into an unmodifiable List .
- forEach(...) — perform a side effect (like printing) on each element.
- reduce(...) — fold every element into a single value (a sum, a max).
- count() — how many elements made it through.
The real power lives in the Collectors class, which you pass to collect :
- Collectors.toList() — gather into a List .
- Collectors.toMap(keyFn, valueFn) — build a Map lookup.
- Collectors.groupingBy(classifier) — bucket elements by a key, like SQL GROUP BY .
- Collectors.joining(", ") — concatenate strings with a separator.
🎯 Your Turn #1: Filter Big Numbers
Finish the pipeline below. Fill in the filter predicate so only numbers greater than 10 survive. The expected output is in the comment — run it on your machine and check.
🎯 Your Turn #2: Map Words to Lengths
This time fill in two blanks: the intermediate operation that transforms each element, and the expression that gives a word's length. Compare with the expected output in the comment.
4️⃣ Lazy Evaluation & Parallel Streams
Lazy evaluation means the pipeline does nothing until a terminal operation pulls data through. Intermediate operations are fused into a single pass, so .filter().map().filter() does not create three temporary lists — each element flows through the whole chain at once. Short-circuiting operations like limit and findFirst can stop early, so the source is never fully processed.
A parallel stream ( .parallel() or collection.parallelStream() ) splits the work across CPU cores using the shared ForkJoinPool. It can be dramatically faster — but only sometimes.
Mini-Challenge: Big Spenders
Time to fly solo. The starter below has only a comment outline — no filled-in logic. Build a single pipeline that finds the customers who spent $100 or more, sorted alphabetically. The expected output is in the comments.
Common Errors (and How to Fix Them)
- ❌ Reusing a consumed stream: IllegalStateException: stream has already been operated upon or closed . A stream is one-shot. Don't store it in a variable and run two terminal ops on it — call list.stream() again to get a fresh one.
- ❌ Side effects inside map(): printing or mutating an external list from map(x -> { list.add(x); return x; } ) looks fine sequentially but corrupts data under parallel() . Keep map pure; do side effects in forEach .
- ❌ Forgetting the terminal operation: list.stream().filter(...).map(...); compiles and runs but does nothing — intermediate ops are lazy. Nothing happens until you add .collect(...) , .forEach(...) , or .toList() .
- ❌ Parallel misuse: calling .parallel() on a 10-item list, or reducing into a shared ArrayList , is slower or outright buggy. Use parallel only for large, stateless, CPU-bound work with an associative reduction.
- ❌ Duplicate keys in toMap(): IllegalStateException: Duplicate key when two elements map to the same key. Pass a merge function: Collectors.toMap(k, v, (a, b) -> a) .
📋 Quick Reference
Goal
Code
Type
Stream from a list
list.stream()
Source
Stream from values
Stream.of(a, b, c)
Range of ints
IntStream.range(0, n)
Keep matching
.filter(pred)
Intermediate
Transform each
.map(fn)
Order / dedupe / cap
.sorted() .distinct() .limit(n)
Collect to list
.toList()
Terminal
Group by key
.collect(groupingBy(fn))
Build a map
.collect(toMap(k, v))
Join strings
.collect(joining(", "))
Fold to one value
.reduce(0, Integer::sum)
Run in parallel
.parallel()
Modifier
❓ Frequently Asked Questions
🎉 Lesson Complete!
You can now build complete stream pipelines — create a stream with .stream() , Stream.of , or IntStream.range , chain intermediate ops like filter and map , and finish with a terminal op such as collect , reduce , or toList . You also understand lazy evaluation and when parallel streams help.
Next: Lambda Expressions Deep Dive — functional interfaces, method references, and the lambdas that power every stream operation you just learned.
Practice quiz
What does List.of("Alice","Bob","Charlie","Bob").stream().filter(n -> n.length() > 3).distinct().map(String::toUpperCase).sorted().toList() produce?
filter drops "Bob" (length 3), distinct removes the duplicate, map uppercases, sorted orders them: [ALICE, CHARLIE].
What is the difference between an intermediate and a terminal operation?
- Intermediate ops return a new stream and are lazy; the terminal op runs the pipeline and produces a result
- Intermediate ops run immediately; terminal ops are lazy
- There is no difference
- Terminal ops can be chained; intermediate ops cannot
Answer: Intermediate ops return a new stream and are lazy; the terminal op runs the pipeline and produces a result. Intermediate ops (filter, map, sorted) are lazy and just describe the pipeline. A terminal op (collect, reduce, toList) actually runs it.
What does IntStream.range(1, 4).sum() return?
- 10
- 4
- 3
- 6
Answer: 6. range's end is EXCLUSIVE, so it streams 1, 2, 3 (not 4). 1 + 2 + 3 = 6.
What does List.of(1,2,3,4,5).stream().map(n -> n * 2).reduce(0, Integer::sum) return?
- 15
- 30
- 120
- 10
Answer: 30. map doubles each element to 2,4,6,8,10, then reduce folds them starting at 0: 2+4+6+8+10 = 30.
Why can't you reuse a stream after calling a terminal operation?
- A stream is a one-shot pipeline; once consumed it throws IllegalStateException
- Streams are thread-local
- The garbage collector deletes it
- You can reuse it freely
Answer: A stream is a one-shot pipeline; once consumed it throws IllegalStateException. A stream is single-use. Operating on a consumed stream throws 'stream has already been operated upon or closed'. Make a fresh one from the source.
Which terminal operation gathers stream elements into a new List (Java 16+)?
- .forEach()
- .filter()
- .toList()
- .map()
Answer: .toList(). toList() (Java 16+) is a shortcut that collects into an unmodifiable List. filter and map are intermediate ops.
When are parallel streams actually worth using?
- For any stream, always
- For large datasets with stateless, CPU-bound work
- Only for tiny collections
- Whenever you share mutable state
Answer: For large datasets with stateless, CPU-bound work. Parallel streams help only for large datasets and stateless, CPU-bound work. For small collections the splitting overhead makes them slower.
What does Collectors.joining(", ", "[", "]") do to a stream of name strings?
- Sorts the names
- Removes duplicates
- Counts the names
joining(delimiter, prefix, suffix) concatenates with ", " between elements and wraps with [ ... ], e.g. [Apple, Banana].
What does a stream do if you forget the terminal operation, e.g. list.stream().filter(...).map(...);?
- It runs the pipeline anyway
- Nothing runs - intermediate ops are lazy
- It throws a compile error
- It prints each element
Answer: Nothing runs - intermediate ops are lazy. Intermediate ops are lazy. Without a terminal op like collect, forEach, or toList, the pipeline never executes.
What is Collectors.groupingBy most like?
- SQL ORDER BY
- SQL SELECT *
- SQL GROUP BY
- SQL DELETE
Answer: SQL GROUP BY. groupingBy(classifier) buckets elements by a key, exactly like SQL GROUP BY, producing a Map of key to list of elements.
Continue this course
- Previous: Previous Lesson
- Next: Next Lesson