Java • Java-Specific Topics

Streams: Map/Filter/Reduce — Use streams for map/filter/reduce patterns (intro)

Learn how Java Streams let you transform, filter, and summarize data in elegant one-line pipelines.

Java programming emblem with a coffee cup, code braces, and terminal
Inside this lesson 10 sections 5 questions 6 figures

SECTION 1

Historical Context & Motivation

Before Java introduced streams, processing a list of data usually meant writing for-loops with temporary variables, conditional checks, and accumulators. This approach works, but it tends to be verbose and error-prone. Developers in languages like Haskell, Python, and Scala had long enjoyed concise patterns called map, filter, and reduce that let them transform collections in a single expressive chain. The Java community wanted these same tools, and with Java 8 in 2014, they finally arrived through the Streams API.

1958 • Lisp Introduces Functional Primitives

John McCarthy's Lisp language pioneered map and reduce as core operations on lists, inspiring decades of functional programming.

2004 • Google's MapReduce Paper

Google published a landmark paper showing how map and reduce patterns could process massive datasets across thousands of servers, making these ideas mainstream.

2014 • Java 8 Ships Streams & Lambdas

Java 8 introduced lambda expressions and the java.util.stream package, finally bringing map/filter/reduce patterns to Java.

2017+ • Streams Become Standard Practice

Java 9–17 added enhancements like takeWhile and dropWhile, cementing streams as the idiomatic way to process collections in modern Java.

The central question streams answer is: How can we describe what we want to happen to data, rather than writing step-by-step instructions for how to do it? This shift from imperative ("do this, then that") to declarative ("give me the result of this transformation") is the heart of the Streams API.

SECTION 2

Core Principles & Definitions

A stream in Java is a sequence of elements that supports data-processing operations. Unlike a collection (such as an ArrayList), a stream does not store data. Instead, it acts like a pipeline that pulls elements from a source, transforms them through a series of operations, and produces a result. Think of it like a conveyor belt in a factory: items move through stations, and at each station something happens to them.

1

Stream Source

A stream starts from a data source such as a List, array, or even a file. You create a stream by calling .stream() on a collection.
2

Intermediate Operations

Operations like map() and filter() are intermediate — they return a new stream and are lazy (they don't execute until a terminal operation is called).
3

Terminal Operations

Operations like reduce(), collect(), and forEach() are terminal — they trigger the pipeline to execute and produce a final result.
4

Pipelines Are One-Use

A stream can only be consumed once. After a terminal operation runs, the stream is spent. If you need to process the data again, create a new stream from the original source.
✦

KEY TAKEAWAY

Think of a stream like a Snapchat filter on a photo app. You pick your photo (the source), stack effects like brightness and crop (intermediate operations), and none of the effects actually render until you tap Save (terminal operation). The original photo isn't changed — you get a brand-new result.

SECTION 3

Visual Explanation — The Stream Pipeline

Stream Pipeline: Source → filter → map → reduce → ResultPipeline (lazy — nothing runs until terminal operation is called)SOURCEList<Integer>FILTERn -> n > 3MAPn -> n * 2REDUCE(a, b) -> a + bInput:153After filter (>3):5After map (×2):10After reduce (sum):10Intermediate OperationTerminal OperationSourceElements flow left to right through the pipeline. Only one element (5) passes the filter.
This diagram shows a stream pipeline processing the list [1, 5, 3]. The filter step keeps only values greater than 3 (just 5). The map step doubles each surviving value (5 × 2 = 10). The reduce step sums everything into a single result: 10.

Notice how data flows from left to right through the pipeline. The purple-bordered boxes represent intermediate operations (filter and map), while the gold-bordered box is the terminal operation (reduce). The key insight is that filter and map don't actually run until the terminal operation (reduce) triggers the entire pipeline. This is what we mean by lazy evaluation — the stream waits until it knows a result is needed before processing any elements.

SECTION 4

How Map, Filter, and Reduce Work

map() — Transform Every Element

The map() operation takes a function and applies it to every element in the stream, producing a new stream of transformed elements. If you have a list of names and want them all uppercase, map() is your tool. The original elements are never changed — a new stream is created with the results.

💡

Syntax

stream.map(element -> transformedElement) — The lambda expression inside the parentheses defines the transformation. For example, map(s -> s.toUpperCase()) converts each string to uppercase.

filter() — Keep Only Matching Elements

The filter() operation takes a predicate — a function that returns true or false — and keeps only the elements for which it returns true. Everything that returns false is discarded from the stream. For example, filter(n -> n > 10) keeps only numbers greater than 10.

reduce() — Combine Into a Single Value

The reduce() operation is a terminal operation that collapses all elements into a single result. You provide an identity value (the starting point) and a binary operator (a function that takes two values and produces one). For summing, the identity is 0 and the operator is addition: reduce(0, (a, b) -> a + b).

REDUCE PATTERN

result=identity⊕e1⊕e2⊕…⊕enresult = identity ⊕ e₁ ⊕ e₂ ⊕ … ⊕ eₙ

Where ⊕ is the binary operator (like +), identity is the starting value (like 0 for sum), and e₁…eₙ are the stream elements.

Besides reduce(), Java provides convenient terminal operations that are essentially built-in reduces: count() counts elements, sum() totals numeric streams, and collect(Collectors.toList()) gathers results into a new List. These are easier to read and should be preferred when they fit your use case.

SECTION 5

Imperative Loops vs. Stream Pipelines

One of the best ways to understand streams is to compare them side-by-side with the traditional loop approach. The following diagram shows the same task — finding the sum of doubled even numbers — written both ways.

Imperative Loop vs. Stream PipelineTask: From [1, 2, 3, 4, 5, 6], keep evens, double them, sum the results❌ IMPERATIVE (for-loop)int sum = 0;for (int n : numbers) {if (n % 2 == 0) {sum += n * 2;}}7 lines • mutable variable • HOW-focused✓ STREAM PIPELINEint sum = numbers.stream().filter(n -> n % 2 == 0).map(n -> n * 2).reduce(0, Integer::sum);4 lines • no mutation • WHAT-focusedExecution Trace (Stream Pipeline)Source:123456Filter:2461, 3, 5 removed (odd)Map:4812each × 2Reduce:240 + 4 + 8 + 12 = 24
The left panel shows a traditional for-loop that uses a mutable variable and explicit control flow. The right panel shows the equivalent stream pipeline — shorter, no mutation, and each step's purpose is clear from its method name. The bottom trace shows how data transforms through each stage.
AspectFor-LoopStream Pipeline
StyleImperative (step-by-step instructions)Declarative (describe the result)
MutabilityRequires mutable accumulator variableNo mutable state needed
ReadabilityLogic is tangled — filter, transform, and accumulate happen in one blockEach operation is a separate, named step
ParallelismDifficult to parallelize safelySwitch to .parallelStream() for free parallelism

Key differences between traditional loops and stream pipelines

SECTION 6

Worked Example — Processing Student Grades

Let's walk through a realistic example. Suppose you have a list of student scores on a test: [85, 92, 67, 74, 91, 58, 88, 73]. You want to find the average score of all students who passed (scored 70 or above).

Finding the Average of Passing Scores

  1. 1

    Step 1 — Create the Source List and Stream

    Start by defining the data and opening a stream: List<Integer> scores = List.of(85, 92, 67, 74, 91, 58, 88, 73); Then call scores.stream() to create a stream from this list.
    Stream contains: 85, 92, 67, 74, 91, 58, 88, 73
  2. 2

    Step 2 — Filter to Keep Only Passing Scores

    Chain .filter(score -> score >= 70) to keep only scores that are 70 or above. The predicate score -> score >= 70 returns true for passing scores and false for failing ones.
    Stream now contains: 85, 92, 74, 91, 88, 73 (67 and 58 removed)
  3. 3

    Step 3 — Convert to an IntStream for Numeric Operations

    Chain .mapToInt(Integer::intValue) to convert the Stream<Integer> into an IntStream. This gives access to numeric operations like average().
    IntStream contains: 85, 92, 74, 91, 88, 73
  4. 4

    Step 4 — Compute the Average (Terminal Operation)

    Call .average() which is a terminal operation that returns an OptionalDouble. Use .orElse(0.0) to provide a default in case the stream is empty.
    (85 + 92 + 74 + 91 + 88 + 73) ÷ 6 = 503 ÷ 6 ≈ 83.83
  5. 5

    Step 5 — Complete Code

    Here is the full pipeline in one expression: double avg = scores.stream().filter(s -> s >= 70).mapToInt(Integer::intValue).average().orElse(0.0); This single line replaces what would have been a for-loop with a running sum, a count variable, and a division at the end.
    avg = 83.83

SECTION 7

Strengths & Limitations of Streams

StrengthsLimitations
Concise, readable code — each operation is a clearly named methodDebugging can be harder — stack traces from lambdas are less intuitive than loop traces
Easy parallelism — just switch .stream() to .parallelStream()Streams are one-use — you cannot rewind or reuse a consumed stream
Lazy evaluation — elements are processed only as needed, which can skip unnecessary workSlight overhead for very small collections (a simple loop may be faster for 3–5 elements)
Composable — you can chain as many operations as you need in a single pipelineLambdas cannot modify local variables outside the lambda (they must be effectively final)

When to use streams and when to stick with loops

⚡

WHEN TO CHOOSE STREAMS

Use streams when you're doing bulk data transformations — filtering lists, transforming every element, or aggregating values. Stick with a traditional loop when you need to modify elements in place, break out early with complex conditions, or when the operation is trivially simple and a stream would be overkill.

SECTION 8

Connection to Advanced Stream Concepts

The map/filter/reduce trio you've learned is just the beginning. Java's Streams API includes many more operations that build on these core ideas. As you gain confidence, you'll encounter tools like flatMap() (which flattens nested collections), sorted(), distinct(), and the powerful Collectors utility class.

Intro ConceptAdvanced ExtensionWhat It Adds
map()flatMap()Transforms each element into a stream, then flattens all streams into one. Useful for lists of lists.
filter()takeWhile() / dropWhile()Takes or drops elements while a condition holds, useful for sorted data.
reduce()collect(Collectors.groupingBy())Groups elements by a key, like sorting students into grade categories.
.stream().parallelStream()Splits work across multiple CPU cores for large datasets.

How today's concepts scale to advanced stream operations

The most important idea to carry forward is that streams encourage you to think about data transformation as a pipeline. Once you're comfortable chaining map, filter, and reduce, every new stream operation is just another station on the conveyor belt. You'll find that this pipeline-thinking transfers to other languages and frameworks too — JavaScript arrays, Python iterators, and even SQL queries follow the same pattern.

SECTION 9

Practice Problems

Question 1: CONCEPTUAL

Explain the difference between an intermediate operation and a terminal operation in a Java stream pipeline. Why does nothing happen until a terminal operation is called?
Show answer
An intermediate operation (like map() or filter()) returns a new stream and does not process any elements by itself. A terminal operation (like reduce() or collect()) triggers the entire pipeline to execute and produces a non-stream result. Streams use lazy evaluation — intermediate operations are only descriptions of what should happen. The actual processing is deferred until a terminal operation demands a result, which lets the stream optimize how it processes elements.

Question 2: BASIC CALCULATION

Given List<String> words = List.of("hello", "hi", "hey", "world", "help");, write a stream pipeline that filters to only words starting with "h" and collects them into a new List. What will the resulting list contain?
Show answer
The pipeline is: List<String> result = words.stream().filter(w -> w.startsWith("h")).collect(Collectors.toList()); The filter checks each word against the predicate w.startsWith("h"). The words "hello", "hi", "hey", and "help" all start with 'h', so they pass. "world" does not start with 'h', so it's removed. The result is ["hello", "hi", "hey", "help"].

Question 3: INTERMEDIATE

Given List<Integer> prices = List.of(120, 45, 200, 80, 15, 300);, write a stream pipeline that: (a) filters to prices over 50, (b) applies a 10% discount (multiply by 0.9), and (c) sums the discounted prices. Show the result.
Show answer
The pipeline is: double total = prices.stream().filter(p -> p > 50).mapToDouble(p -> p * 0.9).sum(); First, filter keeps 120, 200, 80, and 300 (removing 45 and 15). Then mapToDouble applies the 10% discount: 120 × 0.9 = 108.0, 200 × 0.9 = 180.0, 80 × 0.9 = 72.0, 300 × 0.9 = 270.0. Finally, sum() adds them: 108.0 + 180.0 + 72.0 + 270.0 = 630.0. We used mapToDouble instead of map to get access to the numeric sum() method.

Question 4: APPLIED

A teacher stores student names and scores in two parallel lists: List<String> names = List.of("Alice", "Bob", "Carol", "Dave"); and List<Integer> scores = List.of(88, 65, 92, 71);. Using IntStream.range() and stream operations, write a pipeline that prints only the names of students who scored 70 or above.
Show answer
The pipeline is: IntStream.range(0, names.size()).filter(i -> scores.get(i) >= 70).mapToObj(i -> names.get(i)).forEach(System.out::println); Here, IntStream.range(0, 4) creates a stream of indices 0, 1, 2, 3. The filter checks each index's corresponding score: index 0 (88 ≥ 70 ✓), index 1 (65 < 70 ✗), index 2 (92 ≥ 70 ✓), index 3 (71 ≥ 70 ✓). Then mapToObj converts surviving indices to names. The output is Alice, Carol, Dave — each on its own line.

Question 5: CRITICAL THINKING

Consider the following code: List<Integer> nums = List.of(3, 1, 4, 1, 5, 9); Stream<Integer> s = nums.stream().filter(n -> n > 2); long count = s.count(); List<Integer> result = s.collect(Collectors.toList()); This code compiles but crashes at runtime. Explain why, and describe how you would fix it.
Show answer
The code crashes with an IllegalStateException because a stream can only be consumed once. The call to s.count() is a terminal operation that fully consumes the stream. When the next line tries to call s.collect() on the same stream object, the stream is already spent. To fix this, you should create a new stream for each terminal operation: call nums.stream().filter(n -> n > 2).count() for the count and nums.stream().filter(n -> n > 2).collect(Collectors.toList()) for the list. Alternatively, collect once into a list first, then call .size() on that list for the count.

SUMMARY

Lesson Summary

Summary

Java Streams provide a modern, declarative way to process collections. A stream pipeline starts with a source (like a List), flows through intermediate operations such as map() (transform every element) and filter() (keep only matching elements), and ends with a terminal operation like reduce() (combine into a single result) or collect() (gather into a new collection).

Key principles to remember: streams use lazy evaluation (nothing runs until a terminal operation is called), they are single-use (a consumed stream cannot be reused), and they do not modify the original data. Compared to traditional for-loops, streams produce shorter, more readable code and can be easily parallelized. As you progress, operations like flatMap() and Collectors will unlock even more powerful data-processing patterns.

Varsity Tutors • Java • Streams: Map/Filter/Reduce — Use streams for map/filter/reduce patterns (intro)