Streams: map, filter, collect
This is the module most college syllabuses skip entirely, and the one that most
marks out modern Java from the Java of 2010. A team that has been writing Java
since Java 8 expects streams in a pull request the way they expect List rather
than an array.
The idea is small: describe what you want from a collection instead of how to loop over it.
The same thing, twice
Uppercase the names of everyone who took more than twenty tiffins.
List<String> result = new ArrayList<>();
for (Sub s : subs) {
if (s.tiffins() > 20) {
result.add(s.name().toUpperCase());
}
}
List<String> result = subs.stream()
.filter(s -> s.tiffins() > 20)
.map(s -> s.name().toUpperCase())
.toList();
[PRIYA, KAVITA, RAHUL]
The loop mixes three decisions — which ones, what to extract, where to put them —
into one body. The stream states them in order, each on its own line. Reading
filter then map then toList tells you the shape without reading any of the
lambdas.
That readability is the reason to use streams. They are not faster. For small collections they are marginally slower. What you get is code whose intent is visible.
The anatomy
Every stream is three parts:
subs.stream() source
.filter(...) .map(...) intermediate operations (lazy, return a Stream)
.toList() terminal operation (runs everything, returns a result)
Nothing happens until the terminal operation.
Stream<Sub> pending = subs.stream().peek(s -> System.out.println("never printed " + s.name()));
System.out.println("built the pipeline, ran nothing");
built the pipeline, ran nothing
That is laziness, and it is not just a curiosity:
subs.stream()
.peek(s -> System.out.println(" filtering " + s.name()))
.filter(s -> s.tiffins() > 25)
.map(Sub::name)
.limit(1)
.toList();
filtering Priya
result [Priya]
Five subscribers, one element examined. The elements flow through the pipeline
one at a time, and limit(1) stopped the whole thing after the first match.
A loop doing the same would need a break.
A stream is single use
Stream<Sub> once = subs.stream();
System.out.println(once.count());
once.count();
5
threw: stream has already been operated upon or closed
A stream is a pipeline, not a collection. To process the same data twice, call
.stream() twice. If you find yourself wanting to keep one in a variable and use
it later, you almost certainly want the collection instead.
Intermediate operations
| Operation | Does |
|---|---|
filter(predicate) |
Keeps matching elements |
map(function) |
Transforms each element |
mapToInt / mapToLong / mapToDouble |
Transforms to a primitive stream |
flatMap(function) |
Flattens a stream of collections into one stream |
distinct() |
Removes duplicates, using equals |
sorted() / sorted(comparator) |
Sorts. Needs to see everything first |
limit(n) / skip(n) |
First n / all but the first n |
takeWhile(p) / dropWhile(p) |
Stop or start at the first failure |
peek(consumer) |
Look without changing. For debugging only |
flatMap is the one worth a demonstration:
List<List<String>> rounds = List.of(List.of("Priya", "Arjun"), List.of("Kavita"), List.of());
rounds.stream().flatMap(List::stream).toList();
[Priya, Arjun, Kavita]
One flat list from a list of lists — and the empty one simply contributes
nothing, which is the neat part. Any time you have a collection of collections,
or a field that is itself a list, flatMap is the operation.
Terminal operations
| Operation | Returns |
|---|---|
toList() |
An immutable list — Java 16+ |
collect(Collectors.toList()) |
A mutable list |
collect(Collectors.toCollection(ArrayList::new)) |
A list of your chosen type |
count() |
long |
anyMatch / allMatch / noneMatch |
boolean, short-circuiting |
findFirst() / findAny() |
Optional<T> |
min / max (comparator) |
Optional<T> |
reduce(identity, op) |
A single combined value |
forEach(consumer) |
Nothing |
collect(Collectors.joining(", ")) |
A String |
sum / average / summaryStatistics |
On primitive streams only |
Real output from a list of five subscribers:
count : 4
anyMatch : true
findFirst : Optional[Sub[name=Arjun, area=Kharadi, tiffins=18, plan=jain]]
max : Optional[Sub[name=Kavita, area=Wagholi, tiffins=30, plan=veg]]
sum : 96
average : 19.2
stats : IntSummaryStatistics{count=5, sum=96, min=0, average=19.200000, max=30}
joined : Priya, Arjun, Kavita, Amit, Rahul
distinct : [Wagholi, Kharadi, Bavdhan]
Note findFirst and max return Optional — because the stream might be
empty. That is the next lesson.
summaryStatistics() is underused: count, sum, min, average and max in one pass
over the data.
toList() is immutable
List<String> result = subs.stream().map(Sub::name).toList();
result.add("x");
UnsupportedOperationException
Stream.toList(), added in Java 16, returns an unmodifiable list. That is a good
default — most results are read, not modified — and a surprise if you were
expecting ArrayList. When you need a mutable one:
.collect(Collectors.toCollection(ArrayList::new))
Primitive streams
Stream<Integer> boxes every element. For numbers, use the primitive streams:
subs.stream().mapToInt(Sub::tiffins).sum();
IntStream.rangeClosed(1, 5).sum();
IntStream.of(3, 1, 2).boxed().sorted().toList();
96
15
[1, 2, 3]
mapToInt gets you sum, average, max and summaryStatistics, none of
which exist on a Stream<Integer>. boxed() converts back when you need
objects.
The rules that keep streams honest
No side effects in intermediate operations. This works and is wrong:
List<String> collected = new ArrayList<>();
subs.stream().map(Sub::name).forEach(collected::add); // just use toList()
subs.stream().peek(collected::add).count(); // worse
peek exists for logging while debugging. Using it to build a result breaks the
moment the stream is parallel, and the laziness above means peek may not even
run for elements a later limit skipped.
Do not modify the source collection while streaming it. Same rule as the
for-each loop, same ConcurrentModificationException.
Do not use a stream where a loop is clearer. Specifically: when you need an
index, when you want to break out with more than a limit or a findFirst,
when two things are being accumulated at once, or when the lambda bodies are
getting long. The last lesson of this module is entirely about that judgement.
Parallel streams are not a free speed-up. parallelStream() exists, and the
honest advice for a foundation course is: do not use it. It helps only for large
datasets with genuinely independent, CPU-bound work, it uses a shared thread pool
you do not control, and it turns a side effect in a lambda from a bad habit into
a data race. Measure a sequential version first; you will almost always find the
bottleneck is elsewhere.
Check your work
What are the three parts of a stream? A source, zero or more lazy
intermediate operations returning a Stream, and one terminal operation that
runs the pipeline and produces a result.
What happens if you never call a terminal operation? Nothing at all. The pipeline is built and never runs.
Why did only one element get examined in the limit(1) example? Elements
flow through one at a time and limit short-circuits, so the pipeline stopped
after the first match rather than filtering all five.
What happens if you reuse a stream? IllegalStateException: stream has already been operated upon or closed. Call .stream() again on the collection.
What does toList() return, and how do you get a mutable list? An
unmodifiable list. For a mutable one use
collect(Collectors.toCollection(ArrayList::new)).
Why mapToInt rather than map? It avoids boxing and gives you sum,
average, max and summaryStatistics, which do not exist on a
Stream<Integer>.
What is peek for? Looking at elements while debugging. Not for building
results — it may not run for skipped elements, and it breaks under parallelism.
Should you reach for parallelStream()? No, not without measuring. It helps
only for large, CPU-bound, independent work, and it shares a thread pool you do
not control.
Practice 2, loop to stream. The stream version is
.filter(s -> s.tiffins() > 20).map(s -> s.name().toUpperCase()).toList() and
gives [PRIYA, KAVITA, RAHUL]. The thing to notice is not that it is shorter —
it is that the three decisions are on three separate lines instead of nested
inside one loop body.
Practice 4, the laziness experiment. With peek before filter and
limit(1) at the end, exactly one name prints. Move the limit before the
filter and it still prints one — but the result may now be empty, because you
limited before selecting. Order matters in a way it does not in a loop, and that
is worth feeling once.
Practice
-
Build one of each. From a list of subscribers, produce: all names; names of active subscribers; the total tiffins; the highest tiffin count; whether anyone took zero; the distinct areas; the names joined with commas.
-
Convert a loop. Take the filter-and-uppercase loop, write it both ways, and check they produce the same list.
-
Reuse a stream. Store one in a variable, call
count()twice, and read the exception. -
Watch laziness. Put a
peekthat prints, thenfilter, thenlimit(1). Count the printed lines. Then movelimit(1)before thefilterand explain what changed. -
Flatten something. Build a
List<List<String>>of delivery rounds including an empty one, and produce a single flat list of names withflatMap. -
Harder — a monthly summary in one pass each. From a list of subscribers produce: the mean tiffin count to two decimal places; the three busiest subscribers by name; the total bill in paise at Rs 82.35 each; and a single
IntSummaryStatisticsfor the tiffin counts. Then write the same four things as oneforloop and decide honestly which you would rather maintain — the loop wins on one of the four, and noticing which is the point.
Next: Optional, which is what findFirst and max handed back.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship