RizTech Academy logo
RizTech Academy
Streams and Functional JavaLesson 2 of 535 min

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

  1. 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.

  2. Convert a loop. Take the filter-and-uppercase loop, write it both ways, and check they produce the same list.

  3. Reuse a stream. Store one in a variable, call count() twice, and read the exception.

  4. Watch laziness. Put a peek that prints, then filter, then limit(1). Count the printed lines. Then move limit(1) before the filter and explain what changed.

  5. Flatten something. Build a List<List<String>> of delivery rounds including an empty one, and produce a single flat list of names with flatMap.

  6. 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 IntSummaryStatistics for the tiffin counts. Then write the same four things as one for loop 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