Skip to content
Java java8 7 min read

Stream Operations (filter/map/reduce)

Java Streams let you express data-processing pipelines — filtering, transforming, aggregating — in clean, readable one-liners instead of verbose for-loops. Once you understand filter, map, and reduce, you have the core toolkit to tackle almost any collection problem.

What is a Stream Operation?

A Stream is a sequence of elements that supports a pipeline of operations. You get a Stream from a source (like a List or an array), chain zero or more intermediate operations (lazy), and finish with one terminal operation (eager — this is what kicks off the actual work).

source → filter → map → reduce/collect

Every operation returns a new Stream (or a result); the original collection is never modified.

Note: Streams are single-use. Once a terminal operation runs, the stream is exhausted and cannot be reused.

filter — Keeping What You Need

filter(Predicate<T>) is an intermediate operation. It accepts a lambda that returns true for elements you want to keep and false for those you want to drop.

import java.util.List;
import java.util.stream.Collectors;

public class FilterExample {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        List<Integer> evens = numbers.stream()
                .filter(n -> n % 2 == 0)
                .collect(Collectors.toList());

        System.out.println(evens);
    }
}

Output:

[2, 4, 6, 8, 10]

You can chain multiple filter calls — each narrows the stream further:

List<String> names = List.of("Alice", "Bob", "Anna", "Charlie", "Amy");

List<String> result = names.stream()
        .filter(name -> name.startsWith("A"))
        .filter(name -> name.length() > 3)
        .collect(Collectors.toList());

System.out.println(result); // [Alice, Anna]

Output:

[Alice, Anna]

Tip: Chaining two filter calls is just as efficient as combining conditions with && — the JIT compiler fuses them. Prefer separate filters when it aids readability.

map — Transforming Elements

map(Function<T, R>) converts each element from type T to type R. The result stream has the same number of elements but potentially a different type.

import java.util.List;
import java.util.stream.Collectors;

public class MapExample {
    public static void main(String[] args) {
        List<String> words = List.of("hello", "world", "java", "streams");

        List<String> upper = words.stream()
                .map(String::toUpperCase)
                .collect(Collectors.toList());

        System.out.println(upper);
    }
}

Output:

[HELLO, WORLD, JAVA, STREAMS]

Mapping to a different type

import java.util.List;
import java.util.stream.Collectors;

public class MapTypeChange {
    public static void main(String[] args) {
        List<String> prices = List.of("9.99", "24.50", "3.00");

        List<Double> parsed = prices.stream()
                .map(Double::parseDouble)
                .collect(Collectors.toList());

        System.out.println(parsed);
    }
}

Output:

[9.99, 24.5, 3.0]

Primitive-specialised map variants

To avoid boxing overhead when working with primitives, use mapToInt, mapToLong, or mapToDouble. These return specialised streams (IntStream, LongStream, DoubleStream) with extra numeric helpers like sum() and average().

import java.util.List;

public class MapToInt {
    public static void main(String[] args) {
        List<String> names = List.of("Alice", "Bob", "Charlie");

        int totalLength = names.stream()
                .mapToInt(String::length)
                .sum();

        System.out.println("Total characters: " + totalLength);
    }
}

Output:

Total characters: 16

Tip: Prefer mapToInt / mapToDouble over map whenever you will call a numeric terminal operation. It skips the Integer/Double boxing step and is measurably faster on large streams.

flatMap — One-to-Many Transformations

flatMap is map’s sibling for when each element maps to multiple elements. It “flattens” the resulting streams into one.

import java.util.List;
import java.util.stream.Collectors;

public class FlatMapExample {
    public static void main(String[] args) {
        List<List<Integer>> nested = List.of(
                List.of(1, 2, 3),
                List.of(4, 5),
                List.of(6, 7, 8, 9)
        );

        List<Integer> flat = nested.stream()
                .flatMap(List::stream)
                .collect(Collectors.toList());

        System.out.println(flat);
    }
}

Output:

[1, 2, 3, 4, 5, 6, 7, 8, 9]

reduce — Aggregating to a Single Value

reduce is a terminal operation that combines stream elements into a single result using an accumulator function. There are three overloads:

OverloadReturnsUse when
reduce(identity, BinaryOperator)TYou have a safe starting value
reduce(BinaryOperator)Optional<T>No identity; stream may be empty
reduce(identity, BiFunction, BinaryOperator)UDifferent result type (parallel-friendly)

Sum with an identity value

import java.util.List;

public class ReduceSum {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(1, 2, 3, 4, 5);

        int sum = numbers.stream()
                .reduce(0, Integer::sum);

        System.out.println("Sum: " + sum);
    }
}

Output:

Sum: 15

Finding max without an identity

import java.util.List;
import java.util.Optional;

public class ReduceMax {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(3, 7, 1, 9, 4);

        Optional<Integer> max = numbers.stream()
                .reduce(Integer::max);

        max.ifPresent(m -> System.out.println("Max: " + m));
    }
}

Output:

Max: 9

Note: The no-identity overload returns Optional<T> because the stream could be empty. Always handle the empty case — use ifPresent, orElse, or orElseThrow. See Optional for details.

Building a string with reduce

import java.util.List;

public class ReduceString {
    public static void main(String[] args) {
        List<String> words = List.of("Java", "Streams", "Are", "Powerful");

        String sentence = words.stream()
                .reduce("", (a, b) -> a.isEmpty() ? b : a + " " + b);

        System.out.println(sentence);
    }
}

Output:

Java Streams Are Powerful

Tip: For joining strings, prefer Collectors.joining(" ") over reduce — it uses a StringBuilder internally and is more efficient.

Combining filter + map + reduce

The real power shows when you chain all three together:

import java.util.List;

public class Pipeline {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        // Sum of squares of even numbers
        int result = numbers.stream()
                .filter(n -> n % 2 == 0)          // keep evens: 2,4,6,8,10
                .mapToInt(n -> n * n)               // square each: 4,16,36,64,100
                .sum();                             // aggregate

        System.out.println("Sum of squares of evens: " + result);
    }
}

Output:

Sum of squares of evens: 220

Quick Reference: Common Intermediate & Terminal Operations

OperationTypePurpose
filter(Predicate)IntermediateDrop elements that don’t match
map(Function)IntermediateTransform each element
flatMap(Function)IntermediateFlatten nested streams
distinct()IntermediateRemove duplicates
sorted() / sorted(Comparator)IntermediateSort elements
limit(n)IntermediateTake first n elements
skip(n)IntermediateSkip first n elements
peek(Consumer)IntermediateDebug/side-effect without changing stream
reduce(…)TerminalFold to a single value
collect(Collector)TerminalAccumulate into a collection/string/map
forEach(Consumer)TerminalIterate with a side effect
count()TerminalCount remaining elements
findFirst() / findAny()TerminalReturn first/any element as Optional
anyMatch / allMatch / noneMatchTerminalBoolean short-circuit tests

See Stream API for the full API and Collectors for collecting results.

Under the Hood

Lazy evaluation

Intermediate operations build a pipeline description — no iteration happens yet. When a terminal operation is called, the JVM traverses the source once, passing each element through the entire pipeline before moving to the next. This means a filter → map pipeline on a million-element list does exactly one pass, not two.

element 1 → filter? yes → map → accumulate
element 2 → filter? no  → skip
element 3 → filter? yes → map → accumulate

Short-circuit terminals like findFirst() can make this even cheaper — the pipeline stops as soon as the first matching element is found.

Spliterator & parallel streams

Every collection provides a Spliterator that knows how to split itself for parallel work. Changing .stream() to .parallelStream() hands each split to the common ForkJoinPool. reduce with an associative identity is safe for parallel use; stateful operations like sorted() force a merge step.

Boxing cost

map(n -> n * 2) on a Stream<Integer> boxes every int into an Integer object, creating GC pressure. mapToInt(n -> n * 2) on an IntStream keeps everything on the stack as a primitive. For hot paths with large data, always prefer primitive streams.

Stream vs loop performance

For small collections (< ~1 000 elements) a traditional enhanced for-each loop is often faster due to JVM overhead from lambda creation and stream infrastructure. Streams shine for readability and — when combined with parallelStream() — for CPU-bound operations on large datasets.

  • Stream API — overview of the Stream interface and how to create streams
  • Collectors — collect stream results into lists, maps, grouped data, and more
  • Lambda Expressions — the syntax powering every stream predicate and function
  • Optional — handle the empty-stream case returned by reduce and findFirst
  • Functional InterfacesPredicate, Function, BinaryOperator and their role in stream operations
  • for-each Loop — when a plain loop is simpler or faster than a stream
Last updated June 13, 2026
Was this helpful?