Skip to main content
Java · lesson 28 of 66
Open the Java page →
Java intermediate Lesson 28 of 66

Java Streams API

Learn the Java Streams API by running it: laziness, filter, map, flatMap, sorted, reduce, Collectors grouping and partitioning, and primitive streams.

By SkillByExample · Updated

A stream is a pipeline over a source — a collection, an array, a range — made of lazy intermediate operations and one terminal operation that runs it. Streams do not store data and do not change their source. Every example is a single file run with the JDK 21 source launcher (java File.java).

Laziness: nothing runs until the terminal operation

import java.util.List;
import java.util.stream.Stream;

public class LazyDemo {
    public static void main(String[] args) {
        Stream<Integer> pipeline = List.of(1, 2, 3, 4, 5, 6).stream()
            .filter(n -> { System.out.println("  filter " + n); return n % 2 == 0; })
            .map(n -> { System.out.println("  map    " + n); return n * n; });

        System.out.println("pipeline built, nothing printed yet");
        System.out.println("first = " + pipeline.findFirst().orElseThrow());

        try {
            pipeline.count();
        } catch (IllegalStateException e) {
            System.out.println(e.getMessage());
        }
    }
}
$ java LazyDemo.java
pipeline built, nothing printed yet
  filter 1
  filter 2
  map    2
first = 4
stream has already been operated upon or closed

Building the pipeline printed nothing. When findFirst ran, elements went through one at a time — 1 was filtered out, 2 passed and was mapped — and the stream stopped there; 3 to 6 were never examined. A stream is single-use, so the second terminal operation throws.

Creating streams

import java.util.Arrays;
import java.util.List;
import java.util.stream.IntStream;
import java.util.stream.Stream;

public class CreateDemo {
    public static void main(String[] args) {
        System.out.println(List.of("a", "b", "c").stream().toList());
        System.out.println(Arrays.stream(new int[] {3, 1, 2}).sum());
        System.out.println(Stream.of("x", "y", "z").count());
        System.out.println(IntStream.range(1, 5).boxed().toList() + " " + IntStream.rangeClosed(1, 5).boxed().toList());
        System.out.println(Stream.iterate(1, n -> n * 2).limit(8).toList());
        System.out.println(Stream.iterate(1, n -> n < 100, n -> n * 3).toList());
        System.out.println(Stream.generate(() -> "hi").limit(3).toList());
    }
}
$ java CreateDemo.java
[a, b, c]
6
3
[1, 2, 3, 4] [1, 2, 3, 4, 5]
[1, 2, 4, 8, 16, 32, 64, 128]
[1, 3, 9, 27, 81]
[hi, hi, hi]

range excludes its end and rangeClosed includes it. iterate and generate produce infinite streams, so they need limit — or the three-argument iterate (Java 9+), whose middle argument works like a for loop’s condition.

filter, map and flatMap

import java.util.Arrays;
import java.util.List;

public class TransformDemo {
    record Person(String name, int age) {}

    public static void main(String[] args) {
        List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
        System.out.println(numbers.stream().filter(n -> n > 3).filter(n -> n % 2 != 0).toList());

        List<Person> people = List.of(new Person("Alice", 30), new Person("Bob", 25), new Person("Charlie", 35));
        System.out.println(people.stream().map(Person::name).toList());
        System.out.println(people.stream().map(Person::name).map(String::length).toList());

        List<String> sentences = List.of("Hello World", "Java Streams", "are great");
        System.out.println(sentences.stream().map(s -> s.split(" ")).toList().size() + " arrays");
        System.out.println(sentences.stream().flatMap(s -> Arrays.stream(s.split(" "))).toList());
        System.out.println(List.of(List.of(1, 2), List.of(3), List.<Integer>of()).stream().flatMap(List::stream).toList());
    }
}
$ java TransformDemo.java
[5, 7, 9]
[Alice, Bob, Charlie]
[5, 3, 7]
3 arrays
[Hello, World, Java, Streams, are, great]
[1, 2, 3]

map is one-in, one-out: mapping three sentences with split gives three arrays. flatMap lets each element produce a stream of any length — including zero, as the empty list shows — and concatenates them into a single flat stream.

distinct, sorted, skip, limit, peek

import java.util.Comparator;
import java.util.List;

public class OrderingDemo {
    record Person(String name, int age) {}

    public static void main(String[] args) {
        List<Integer> nums = List.of(5, 3, 1, 4, 1, 5, 9, 2, 6, 5);
        System.out.println(nums.stream().distinct().toList());
        System.out.println(nums.stream().distinct().sorted().skip(2).limit(3).toList());
        System.out.println(nums.stream().sorted(Comparator.reverseOrder()).limit(3).toList());

        List<Person> people = List.of(new Person("Bob", 25), new Person("Alice", 30), new Person("Cara", 25));
        System.out.println(people.stream()
            .sorted(Comparator.comparingInt(Person::age).thenComparing(Person::name))
            .map(Person::name).toList());

        List<Integer> doubled = nums.stream().filter(n -> n > 5).peek(n -> System.out.println("saw " + n)).map(n -> n * 2).toList();
        System.out.println(doubled);
    }
}
$ java OrderingDemo.java
[5, 3, 1, 4, 9, 2, 6]
[3, 4, 5]
[9, 6, 5]
[Bob, Cara, Alice]
saw 9
saw 6
[18, 12]

distinct keeps the first occurrence of each value in encounter order. thenComparing breaks ties: Bob and Cara are both 25, so they are ordered by name. peek lets you watch elements pass without changing them — handy for debugging, but do not put real logic in it.

Collectors: grouping, counting, partitioning, joining

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

public class CollectDemo {
    public static void main(String[] args) {
        List<String> words = List.of("apple", "banana", "cherry", "apricot", "blueberry");

        Map<Character, List<String>> byLetter = words.stream()
            .collect(Collectors.groupingBy(w -> w.charAt(0), TreeMap::new, Collectors.toList()));
        System.out.println(byLetter);

        Map<Character, Long> counts = words.stream()
            .collect(Collectors.groupingBy(w -> w.charAt(0), TreeMap::new, Collectors.counting()));
        System.out.println(counts);

        System.out.println(words.stream().collect(Collectors.partitioningBy(w -> w.length() > 5)));
        Map<String, Integer> lengths = words.stream().collect(Collectors.toMap(w -> w, String::length, (a, b) -> a, TreeMap::new));
        System.out.println(lengths);
        System.out.println(words.stream().collect(Collectors.joining(", ", "[", "]")));

        try {
            words.stream().collect(Collectors.toMap(w -> w.charAt(0), w -> w));
        } catch (IllegalStateException e) {
            System.out.println(e.getMessage());
        }
    }
}
$ java CollectDemo.java
{a=[apple, apricot], b=[banana, blueberry], c=[cherry]}
{a=2, b=2, c=1}
{false=[apple], true=[banana, cherry, apricot, blueberry]}
{apple=5, apricot=7, banana=6, blueberry=9, cherry=6}
[apple, banana, cherry, apricot, blueberry]
Duplicate key a (attempted merging values apple and apricot)

groupingBy builds a Map<K, List<V>>; the downstream collector (counting() here) decides what each group becomes. The TreeMap::new argument sorts the keys — a plain groupingBy returns a HashMap with no ordering promise. toMap throws on a duplicate key unless you pass a merge function such as (a, b) -> a.

reduce and matching

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

public class ReduceDemo {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(1, 2, 3, 4, 5);
        System.out.println(numbers.stream().reduce(0, Integer::sum));
        System.out.println(numbers.stream().reduce(1, (a, b) -> a * b));
        System.out.println(numbers.stream().reduce(Integer::max));
        Optional<Integer> none = List.<Integer>of().stream().reduce(Integer::max);
        System.out.println(none);

        List<String> names = List.of("Alice", "Bob", "Charlie", "Diana");
        System.out.println(names.stream().filter(n -> n.length() > 4).count());
        System.out.println(names.stream().filter(n -> n.startsWith("C")).findFirst());
        System.out.println(names.stream().anyMatch(n -> n.length() > 6) + " "
            + names.stream().allMatch(n -> n.length() < 10) + " "
            + names.stream().noneMatch(String::isEmpty));
        System.out.println(List.<String>of().stream().allMatch(n -> n.length() > 100));
    }
}
$ java ReduceDemo.java
15
120
Optional[5]
Optional.empty
3
Optional[Charlie]
true true true
true

reduce with an identity (0 for sum, 1 for product) always returns a value; without one it returns an Optional, which is empty for an empty stream. allMatch on an empty stream is true — there is no element that fails — a fact that surprises people in validation code.

A realistic pipeline

import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;

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

    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"));

        List<Order> paid = orders.stream().filter(o -> o.status().equals("PAID")).toList();
        System.out.println("revenue: " + paid.stream().mapToDouble(Order::amount).sum());

        Map<String, Double> byCustomer = paid.stream()
            .collect(Collectors.groupingBy(Order::customer, TreeMap::new, Collectors.summingDouble(Order::amount)));
        System.out.println("by customer: " + byCustomer);

        paid.stream()
            .sorted(Comparator.comparingDouble(Order::amount).reversed())
            .limit(2)
            .forEach(o -> System.out.println("top: " + o.id() + " " + o.amount()));

        System.out.println("status counts: " + orders.stream()
            .collect(Collectors.groupingBy(Order::status, TreeMap::new, Collectors.counting())));
    }
}
$ java OrderProcessor.java
revenue: 650.0
by customer: {Alice=350.0, Charlie=300.0}
top: O4 300.0
top: O3 200.0
status counts: {CANCELLED=1, PAID=3, PENDING=1}

Each question is one pipeline over the same immutable list: filter to the paid orders once, then sum, group, rank or count. The stream never modifies orders, so the pipelines cannot interfere with each other.

Primitive streams

import java.util.IntSummaryStatistics;
import java.util.List;
import java.util.stream.IntStream;

public class PrimitiveDemo {
    public static void main(String[] args) {
        System.out.println(IntStream.rangeClosed(1, 100).sum());
        System.out.println(IntStream.rangeClosed(1, 100).average());
        System.out.println(IntStream.empty().max());

        IntSummaryStatistics stats = IntStream.of(3, 1, 4, 1, 5, 9, 2, 6).summaryStatistics();
        System.out.println(stats);

        List<String> words = List.of("stream", "of", "words");
        System.out.println(words.stream().mapToInt(String::length).max().getAsInt());
        System.out.println(IntStream.range(0, 3).mapToObj(i -> "item" + i).toList());
    }
}
$ java PrimitiveDemo.java
5050
OptionalDouble[50.5]
OptionalInt.empty
IntSummaryStatistics{count=8, sum=31, min=1, average=3.875000, max=9}
6
[item0, item1, item2]

IntStream, LongStream and DoubleStream work on unboxed values and add sum, average and summaryStatistics. average and max return OptionalDouble/OptionalInt because an empty stream has none. mapToInt enters the primitive world, mapToObj or boxed leaves it.

Practice

  1. From a list of sentences, produce the distinct words in lowercase, sorted alphabetically.
Solution
import java.util.Arrays;
import java.util.List;

public class DistinctWords {
    public static void main(String[] args) {
        List<String> sentences = List.of("The quick fox", "the lazy dog", "A quick dog");
        List<String> words = sentences.stream()
            .flatMap(s -> Arrays.stream(s.split("\\s+")))
            .map(String::toLowerCase)
            .distinct()
            .sorted()
            .toList();
        System.out.println(words);
    }
}
$ java DistinctWords.java
[a, dog, fox, lazy, quick, the]
  1. Find the average order amount per customer, sorted by customer name, using groupingBy with averagingDouble.
Solution
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;

public class AveragePerCustomer {
    record Order(String customer, double amount) {}

    public static void main(String[] args) {
        List<Order> orders = List.of(new Order("Bob", 50), new Order("Alice", 150),
            new Order("Alice", 200), new Order("Bob", 75));
        Map<String, Double> averages = orders.stream().collect(
            Collectors.groupingBy(Order::customer, TreeMap::new, Collectors.averagingDouble(Order::amount)));
        System.out.println(averages);
    }
}
$ java AveragePerCustomer.java
{Alice=175.0, Bob=62.5}
  1. Count how many times each character appears in "mississippi", keyed by character in sorted order.
Solution
import java.util.TreeMap;
import java.util.function.Function;
import java.util.stream.Collectors;

public class CharCount {
    public static void main(String[] args) {
        var counts = "mississippi".chars()
            .mapToObj(c -> (char) c)
            .collect(Collectors.groupingBy(Function.identity(), TreeMap::new, Collectors.counting()));
        System.out.println(counts);
    }
}
$ java CharCount.java
{i=4, m=1, p=2, s=4}

String.chars() returns an IntStream, so each code is cast back to char before grouping.

  1. Use Stream.iterate to print the first 10 Fibonacci numbers.
Solution
import java.util.stream.Stream;

public class Fibonacci {
    public static void main(String[] args) {
        System.out.println(Stream.iterate(new long[] {0, 1}, f -> new long[] {f[1], f[0] + f[1]})
            .limit(10)
            .map(f -> f[0])
            .toList());
    }
}
$ java Fibonacci.java
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

Each element is a pair {current, next}; map keeps only the first number.

Frequently Asked Questions

Are Java streams lazy?
Yes. Intermediate operations such as filter, map and sorted only describe a pipeline; nothing runs until a terminal operation such as collect, count or findFirst is called. Elements then flow through the pipeline one at a time, so filter().map().findFirst() can stop after the first match without touching the rest of the source.
Can I reuse a Java stream?
No. A stream can be consumed only once, and calling a second terminal operation throws IllegalStateException with the message that the stream has already been operated upon or closed. Keep the source collection and call stream() again, or pass around a Supplier<Stream<T>> if you need a fresh stream each time.
What is the difference between map and flatMap?
map turns each element into exactly one new element, so a stream of sentences becomes a stream of arrays or lists. flatMap turns each element into a stream of zero or more elements and concatenates them, so a stream of sentences becomes one stream of words. Use flatMap whenever one input produces several outputs.
What is the difference between Stream.toList() and Collectors.toList()?
Stream.toList(), added in Java 16, returns an unmodifiable list, so add or set throws UnsupportedOperationException. Collectors.toList() currently returns an ArrayList but makes no promise about mutability. Use toList() for read-only results and Collectors.toCollection(ArrayList::new) when you need a list you will modify.
When should I use parallel streams?
Only for large, CPU-bound work with no shared mutable state, and only after measuring. Parallel streams split work across the common ForkJoinPool, which has overhead and is shared by the whole JVM. Small collections, I/O-bound tasks and pipelines with side effects are usually slower or wrong in parallel.