Skip to content
Codeloom
Java

Java Streams API: A Complete Guide

Master Java Streams for functional-style data processing. Covers stream creation, map, filter, reduce, collectors, parallel streams, and performance considerations.

·7 min read · By Codeloom
Intermediate 15 min read

What you'll learn

  • How to create streams from various sources
  • How to use map, filter, and reduce effectively
  • How to collect results with built-in and custom collectors
  • How parallel streams work and when to use them
  • Common mistakes and performance pitfalls

Prerequisites

  • Java basics (generics, lambdas)
  • Collections framework familiarity

The Streams API, introduced in Java 8, provides a functional approach to processing collections of data. A stream is a sequence of elements that supports sequential and parallel aggregate operations. It does not store data or modify the underlying collection — it is a pipeline for transforming and filtering data.

Creating streams

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

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

// from values
Stream<String> stream2 = Stream.of("one", "two", "three");

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

// using Stream.generate (infinite)
Stream<Double> randoms = Stream.generate(Math::random).limit(5);

// using Stream.iterate
Stream<Integer> counting = Stream.iterate(0, n -> n + 2).limit(10);
// 0, 2, 4, 6, 8, 10, 12, 14, 16, 18

// from a string
IntStream chars = "hello".chars();

// using Stream.builder
Stream<String> built = Stream.<String>builder()
    .add("a").add("b").add("c")
    .build();

Intermediate operations

Intermediate operations return a new stream and are lazy — they do not execute until a terminal operation is invoked.

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

map

List<String> names = List.of("alice", "bob", "charlie");

List<String> upper = names.stream()
    .map(String::toUpperCase)
    .toList();
// [ALICE, BOB, CHARLIE]

// map to different type
List<Integer> lengths = names.stream()
    .map(String::length)
    .toList();
// [5, 3, 7]

flatMap

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)
    .toList();
// [1, 2, 3, 4, 5, 6, 7, 8, 9]

// practical: split sentences into words
List<String> sentences = List.of("hello world", "foo bar baz");
List<String> words = sentences.stream()
    .flatMap(s -> Arrays.stream(s.split(" ")))
    .toList();
// [hello, world, foo, bar, baz]

distinct, sorted, peek

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

List<Integer> result = nums.stream()
    .distinct()                    // remove duplicates
    .sorted()                      // natural order
    .peek(n -> System.out.print(n + " "))  // debug: prints each element
    .toList();
// prints: 1 2 3 4 5 6 9
// result: [1, 2, 3, 4, 5, 6, 9]

// sort with custom comparator
List<String> names = List.of("Charlie", "Alice", "Bob");
List<String> sorted = names.stream()
    .sorted(Comparator.comparingInt(String::length))
    .toList();
// [Bob, Alice, Charlie]

Terminal operations

reduce

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

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

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

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

// string concatenation
String joined = List.of("a", "b", "c").stream()
    .reduce("", (a, b) -> a + b);
// "abc"

collect with Collectors

import java.util.stream.Collectors;

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

// to set (removes duplicates)
Set<String> uniqueNames = names.stream()
    .collect(Collectors.toSet());

// joining
String csv = names.stream()
    .collect(Collectors.joining(", "));
// "Alice, Bob, Charlie, Alice, Diana, Bob"

// grouping
Map<Integer, List<String>> byLength = names.stream()
    .collect(Collectors.groupingBy(String::length));
// {3=[Bob, Bob], 5=[Alice, Alice, Diana], 7=[Charlie]}

// counting per group
Map<String, Long> frequency = names.stream()
    .collect(Collectors.groupingBy(s -> s, Collectors.counting()));
// {Alice=2, Bob=2, Charlie=1, Diana=1}

// partitioning (boolean grouping)
Map<Boolean, List<String>> partitioned = names.stream()
    .collect(Collectors.partitioningBy(n -> n.length() > 4));
// {false=[Bob, Bob], true=[Alice, Charlie, Alice, Diana]}

// summarizing
IntSummaryStatistics stats = names.stream()
    .collect(Collectors.summarizingInt(String::length));
System.out.println("Count: " + stats.getCount());
System.out.println("Average length: " + stats.getAverage());
System.out.println("Max length: " + stats.getMax());

toMap

record Person(String name, int age) {}

List<Person> people = List.of(
    new Person("Alice", 30),
    new Person("Bob", 25),
    new Person("Charlie", 35)
);

// simple toMap
Map<String, Integer> nameToAge = people.stream()
    .collect(Collectors.toMap(Person::name, Person::age));
// {Alice=30, Bob=25, Charlie=35}

// handling duplicate keys
List<Person> withDupes = List.of(
    new Person("Alice", 30),
    new Person("Alice", 31)
);

Map<String, Integer> latest = withDupes.stream()
    .collect(Collectors.toMap(
        Person::name,
        Person::age,
        (existing, replacement) -> replacement  // merge function
    ));
// {Alice=31}

Other terminal operations

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

// forEach
nums.stream().forEach(System.out::println);

// count
long count = nums.stream().filter(n -> n > 3).count(); // 2

// anyMatch, allMatch, noneMatch
boolean hasEven = nums.stream().anyMatch(n -> n % 2 == 0);     // true
boolean allPositive = nums.stream().allMatch(n -> n > 0);       // true
boolean noneNegative = nums.stream().noneMatch(n -> n < 0);     // true

// findFirst, findAny
Optional<Integer> first = nums.stream()
    .filter(n -> n > 3)
    .findFirst(); // Optional[4]

// min, max
Optional<Integer> min = nums.stream().min(Comparator.naturalOrder()); // Optional[1]

Primitive streams

// IntStream, LongStream, DoubleStream avoid boxing overhead
IntStream.range(1, 6).forEach(System.out::println);     // 1, 2, 3, 4, 5
IntStream.rangeClosed(1, 5).forEach(System.out::println); // 1, 2, 3, 4, 5

// aggregate operations
int sum = IntStream.rangeClosed(1, 100).sum(); // 5050
double avg = IntStream.of(1, 2, 3, 4, 5).average().orElse(0); // 3.0

// convert between streams
List<Integer> boxed = IntStream.range(1, 6)
    .boxed()
    .toList();

IntStream unboxed = List.of(1, 2, 3).stream()
    .mapToInt(Integer::intValue);

Parallel streams

List<Integer> largeList = IntStream.rangeClosed(1, 1_000_000)
    .boxed()
    .toList();

// parallel processing
long count = largeList.parallelStream()
    .filter(n -> isPrime(n))
    .count();

// convert sequential to parallel
long sum = largeList.stream()
    .parallel()
    .mapToLong(Integer::longValue)
    .sum();

static boolean isPrime(int n) {
    if (n < 2) return false;
    for (int i = 2; i * i <= n; i++) {
        if (n % i == 0) return false;
    }
    return true;
}

When to use parallel streams

Parallel streams are not always faster. They help when:

  • The data set is large (thousands of elements minimum).
  • Each element requires significant computation.
  • The operation is stateless and associative.
  • There is no shared mutable state.

They hurt when:

  • The data set is small (thread overhead dominates).
  • Operations involve I/O (threads block).
  • Order matters and you use forEachOrdered.
  • The source is hard to split (like a LinkedList).

Real-world example: processing data

record Employee(String name, String department, double salary) {}

List<Employee> employees = List.of(
    new Employee("Alice", "Engineering", 95000),
    new Employee("Bob", "Engineering", 88000),
    new Employee("Charlie", "Marketing", 72000),
    new Employee("Diana", "Engineering", 102000),
    new Employee("Eve", "Marketing", 68000),
    new Employee("Frank", "Sales", 78000)
);

// average salary per department
Map<String, Double> avgSalary = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::department,
        Collectors.averagingDouble(Employee::salary)
    ));
// {Engineering=95000.0, Marketing=70000.0, Sales=78000.0}

// highest paid per department
Map<String, Optional<Employee>> topPaid = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::department,
        Collectors.maxBy(Comparator.comparingDouble(Employee::salary))
    ));

// names of employees earning above average
double overallAvg = employees.stream()
    .mapToDouble(Employee::salary)
    .average()
    .orElse(0);

List<String> aboveAverage = employees.stream()
    .filter(e -> e.salary() > overallAvg)
    .map(Employee::name)
    .sorted()
    .toList();
// [Alice, Diana]

Common mistakes

Reusing streams. A stream can only be consumed once. Trying to reuse it throws IllegalStateException.

Stream<String> stream = names.stream();
stream.forEach(System.out::println);  // works
stream.count();  // throws IllegalStateException

Side effects in intermediate operations. Do not mutate external state in map or filter. Use forEach for side effects at the end of the pipeline.

Using streams for everything. A simple for loop is clearer and faster for basic iteration. Streams shine for multi-step transformations, not for every loop in your code.

Streams provide a clean, composable way to process data. Learn the common operations, understand when parallel helps, and you will find them indispensable for data transformation tasks.