Skip to main content
Java intermediate Lesson 28 of 58

Java Streams API

Master the Java Streams API — filter, map, collect, flatMap, reduce, and Collectors for expressive data processing pipelines.

What Is a Stream?

A Stream is a sequence of elements supporting functional-style aggregate operations. Streams do not store data — they pull from a source (collection, array, generator) and process it through a pipeline.

Source → intermediate ops (lazy) → terminal op (triggers execution)

List.of(1,2,3,4,5)
    .stream()              // source
    .filter(n -> n % 2 == 0)  // intermediate (lazy)
    .map(n -> n * n)          // intermediate (lazy)
    .collect(toList())         // terminal  → [4, 16]

Creating Streams

import java.util.stream.*;
import java.util.*;

// From a collection
List<String> names = List.of("Alice", "Bob", "Charlie");
Stream<String> s1 = names.stream();

// From an array
int[] numbers = {1, 2, 3, 4, 5};
IntStream s2 = Arrays.stream(numbers);

// From values directly
Stream<String> s3 = Stream.of("x", "y", "z");

// Empty stream
Stream<String> empty = Stream.empty();

// Infinite streams
Stream<Integer> naturals  = Stream.iterate(1, n -> n + 1);       // 1, 2, 3, ...
Stream<Integer> powers    = Stream.iterate(1, n -> n * 2);       // 1, 2, 4, 8, ...
Stream<Double>  randoms   = Stream.generate(Math::random);       // random doubles

// Primitive streams (avoid boxing overhead)
IntStream    ints    = IntStream.range(1, 6);    // 1, 2, 3, 4, 5
IntStream    ints2   = IntStream.rangeClosed(1, 5); // same
LongStream   longs   = LongStream.of(10L, 20L, 30L);
DoubleStream doubles = DoubleStream.of(1.1, 2.2, 3.3);

Intermediate Operations

These return a new stream. They are lazy — nothing executes until a terminal op is called.

filter

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());
// [2, 4, 6, 8, 10]

// Multiple filters compose naturally
List<Integer> result = numbers.stream()
    .filter(n -> n > 3)
    .filter(n -> n % 2 != 0)
    .collect(Collectors.toList());
// [5, 7, 9]

map

List<String> words = List.of("hello", "world", "java");

// Transform each element
List<String> upper = words.stream()
    .map(String::toUpperCase)
    .collect(Collectors.toList());
// [HELLO, WORLD, JAVA]

// Map to a different type
List<Integer> lengths = words.stream()
    .map(String::length)
    .collect(Collectors.toList());
// [5, 5, 4]

// Map to extracted field
record Person(String name, int age) {}
List<Person> people = List.of(
    new Person("Alice", 30),
    new Person("Bob", 25),
    new Person("Charlie", 35)
);

List<String> peopleNames = people.stream()
    .map(Person::name)
    .collect(Collectors.toList());
// [Alice, Bob, Charlie]

flatMap

// Each element maps to a stream — all are flattened into one stream
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(Collection::stream)
    .collect(Collectors.toList());
// [1, 2, 3, 4, 5, 6, 7, 8, 9]

// Splitting sentences into words
List<String> sentences = List.of("Hello World", "Java Streams", "are great");

List<String> allWords = sentences.stream()
    .flatMap(sentence -> Arrays.stream(sentence.split(" ")))
    .collect(Collectors.toList());
// [Hello, World, Java, Streams, are, great]

sorted, distinct, limit, skip, peek

List<Integer> nums = List.of(5, 3, 1, 4, 1, 5, 9, 2, 6, 5);

List<Integer> processed = nums.stream()
    .distinct()               // remove duplicates: [5,3,1,4,9,2,6]
    .sorted()                 // natural order:     [1,2,3,4,5,6,9]
    .skip(2)                  // skip first 2:      [3,4,5,6,9]
    .limit(3)                 // take at most 3:    [3,4,5]
    .collect(Collectors.toList());

// Custom sort
List<Person> people = ...;
List<Person> sorted = people.stream()
    .sorted(Comparator.comparing(Person::age).reversed())
    .collect(Collectors.toList());

// peek — inspect elements without consuming the stream (debugging)
List<Integer> result2 = nums.stream()
    .filter(n -> n > 3)
    .peek(n -> System.out.println("after filter: " + n))
    .map(n -> n * 2)
    .peek(n -> System.out.println("after map: " + n))
    .collect(Collectors.toList());

Terminal Operations

These trigger the pipeline and produce a result.

collect

import java.util.stream.*;
import java.util.*;

List<String> words = List.of("apple", "banana", "cherry", "apricot", "blueberry");

// toList() — Java 16+
List<String> list = words.stream().collect(Collectors.toList());
// or simply: words.stream().toList()  (Java 16+, unmodifiable)

// toSet()
Set<String> set = words.stream().collect(Collectors.toSet());

// toMap()
Map<String, Integer> wordLengths = words.stream()
    .collect(Collectors.toMap(
        w -> w,          // key
        String::length   // value
    ));

// groupingBy — group into a Map<K, List<V>>
Map<Character, List<String>> byFirstLetter = words.stream()
    .collect(Collectors.groupingBy(w -> w.charAt(0)));
// {a=[apple, apricot], b=[banana, blueberry], c=[cherry]}

// counting per group
Map<Character, Long> countByLetter = words.stream()
    .collect(Collectors.groupingBy(
        w -> w.charAt(0),
        Collectors.counting()
    ));
// {a=2, b=2, c=1}

// joining
String csv = words.stream()
    .collect(Collectors.joining(", ", "[", "]"));
// [apple, banana, cherry, apricot, blueberry]

// partitioningBy — splits into true/false groups
Map<Boolean, List<String>> partition = words.stream()
    .collect(Collectors.partitioningBy(w -> w.length() > 5));
// {false=[apple], true=[banana, cherry, apricot, blueberry]}

reduce

List<Integer> numbers = List.of(1, 2, 3, 4, 5);

// Sum with reduce (identity + accumulator)
int sum = numbers.stream()
    .reduce(0, Integer::sum); // 15

// Product
int product = numbers.stream()
    .reduce(1, (a, b) -> a * b); // 120

// Max without identity — returns Optional
Optional<Integer> max = numbers.stream()
    .reduce(Integer::max);
max.ifPresent(m -> System.out.println("Max: " + m)); // Max: 5

// Summing strings
List<String> words2 = List.of("Java", " ", "Streams");
String concat = words2.stream()
    .reduce("", String::concat); // "Java Streams"

forEach, count, findFirst, anyMatch, allMatch, noneMatch

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

// forEach — terminal, returns void
names.stream().forEach(System.out::println);

// count
long count = names.stream().filter(n -> n.length() > 4).count(); // 2

// findFirst — returns Optional<T>
Optional<String> first = names.stream()
    .filter(n -> n.startsWith("C"))
    .findFirst();
first.ifPresent(System.out::println); // Charlie

// findAny — may be faster in parallel streams
Optional<String> any = names.parallelStream()
    .filter(n -> n.length() == 3)
    .findAny();

// matching — all return boolean
boolean anyLong    = names.stream().anyMatch(n -> n.length() > 6);  // false
boolean allShort   = names.stream().allMatch(n -> n.length() < 10); // true
boolean noneEmpty  = names.stream().noneMatch(String::isEmpty);      // true

Real-World Example — Processing Orders

import java.util.*;
import java.util.stream.*;

record Order(String id, String customer, double amount, String status) {}

public class OrderProcessor {

    public static void main(String[] args) {
        List<Order> orders = List.of(
            new Order("O1", "Alice",   150.00, "PAID"),
            new Order("O2", "Bob",      50.00, "PENDING"),
            new Order("O3", "Alice",   200.00, "PAID"),
            new Order("O4", "Charlie", 300.00, "PAID"),
            new Order("O5", "Bob",      75.00, "CANCELLED")
        );

        // Total revenue from paid orders
        double revenue = orders.stream()
            .filter(o -> "PAID".equals(o.status()))
            .mapToDouble(Order::amount)
            .sum();
        System.out.println("Revenue: " + revenue); // 650.0

        // Revenue per customer (paid orders only)
        Map<String, Double> revenueByCustomer = orders.stream()
            .filter(o -> "PAID".equals(o.status()))
            .collect(Collectors.groupingBy(
                Order::customer,
                Collectors.summingDouble(Order::amount)
            ));
        System.out.println(revenueByCustomer);
        // {Alice=350.0, Charlie=300.0}

        // Top 2 paid orders by amount
        List<Order> top2 = orders.stream()
            .filter(o -> "PAID".equals(o.status()))
            .sorted(Comparator.comparingDouble(Order::amount).reversed())
            .limit(2)
            .collect(Collectors.toList());
        top2.forEach(o -> System.out.println(o.id() + ": " + o.amount()));
        // O4: 300.0
        // O3: 200.0

        // All customer names who have at least one paid order
        Set<String> paidCustomers = orders.stream()
            .filter(o -> "PAID".equals(o.status()))
            .map(Order::customer)
            .collect(Collectors.toSet());
        System.out.println(paidCustomers); // [Alice, Charlie]
    }
}

Primitive Streams — IntStream, LongStream, DoubleStream

Use primitive streams to avoid boxing overhead:

// IntStream has built-in sum, average, min, max, stats
IntStream range = IntStream.rangeClosed(1, 100);
System.out.println(range.sum());     // 5050
System.out.println(IntStream.rangeClosed(1, 100).average()); // OptionalDouble[50.5]

// Statistics summary
IntSummaryStatistics stats = IntStream.of(3, 1, 4, 1, 5, 9, 2, 6)
    .summaryStatistics();
System.out.println(stats.getMin());   // 1
System.out.println(stats.getMax());   // 9
System.out.println(stats.getSum());   // 31
System.out.println(stats.getAverage()); // 3.875

// Boxing / unboxing between streams
IntStream intStream = IntStream.range(1, 6);
Stream<Integer> boxed = intStream.boxed(); // int → Integer
IntStream unboxed = boxed.mapToInt(Integer::intValue); // Integer → int

Frequently Asked Questions

Are streams lazy? What does that mean?
Yes. Intermediate operations (filter, map, sorted, etc.) are lazy — they build a pipeline but do nothing until a terminal operation (collect, forEach, count, etc.) is called. This means a stream of a million elements with filter().map().findFirst() may only process a handful of elements before stopping.
Can I reuse a stream?
No. A stream can only be consumed once. After a terminal operation is called the stream is closed. If you need to process the same data twice, collect to a List and stream it again, or use a Supplier<Stream<T>> factory.
When should I use parallel streams?
Parallel streams split work across the ForkJoinPool and can speed up CPU-bound operations on large datasets. Avoid them for: small collections (overhead outweighs benefit), I/O-bound work, operations with side effects, or when order matters. Always benchmark — parallel is not always faster.
What is the difference between map and flatMap?
map applies a function to each element and wraps each result in the stream — one element in, one element out. flatMap applies a function that returns a stream per element, then flattens all those streams into one. Use flatMap when each element maps to zero-or-more results (e.g., a list of sentences mapped to individual words).