Streams

August 5, 201927 min readUpdated 2/28/2026

1. What is the Stream API?

Think of a conveyor belt in a sushi restaurant. Plates of sushi travel along the belt, and you — the customer — pick only the ones you want, maybe add some soy sauce, and eat them. You never rearrange the kitchen. You never touch the plates you skip. You simply describe what you want and let the system deliver it.

The Java Stream API, introduced in Java 8 as part of the java.util.stream package, brings this declarative processing model to collections and other data sources. Instead of writing explicit loops that tell the computer how to iterate, you write a pipeline that describes what transformations and filters to apply.

Key characteristics of Streams:

  • Not a data structure — A Stream does not store elements. It reads elements from a source (Collection, array, file, generator) and pushes them through a pipeline of operations.
  • Declarative pipeline — You chain operations like filter, map, and reduce to express complex data processing in a readable, composable way.
  • Lazy evaluation — Intermediate operations (filter, map, sorted) are not executed until a terminal operation (collect, forEach, count) triggers the pipeline. This allows optimizations like short-circuiting.
  • Does not modify the source — Streaming over a List does not add, remove, or change elements in that List. The original data remains untouched.
  • Single use — Once a terminal operation is invoked, the Stream is consumed. You must create a new Stream to process the same data again.
  • Parallelism built in — Switch from sequential to parallel processing with a single method call (parallelStream()), leveraging multiple CPU cores automatically.

Stream Pipeline Structure

Every Stream pipeline has exactly three parts:

Part Description Examples
Source Where the data originates list.stream(), Arrays.stream(arr), Stream.of()
Intermediate Operations Transform the stream (lazy, return a new Stream) filter(), map(), sorted(), distinct()
Terminal Operation Produces a result or side-effect (triggers execution) collect(), forEach(), count(), reduce()

Here is a visual representation of how a Stream pipeline works:


Source          Intermediate Ops         Terminal Op
[data] ----> filter() ----> map() ----> collect()
                 |              |              |
            (lazy)         (lazy)      (triggers all)
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class StreamIntro {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Eve");

        // Stream pipeline: source -> intermediate ops -> terminal op
        List<String> result = names.stream()          // Source: create stream from list
            .filter(n -> n.length() > 3)              // Intermediate: keep names longer than 3 chars
            .map(String::toUpperCase)                  // Intermediate: convert to uppercase
            .sorted()                                  // Intermediate: sort alphabetically
            .collect(Collectors.toList());             // Terminal: collect into a new list

        System.out.println("Result: " + result);
        // Output: Result: [ALICE, CHARLIE, DAVID]

        // The original list is unchanged
        System.out.println("Original: " + names);
        // Output: Original: [Alice, Bob, Charlie, David, Eve]

        // Streams are single-use -- this will throw IllegalStateException
        var stream = names.stream();
        stream.count(); // terminal operation consumes the stream
        // stream.forEach(System.out::println); // ERROR: stream has already been operated upon
    }
}

2. Creating Streams

Before you can process data with the Stream API, you need to create a Stream. Java provides multiple ways depending on the data source.

2.1 From Collections

The most common way. Every class implementing Collection (List, Set, Queue) inherits a stream() method.

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

public class StreamFromCollections {
    public static void main(String[] args) {
        // From a List
        List<String> languages = List.of("Java", "Python", "Go", "Rust");
        List<String> filtered = languages.stream()
            .filter(lang -> lang.length() > 2)
            .collect(Collectors.toList());
        System.out.println("Filtered: " + filtered);
        // Output: Filtered: [Java, Python, Rust]

        // From a Set
        Set<Integer> numbers = new LinkedHashSet<>(Set.of(5, 3, 1, 4, 2));
        int sum = numbers.stream()
            .filter(n -> n % 2 != 0)
            .mapToInt(Integer::intValue)
            .sum();
        System.out.println("Sum of odds: " + sum);

        // From a Map (via entrySet, keySet, or values)
        Map<String, Integer> scores = Map.of("Alice", 92, "Bob", 78, "Charlie", 95);
        scores.entrySet().stream()
            .filter(e -> e.getValue() >= 90)
            .forEach(e -> System.out.println(e.getKey() + " scored " + e.getValue()));
        // Output: Alice scored 92
        //         Charlie scored 95
    }
}

2.2 From Arrays

Use Arrays.stream() for arrays. For primitive arrays (int[], long[], double[]), it returns the corresponding primitive stream (IntStream, LongStream, DoubleStream).

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

public class StreamFromArrays {
    public static void main(String[] args) {
        String[] colors = {"Red", "Green", "Blue", "Yellow"};

        // Full array stream
        Arrays.stream(colors).forEach(System.out::println);

        // Partial array: from index 1 (inclusive) to 3 (exclusive)
        String[] partial = Arrays.stream(colors, 1, 3)
            .toArray(String[]::new);
        System.out.println(Arrays.toString(partial));
        // Output: [Green, Blue]

        // Stream.of() works with varargs
        Stream.of("One", "Two", "Three").forEach(System.out::println);

        // Primitive array -- returns IntStream, not Stream<Integer>
        int[] numbers = {10, 20, 30, 40};
        int sum = Arrays.stream(numbers).sum();
        System.out.println("Sum: " + sum); // Output: Sum: 100

        double avg = Arrays.stream(numbers).average().orElse(0.0);
        System.out.println("Average: " + avg); // Output: Average: 25.0
    }
}

2.3 Stream.of(), generate(), and iterate()

Java provides static factory methods on the Stream interface to create streams without needing an existing collection or array.

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

public class StreamFactoryMethods {
    public static void main(String[] args) {
        // Stream.empty() -- useful as a return value instead of null
        Stream<String> empty = Stream.empty();
        System.out.println("Empty count: " + empty.count()); // Output: 0

        // Stream.of() -- create from individual elements
        Stream.of("Java", "Python", "Go")
            .map(String::toUpperCase)
            .forEach(System.out::println);

        // Stream.generate() -- infinite stream from a Supplier
        // ALWAYS use limit() or the stream runs forever
        List<String> uuids = Stream.generate(UUID::randomUUID)
            .limit(3)
            .map(UUID::toString)
            .collect(Collectors.toList());
        System.out.println("UUIDs: " + uuids);

        // Stream.iterate() -- Java 8 style (seed + UnaryOperator, must limit)
        List<Integer> powersOf2 = Stream.iterate(1, n -> n * 2)
            .limit(8)
            .collect(Collectors.toList());
        System.out.println("Powers: " + powersOf2);
        // Output: Powers: [1, 2, 4, 8, 16, 32, 64, 128]

        // Stream.iterate() -- Java 9+ style (seed + predicate + UnaryOperator)
        Stream.iterate(1, n -> n <= 100, n -> n * 2)
            .forEach(n -> System.out.print(n + " "));
        // Output: 1 2 4 8 16 32 64
        System.out.println();

        // Stream.concat() -- combine two streams into one
        Stream<String> s1 = Stream.of("A", "B");
        Stream<String> s2 = Stream.of("C", "D");
        Stream.concat(s1, s2).forEach(System.out::println);
        // Output: A, B, C, D
    }
}

2.4 IntStream.range() and rangeClosed()

For generating sequences of integers, IntStream provides range() (exclusive end) and rangeClosed() (inclusive end). These are more efficient than Stream.iterate() for simple numeric ranges.

import java.util.stream.IntStream;
import java.util.stream.LongStream;

public class StreamRanges {
    public static void main(String[] args) {
        // range(start, endExclusive) -- does NOT include the end value
        IntStream.range(1, 5).forEach(n -> System.out.print(n + " "));
        // Output: 1 2 3 4
        System.out.println();

        // rangeClosed(start, endInclusive) -- INCLUDES the end value
        IntStream.rangeClosed(1, 5).forEach(n -> System.out.print(n + " "));
        // Output: 1 2 3 4 5
        System.out.println();

        // Sum of 1 to 100
        int sum = IntStream.rangeClosed(1, 100).sum();
        System.out.println("Sum 1-100: " + sum); // Output: Sum 1-100: 5050

        // LongStream for larger ranges
        long bigSum = LongStream.rangeClosed(1, 1_000_000).sum();
        System.out.println("Sum 1-1M: " + bigSum);
        // Output: Sum 1-1M: 500000500000

        // Using range as a loop replacement
        IntStream.range(0, 5)
            .mapToObj(i -> "Item " + i)
            .forEach(System.out::println);
    }
}

Stream Creation Summary

Method Returns Use Case
collection.stream() Stream<T> Most common — stream from any Collection
Arrays.stream(array) Stream<T> or primitive stream Stream from an array
Stream.of(a, b, c) Stream<T> Stream from individual values
Stream.empty() Stream<T> Empty stream (null-safe return)
Stream.generate(supplier) Stream<T> Infinite stream from a Supplier
Stream.iterate(seed, op) Stream<T> Infinite stream with iteration
Stream.concat(s1, s2) Stream<T> Merge two streams
IntStream.range(a, b) IntStream Range of ints [a, b)
IntStream.rangeClosed(a, b) IntStream Range of ints [a, b]

3. Intermediate Operations

Intermediate operations transform a Stream into another Stream. They are lazy — nothing happens until a terminal operation triggers the pipeline. You can chain as many intermediate operations as you need. Each one returns a new Stream, leaving the original Stream unchanged.

3.1 filter(Predicate)

filter() keeps only the elements that match the given condition. Elements that pass the test continue down the pipeline; those that fail are discarded.

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

        // Keep only even numbers
        List<Integer> evens = numbers.stream()
            .filter(n -> n % 2 == 0)
            .collect(Collectors.toList());
        System.out.println("Evens: " + evens);
        // Output: Evens: [2, 4, 6, 8, 10]

        // Chaining multiple filters (equivalent to AND)
        List<Integer> result = numbers.stream()
            .filter(n -> n > 3)
            .filter(n -> n < 8)
            .collect(Collectors.toList());
        System.out.println("Between 3 and 8: " + result);
        // Output: Between 3 and 8: [4, 5, 6, 7]

        // Filtering with complex predicates
        List<String> names = List.of("Alice", "Bob", "Charlie", "Anna", "Andrew");
        List<String> startsWithA = names.stream()
            .filter(name -> name.startsWith("A"))
            .filter(name -> name.length() > 4)
            .collect(Collectors.toList());
        System.out.println("Starts with A, length > 4: " + startsWithA);
        // Output: Starts with A, length > 4: [Alice, Andrew]
    }
}

3.2 map(Function)

map() transforms each element by applying a function. The input type and output type can be different. This is one of the most commonly used operations.

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

public class MapExample {
    public static void main(String[] args) {
        // Transform strings to uppercase
        List<String> names = List.of("alice", "bob", "charlie");
        List<String> upper = names.stream()
            .map(String::toUpperCase)
            .collect(Collectors.toList());
        System.out.println("Upper: " + upper);
        // Output: Upper: [ALICE, BOB, CHARLIE]

        // Transform to different type: String -> Integer (length)
        List<Integer> lengths = names.stream()
            .map(String::length)
            .collect(Collectors.toList());
        System.out.println("Lengths: " + lengths);
        // Output: Lengths: [5, 3, 7]

        // Chain map operations
        List<String> formatted = names.stream()
            .map(String::trim)
            .map(s -> s.substring(0, 1).toUpperCase() + s.substring(1))
            .map(s -> "Hello, " + s + "!")
            .collect(Collectors.toList());
        System.out.println("Formatted: " + formatted);
        // Output: Formatted: [Hello, Alice!, Hello, Bob!, Hello, Charlie!]

        // Extract a field from objects
        List<String> emails = List.of("alice@mail.com", "bob@mail.com", "charlie@mail.com");
        List<String> usernames = emails.stream()
            .map(email -> email.split("@")[0])
            .collect(Collectors.toList());
        System.out.println("Usernames: " + usernames);
        // Output: Usernames: [alice, bob, charlie]
    }
}

3.3 flatMap(Function)

flatMap() is used when each element maps to multiple elements (a stream of streams). It flattens the nested streams into a single stream. This is essential when dealing with lists of lists, optional values, or one-to-many transformations.

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

public class FlatMapExample {
    public static void main(String[] args) {
        // Problem: List of lists -- map gives Stream<List<String>>
        List<List<String>> nested = List.of(
            List.of("Java", "Python"),
            List.of("Go", "Rust"),
            List.of("JavaScript", "TypeScript")
        );

        // map() would give us Stream<List<String>> -- not what we want
        // flatMap() flattens it into Stream<String>
        List<String> allLanguages = nested.stream()
            .flatMap(List::stream)
            .collect(Collectors.toList());
        System.out.println("All: " + allLanguages);
        // Output: All: [Java, Python, Go, Rust, JavaScript, TypeScript]

        // Split sentences into words
        List<String> sentences = List.of(
            "Java is great",
            "Streams are powerful",
            "FlatMap flattens"
        );
        List<String> words = sentences.stream()
            .flatMap(sentence -> Arrays.stream(sentence.split(" ")))
            .map(String::toLowerCase)
            .distinct()
            .sorted()
            .collect(Collectors.toList());
        System.out.println("Words: " + words);
        // Output: Words: [are, flatmap, flattens, great, is, java, powerful, streams]

        // flatMap with arrays
        String[][] data = {{"a", "b"}, {"c", "d"}, {"e", "f"}};
        List<String> flat = Arrays.stream(data)
            .flatMap(Arrays::stream)
            .collect(Collectors.toList());
        System.out.println("Flat: " + flat);
        // Output: Flat: [a, b, c, d, e, f]
    }
}

3.4 sorted()

sorted() sorts the elements. Without arguments, it uses natural ordering (elements must implement Comparable). With a Comparator argument, you control the sort order.

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

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

        // Natural ordering (alphabetical for Strings)
        List<String> sorted = names.stream()
            .sorted()
            .collect(Collectors.toList());
        System.out.println("Sorted: " + sorted);
        // Output: Sorted: [Alice, Bob, Charlie, David, Eve]

        // Reverse order
        List<String> reversed = names.stream()
            .sorted(Comparator.reverseOrder())
            .collect(Collectors.toList());
        System.out.println("Reversed: " + reversed);
        // Output: Reversed: [Eve, David, Charlie, Bob, Alice]

        // Sort by length
        List<String> byLength = names.stream()
            .sorted(Comparator.comparingInt(String::length))
            .collect(Collectors.toList());
        System.out.println("By length: " + byLength);
        // Output: By length: [Bob, Eve, Alice, David, Charlie]

        // Sort by length descending, then alphabetically
        List<String> complex = names.stream()
            .sorted(Comparator.comparingInt(String::length)
                .reversed()
                .thenComparing(Comparator.naturalOrder()))
            .collect(Collectors.toList());
        System.out.println("Complex sort: " + complex);
        // Output: Complex sort: [Charlie, Alice, David, Bob, Eve]
    }
}

3.5 distinct(), peek(), limit(), and skip()

These operations help you control which elements pass through the pipeline and inspect elements during processing.

Operation Description
distinct() Removes duplicates (uses equals())
peek(Consumer) Performs an action on each element without consuming it — useful for debugging
limit(n) Truncates the stream to at most n elements
skip(n) Discards the first n elements
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class MoreIntermediateOps {
    public static void main(String[] args) {
        // distinct() -- removes duplicates
        List<Integer> numbers = List.of(1, 2, 3, 2, 1, 4, 3, 5);
        List<Integer> unique = numbers.stream()
            .distinct()
            .collect(Collectors.toList());
        System.out.println("Unique: " + unique);
        // Output: Unique: [1, 2, 3, 4, 5]

        // peek() -- inspect elements without consuming (great for debugging)
        List<String> result = List.of("one", "two", "three", "four").stream()
            .filter(s -> s.length() > 3)
            .peek(s -> System.out.println("After filter: " + s))
            .map(String::toUpperCase)
            .peek(s -> System.out.println("After map: " + s))
            .collect(Collectors.toList());
        // Output:
        // After filter: three
        // After map: THREE
        // After filter: four
        // After map: FOUR
        System.out.println("Result: " + result);
        // Output: Result: [THREE, FOUR]

        // limit(n) -- take only the first n elements
        List<Integer> firstThree = List.of(10, 20, 30, 40, 50).stream()
            .limit(3)
            .collect(Collectors.toList());
        System.out.println("First 3: " + firstThree);
        // Output: First 3: [10, 20, 30]

        // skip(n) -- skip the first n elements
        List<Integer> skipTwo = List.of(10, 20, 30, 40, 50).stream()
            .skip(2)
            .collect(Collectors.toList());
        System.out.println("Skip 2: " + skipTwo);
        // Output: Skip 2: [30, 40, 50]

        // Pagination: skip + limit
        List<String> allItems = List.of("A", "B", "C", "D", "E", "F", "G", "H");
        int pageSize = 3;
        int pageNumber = 2; // 0-based
        List<String> page = allItems.stream()
            .skip((long) pageNumber * pageSize)
            .limit(pageSize)
            .collect(Collectors.toList());
        System.out.println("Page 2: " + page);
        // Output: Page 2: [G, H]
    }
}

Intermediate Operations Summary

Operation Signature Description
filter filter(Predicate<T>) Keep elements matching the predicate
map map(Function<T, R>) Transform each element
flatMap flatMap(Function<T, Stream<R>>) Transform and flatten nested streams
sorted sorted() or sorted(Comparator) Sort elements
distinct distinct() Remove duplicates
peek peek(Consumer<T>) Perform action without consuming
limit limit(long n) Truncate to first n elements
skip skip(long n) Discard first n elements

4. Terminal Operations

Terminal operations trigger the execution of the entire pipeline and produce a result (a value, a collection, or a side-effect). Once a terminal operation is called, the stream is consumed and cannot be reused.

4.1 forEach() and forEachOrdered()

forEach() performs an action on each element. For parallel streams, use forEachOrdered() if order matters.

import java.util.List;

public class ForEachExample {
    public static void main(String[] args) {
        List<String> fruits = List.of("Apple", "Banana", "Cherry");

        // forEach -- order guaranteed for sequential streams
        fruits.stream().forEach(f -> System.out.println("Fruit: " + f));

        // Method reference shorthand
        fruits.stream().forEach(System.out::println);

        // forEachOrdered -- guarantees encounter order even in parallel
        fruits.parallelStream().forEachOrdered(System.out::println);
        // Output (always in order): Apple, Banana, Cherry
    }
}

4.2 collect()

collect() is the most versatile terminal operation. It uses a Collector to accumulate elements into a result container (List, Set, Map, String, etc.). The Collectors utility class provides dozens of pre-built collectors.

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

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

        // Collect to List
        List<String> list = names.stream()
            .filter(n -> n.length() > 3)
            .collect(Collectors.toList());
        System.out.println("List: " + list);
        // Output: List: [Alice, Charlie, Alice, David]

        // Collect to Set (removes duplicates)
        Set<String> set = names.stream()
            .collect(Collectors.toSet());
        System.out.println("Set: " + set);

        // Collect to Map
        Map<String, Integer> nameLengths = names.stream()
            .distinct()
            .collect(Collectors.toMap(
                name -> name,         // key
                String::length        // value
            ));
        System.out.println("Map: " + nameLengths);
        // Output: Map: {Alice=5, Bob=3, Charlie=7, David=5}

        // Join strings
        String joined = names.stream()
            .distinct()
            .collect(Collectors.joining(", ", "[", "]"));
        System.out.println("Joined: " + joined);
        // Output: Joined: [Alice, Bob, Charlie, David]
    }
}

4.3 reduce()

reduce() combines all elements into a single result by repeatedly applying a binary operator. It has three forms: with identity, without identity, and with identity + combiner (for parallel streams).

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

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

        // reduce with identity -- always returns a value
        int sum = numbers.stream()
            .reduce(0, Integer::sum);
        System.out.println("Sum: " + sum); // Output: Sum: 15

        int product = numbers.stream()
            .reduce(1, (a, b) -> a * b);
        System.out.println("Product: " + product); // Output: Product: 120

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

        // Concatenating strings with reduce
        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: " + sentence);
        // Output: Sentence: Java Streams Are Powerful

        // reduce on empty stream
        Optional<Integer> emptyResult = List.<Integer>of().stream()
            .reduce(Integer::sum);
        System.out.println("Empty: " + emptyResult.isPresent()); // Output: Empty: false
    }
}

4.4 count(), min(), max()

These terminal operations return aggregate values from the stream.

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

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

        // count()
        long count = numbers.stream()
            .filter(n -> n > 5)
            .count();
        System.out.println("Count > 5: " + count); // Output: Count > 5: 3

        // min() with Comparator
        Optional<Integer> min = numbers.stream()
            .min(Comparator.naturalOrder());
        System.out.println("Min: " + min.orElse(-1)); // Output: Min: 1

        // max() with Comparator
        Optional<Integer> max = numbers.stream()
            .max(Comparator.naturalOrder());
        System.out.println("Max: " + max.orElse(-1)); // Output: Max: 9

        // Finding the longest string
        List<String> words = List.of("cat", "elephant", "dog", "hippopotamus");
        Optional<String> longest = words.stream()
            .max(Comparator.comparingInt(String::length));
        System.out.println("Longest: " + longest.orElse("none"));
        // Output: Longest: hippopotamus
    }
}

4.5 findFirst(), findAny()

findFirst() returns the first element. findAny() returns any element (useful with parallel streams for better performance). Both return Optional.

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

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

        // findFirst() -- guaranteed to return the first matching element
        Optional<String> first = names.stream()
            .filter(n -> n.startsWith("A"))
            .findFirst();
        System.out.println("First with A: " + first.orElse("none"));
        // Output: First with A: Alice

        // findAny() -- may return any matching element (non-deterministic in parallel)
        Optional<String> any = names.parallelStream()
            .filter(n -> n.startsWith("A"))
            .findAny();
        System.out.println("Any with A: " + any.orElse("none"));
        // Output: Any with A: Alice (or Andrew or Anna -- non-deterministic)

        // findFirst on empty result
        Optional<String> missing = names.stream()
            .filter(n -> n.startsWith("Z"))
            .findFirst();
        System.out.println("Found Z? " + missing.isPresent());
        // Output: Found Z? false
    }
}

4.6 anyMatch(), allMatch(), noneMatch()

These short-circuiting operations test whether elements match a condition. They return boolean and stop processing as soon as the answer is determined.

import java.util.List;

public class MatchExample {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(2, 4, 6, 8, 10);

        // anyMatch -- does at least one element match?
        boolean hasEven = numbers.stream().anyMatch(n -> n % 2 == 0);
        System.out.println("Has even? " + hasEven); // Output: Has even? true

        // allMatch -- do ALL elements match?
        boolean allEven = numbers.stream().allMatch(n -> n % 2 == 0);
        System.out.println("All even? " + allEven); // Output: All even? true

        // noneMatch -- does NO element match?
        boolean noneNegative = numbers.stream().noneMatch(n -> n < 0);
        System.out.println("None negative? " + noneNegative); // Output: None negative? true

        // Short-circuiting behavior
        List<Integer> mixed = List.of(1, 2, 3, 4, 5);
        boolean anyOver3 = mixed.stream()
            .peek(n -> System.out.print("Checking " + n + "... "))
            .anyMatch(n -> n > 3);
        System.out.println("\nAny over 3? " + anyOver3);
        // Output: Checking 1... Checking 2... Checking 3... Checking 4...
        //         Any over 3? true
        // Note: 5 was never checked -- short-circuited after finding 4
    }
}

4.7 toArray()

toArray() collects the stream elements into an array. Without arguments it returns Object[]; with a generator function it returns the specific type.

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

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

        // toArray() without argument -- returns Object[]
        Object[] objArray = names.stream().toArray();
        System.out.println(Arrays.toString(objArray));

        // toArray with generator -- returns String[]
        String[] strArray = names.stream()
            .map(String::toUpperCase)
            .toArray(String[]::new);
        System.out.println(Arrays.toString(strArray));
        // Output: [ALICE, BOB, CHARLIE]

        // Convert filtered stream to int array
        int[] evenNumbers = List.of(1, 2, 3, 4, 5, 6).stream()
            .filter(n -> n % 2 == 0)
            .mapToInt(Integer::intValue)
            .toArray();
        System.out.println(Arrays.toString(evenNumbers));
        // Output: [2, 4, 6]
    }
}

Terminal Operations Summary

Operation Return Type Description
forEach(Consumer) void Perform action on each element
collect(Collector) R Accumulate into a container (List, Set, Map, etc.)
reduce(identity, op) T Combine all elements into one value
count() long Count elements
min(Comparator) Optional<T> Find minimum element
max(Comparator) Optional<T> Find maximum element
findFirst() Optional<T> First element (deterministic)
findAny() Optional<T> Any element (non-deterministic in parallel)
anyMatch(Predicate) boolean True if any element matches
allMatch(Predicate) boolean True if all elements match
noneMatch(Predicate) boolean True if no element matches
toArray() Object[] or T[] Collect to an array

5. Collectors

The Collectors class in java.util.stream provides a rich set of pre-built collectors for the collect() terminal operation. This section covers the most commonly used ones. For a deep dive into all Collectors, see the dedicated Java 8 Collectors Class tutorial.

5.1 Basic Collectors: toList(), toSet(), toMap()

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

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

        // toList() -- ordered, allows duplicates
        List<String> list = names.stream()
            .filter(n -> n.length() > 3)
            .collect(Collectors.toList());
        System.out.println("List: " + list);
        // Output: List: [Alice, Charlie, Alice, David]

        // toSet() -- unordered, no duplicates
        Set<String> set = names.stream()
            .collect(Collectors.toSet());
        System.out.println("Set: " + set);

        // toMap() -- key-value pairs
        Map<String, Integer> nameLengths = names.stream()
            .distinct()
            .collect(Collectors.toMap(
                n -> n,              // key mapper
                String::length       // value mapper
            ));
        System.out.println("Map: " + nameLengths);

        // toMap() with merge function for duplicate keys
        Map<String, Integer> nameCount = names.stream()
            .collect(Collectors.toMap(
                n -> n,              // key
                n -> 1,              // value
                Integer::sum         // merge: add counts
            ));
        System.out.println("Counts: " + nameCount);
        // Output: Counts: {Alice=2, Bob=1, Charlie=1, David=1}
    }
}

5.2 joining()

Collectors.joining() concatenates stream elements into a single String. You can specify a delimiter, prefix, and suffix.

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

public class JoiningCollector {
    public static void main(String[] args) {
        List<String> languages = List.of("Java", "Python", "Go", "Rust");

        // Simple concatenation
        String simple = languages.stream().collect(Collectors.joining());
        System.out.println(simple); // Output: JavaPythonGoRust

        // With delimiter
        String delimited = languages.stream().collect(Collectors.joining(", "));
        System.out.println(delimited); // Output: Java, Python, Go, Rust

        // With delimiter, prefix, and suffix
        String formatted = languages.stream()
            .collect(Collectors.joining(" | ", "[ ", " ]"));
        System.out.println(formatted); // Output: [ Java | Python | Go | Rust ]

        // Practical: building a SQL IN clause
        List<Integer> ids = List.of(101, 205, 310, 422);
        String inClause = ids.stream()
            .map(String::valueOf)
            .collect(Collectors.joining(", ", "WHERE id IN (", ")"));
        System.out.println(inClause);
        // Output: WHERE id IN (101, 205, 310, 422)
    }
}

5.3 groupingBy() and partitioningBy()

groupingBy() groups elements by a classifier function into a Map<K, List<T>>. partitioningBy() is a special case that splits elements into two groups (true/false) based on a predicate.

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

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

        // Group by first letter
        Map<Character, List<String>> byLetter = names.stream()
            .collect(Collectors.groupingBy(n -> n.charAt(0)));
        System.out.println("By letter: " + byLetter);
        // Output: By letter: {A=[Alice, Anna], B=[Bob, Brian], C=[Charlie, Chris]}

        // Group by length with counting
        Map<Integer, Long> byLength = names.stream()
            .collect(Collectors.groupingBy(String::length, Collectors.counting()));
        System.out.println("By length: " + byLength);
        // Output: By length: {3=1, 4=1, 5=3, 7=1}

        // partitioningBy -- split into two groups
        Map<Boolean, List<String>> partitioned = names.stream()
            .collect(Collectors.partitioningBy(n -> n.length() > 4));
        System.out.println("Long names: " + partitioned.get(true));
        System.out.println("Short names: " + partitioned.get(false));
        // Output: Long names: [Alice, Brian, Charlie, Chris]
        //         Short names: [Anna, Bob]
    }
}

6. Parallel Streams

Parallel streams divide the workload across multiple threads using the common ForkJoinPool. They can significantly speed up processing of large data sets, but they are not always faster and come with important caveats.

When to Use Parallel Streams

Use Parallel When… Avoid Parallel When…
Large data sets (100,000+ elements) Small data sets (overhead exceeds benefit)
CPU-bound operations (computation) I/O-bound operations (network, file reads)
No shared mutable state Operations modify shared variables
Operations are independent and stateless Order-dependent processing
Source supports efficient splitting (ArrayList, arrays) Poor-splitting sources (LinkedList, Stream.iterate)
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class ParallelStreamExample {
    public static void main(String[] args) {
        // Creating a parallel stream
        List<Integer> numbers = IntStream.rangeClosed(1, 1_000_000)
            .boxed()
            .collect(Collectors.toList());

        // Sequential sum
        long startSeq = System.nanoTime();
        long sumSeq = numbers.stream()
            .mapToLong(Integer::longValue)
            .sum();
        long timeSeq = System.nanoTime() - startSeq;

        // Parallel sum
        long startPar = System.nanoTime();
        long sumPar = numbers.parallelStream()
            .mapToLong(Integer::longValue)
            .sum();
        long timePar = System.nanoTime() - startPar;

        System.out.println("Sequential sum: " + sumSeq + " in " + timeSeq / 1_000_000 + "ms");
        System.out.println("Parallel sum:   " + sumPar + " in " + timePar / 1_000_000 + "ms");

        // Converting between sequential and parallel
        List<String> names = List.of("Alice", "Bob", "Charlie");
        names.stream()
            .parallel()    // switch to parallel
            .sequential()  // switch back to sequential
            .forEach(System.out::println);
    }
}

Thread Safety Warning

Parallel streams use multiple threads. If your operations modify shared mutable state, you will get race conditions and incorrect results. Here is an example of what NOT to do:

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class ParallelSafetyExample {
    public static void main(String[] args) {
        // WRONG: Modifying a shared list from parallel stream
        List<Integer> unsafeList = new ArrayList<>();
        IntStream.rangeClosed(1, 10000)
            .parallel()
            .forEach(unsafeList::add); // Race condition!
        System.out.println("Unsafe size: " + unsafeList.size());
        // Output: unpredictable -- could be less than 10000, could throw exception

        // CORRECT: Use collect() instead of modifying shared state
        List<Integer> safeList = IntStream.rangeClosed(1, 10000)
            .parallel()
            .boxed()
            .collect(Collectors.toList());
        System.out.println("Safe size: " + safeList.size());
        // Output: Safe size: 10000

        // CORRECT: Use a synchronized collection if you must
        List<Integer> syncList = Collections.synchronizedList(new ArrayList<>());
        IntStream.rangeClosed(1, 10000)
            .parallel()
            .forEach(syncList::add);
        System.out.println("Sync size: " + syncList.size());
        // Output: Sync size: 10000
    }
}

7. Primitive Streams

Java generics do not work with primitives, so Stream<int> is not valid. To avoid the performance cost of boxing/unboxing (wrapping primitives in Integer, Long, Double), Java provides three specialized stream types:

Stream Type Element Type Key Methods
IntStream int sum(), average(), min(), max(), range()
LongStream long sum(), average(), min(), max(), range()
DoubleStream double sum(), average(), min(), max()

Converting Between Object and Primitive Streams

From To Method
Stream<T> IntStream mapToInt(ToIntFunction)
Stream<T> LongStream mapToLong(ToLongFunction)
Stream<T> DoubleStream mapToDouble(ToDoubleFunction)
IntStream Stream<Integer> boxed()
IntStream LongStream asLongStream()
IntStream DoubleStream asDoubleStream()
import java.util.IntSummaryStatistics;
import java.util.List;
import java.util.OptionalDouble;
import java.util.OptionalInt;
import java.util.stream.IntStream;

public class PrimitiveStreamExample {
    public static void main(String[] args) {
        // IntStream basics
        int sum = IntStream.of(10, 20, 30, 40, 50).sum();
        System.out.println("Sum: " + sum); // Output: Sum: 150

        OptionalDouble avg = IntStream.of(10, 20, 30, 40, 50).average();
        System.out.println("Average: " + avg.orElse(0.0)); // Output: Average: 30.0

        OptionalInt min = IntStream.of(10, 20, 30, 40, 50).min();
        System.out.println("Min: " + min.orElse(-1)); // Output: Min: 10

        OptionalInt max = IntStream.of(10, 20, 30, 40, 50).max();
        System.out.println("Max: " + max.orElse(-1)); // Output: Max: 50

        // summaryStatistics -- get all stats in one pass
        IntSummaryStatistics stats = IntStream.of(10, 20, 30, 40, 50)
            .summaryStatistics();
        System.out.println("Count: " + stats.getCount());     // 5
        System.out.println("Sum: " + stats.getSum());         // 150
        System.out.println("Min: " + stats.getMin());         // 10
        System.out.println("Max: " + stats.getMax());         // 50
        System.out.println("Avg: " + stats.getAverage());     // 30.0

        // mapToInt -- convert Stream<String> to IntStream
        List<String> words = List.of("Java", "is", "powerful");
        int totalChars = words.stream()
            .mapToInt(String::length)
            .sum();
        System.out.println("Total chars: " + totalChars); // Output: Total chars: 15

        // boxed() -- convert IntStream back to Stream<Integer>
        List<Integer> boxed = IntStream.rangeClosed(1, 5)
            .boxed()
            .toList();
        System.out.println("Boxed: " + boxed); // Output: Boxed: [1, 2, 3, 4, 5]
    }
}

8. Stream Pipeline Best Practices

Streams are a powerful tool, but they can be misused. Following these best practices will keep your stream pipelines readable, correct, and performant.

8.1 Keep Operations Simple

Each operation in the pipeline should do one thing. If a lambda is more than 2-3 lines, extract it into a method and use a method reference.

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

public class BestPracticeSimple {
    public static void main(String[] args) {
        List<String> emails = List.of(
            "alice@GMAIL.com", "  bob@yahoo.com  ", "CHARLIE@gmail.com", "invalid-email"
        );

        // BAD: Complex logic crammed into one lambda
        List<String> bad = emails.stream()
            .filter(e -> {
                String trimmed = e.trim().toLowerCase();
                return trimmed.contains("@") && trimmed.endsWith(".com");
            })
            .map(e -> e.trim().toLowerCase())
            .collect(Collectors.toList());

        // GOOD: Extract complex logic into named methods
        List<String> good = emails.stream()
            .map(String::trim)
            .map(String::toLowerCase)
            .filter(BestPracticeSimple::isValidEmail)
            .collect(Collectors.toList());

        System.out.println("Valid emails: " + good);
        // Output: Valid emails: [alice@gmail.com, bob@yahoo.com, charlie@gmail.com]
    }

    private static boolean isValidEmail(String email) {
        return email.contains("@") && email.endsWith(".com");
    }
}

8.2 Avoid Side Effects

Stream operations should be pure functions — they should not modify external state. The exception is forEach(), which exists specifically for side effects.

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

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

        // BAD: Side effect in map() -- modifying an external list
        List<String> external = new ArrayList<>();
        names.stream()
            .map(n -> {
                external.add(n); // Side effect -- do not do this!
                return n.toUpperCase();
            })
            .collect(Collectors.toList());

        // GOOD: Use collect() to build the result
        List<String> result = names.stream()
            .map(String::toUpperCase)
            .collect(Collectors.toList());
        System.out.println("Result: " + result);
        // Output: Result: [ALICE, BOB, CHARLIE]
    }
}

8.3 Do Not Reuse Streams

A Stream can only be consumed once. Attempting to reuse it throws IllegalStateException. If you need to process the same data multiple times, create a new Stream each time or store the source data.

import java.util.List;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;

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

        // BAD: Reusing a stream
        Stream<String> stream = names.stream().filter(n -> n.length() > 3);
        stream.forEach(System.out::println);  // works
        // stream.count();  // IllegalStateException: stream has already been operated upon

        // GOOD: Create a new stream each time
        long count = names.stream().filter(n -> n.length() > 3).count();
        List<String> filtered = names.stream()
            .filter(n -> n.length() > 3)
            .collect(Collectors.toList());

        // ALTERNATIVE: Use a Supplier<Stream> for repeated use
        Supplier<Stream<String>> streamSupplier = () ->
            names.stream().filter(n -> n.length() > 3);

        long count2 = streamSupplier.get().count();
        List<String> list2 = streamSupplier.get().collect(Collectors.toList());
        System.out.println("Count: " + count2 + ", List: " + list2);
    }
}

8.4 Order of Operations Matters

The order of intermediate operations can significantly affect performance. Filter early to reduce the number of elements processed by expensive operations like sorted() or map().

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

public class BestPracticeOrder {
    public static void main(String[] args) {
        List<String> names = List.of("Charlie", "Alice", "Eve", "Bob", "David",
            "Frank", "Grace", "Hannah", "Ivy", "Jack");

        // BAD: Sort everything, then filter
        List<String> bad = names.stream()
            .sorted()                            // sorts all 10 elements
            .filter(n -> n.length() > 3)         // then filters
            .map(String::toUpperCase)
            .collect(Collectors.toList());

        // GOOD: Filter first, then sort the smaller set
        List<String> good = names.stream()
            .filter(n -> n.length() > 3)         // filters to 7 elements
            .sorted()                            // sorts only 7 elements
            .map(String::toUpperCase)
            .collect(Collectors.toList());

        System.out.println("Result: " + good);
        // Output: Result: [ALICE, CHARLIE, DAVID, FRANK, GRACE, HANNAH]
    }
}

9. Stream vs Loop

Streams and loops are both tools for processing collections. Neither is universally better — each has strengths. Knowing when to use which is a mark of a senior developer.

When Streams Shine

Scenario Why Streams Win
Filter + transform + collect Readable, composable pipeline
Grouping and aggregation groupingBy, counting, summarizing are expressive
Parallel processing One method call to parallelize
Chaining multiple operations Flat pipeline vs nested loops
Declarative data queries Reads like SQL: filter-where, map-select, collect-into

When Loops Are Better

Scenario Why Loops Win
Need to break/continue mid-iteration Streams have no break/continue
Modifying the source collection Streams do not modify the source
Index-based access needed Streams do not expose indices directly
Multiple return values from one pass Loops can update multiple variables
Checked exceptions in the logic Lambdas do not support checked exceptions neatly
Simple iteration with nothing to transform A for-each loop is simpler
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

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

        // --- STREAM WINS: Filter + Transform + Collect ---

        // Stream approach -- clean, declarative
        List<String> streamResult = names.stream()
            .filter(n -> n.startsWith("A"))
            .map(String::toUpperCase)
            .sorted()
            .collect(Collectors.toList());

        // Loop approach -- more verbose
        List<String> loopResult = new ArrayList<>();
        for (String name : names) {
            if (name.startsWith("A")) {
                loopResult.add(name.toUpperCase());
            }
        }
        java.util.Collections.sort(loopResult);

        System.out.println("Stream: " + streamResult);
        System.out.println("Loop:   " + loopResult);

        // --- STREAM WINS: Grouping ---
        Map<Character, List<String>> grouped = names.stream()
            .collect(Collectors.groupingBy(n -> n.charAt(0)));
        System.out.println("Grouped: " + grouped);
        // This would take 10+ lines with loops

        // --- LOOP WINS: Early exit with break ---
        String found = null;
        for (String name : names) {
            if (name.length() > 5) {
                found = name;
                break; // Stream alternative: findFirst(), but less flexible
            }
        }
        System.out.println("Found: " + found);

        // --- LOOP WINS: Index-based access ---
        for (int i = 0; i < names.size(); i++) {
            System.out.println(i + ": " + names.get(i));
        }
    }
}

10. Complete Practical Example: Employee Analytics

Let us bring everything together with a realistic example. We have a list of employees and need to perform various analytics: filtering, grouping, aggregating, and reporting.

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

public class EmployeeAnalytics {

    enum Department { ENGINEERING, MARKETING, SALES, HR, FINANCE }

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

    public static void main(String[] args) {
        List<Employee> employees = List.of(
            new Employee("Alice",   Department.ENGINEERING, 95000, 5),
            new Employee("Bob",     Department.ENGINEERING, 110000, 8),
            new Employee("Charlie", Department.MARKETING,   72000, 3),
            new Employee("Diana",   Department.SALES,       68000, 2),
            new Employee("Eve",     Department.ENGINEERING, 125000, 12),
            new Employee("Frank",   Department.HR,          65000, 4),
            new Employee("Grace",   Department.FINANCE,     88000, 6),
            new Employee("Hannah",  Department.MARKETING,   78000, 5),
            new Employee("Ivan",    Department.SALES,       71000, 3),
            new Employee("Julia",   Department.FINANCE,     92000, 7),
            new Employee("Kevin",   Department.HR,          60000, 1),
            new Employee("Laura",   Department.ENGINEERING, 105000, 9),
            new Employee("Mike",    Department.SALES,       75000, 4),
            new Employee("Nina",    Department.MARKETING,   82000, 6),
            new Employee("Oscar",   Department.FINANCE,     97000, 8)
        );

        // 1. Total salary expense
        double totalSalary = employees.stream()
            .mapToDouble(Employee::salary)
            .sum();
        System.out.println("Total salary expense: $" + String.format("%,.2f", totalSalary));

        // 2. Average salary
        double avgSalary = employees.stream()
            .mapToDouble(Employee::salary)
            .average()
            .orElse(0.0);
        System.out.println("Average salary: $" + String.format("%,.2f", avgSalary));

        // 3. Highest paid employee
        employees.stream()
            .max(Comparator.comparingDouble(Employee::salary))
            .ifPresent(e -> System.out.println("Highest paid: " + e.name() + " ($" +
                String.format("%,.2f", e.salary()) + ")"));

        // 4. Employees earning above average
        List<String> aboveAverage = employees.stream()
            .filter(e -> e.salary() > avgSalary)
            .sorted(Comparator.comparingDouble(Employee::salary).reversed())
            .map(e -> e.name() + " ($" + String.format("%,.2f", e.salary()) + ")")
            .collect(Collectors.toList());
        System.out.println("Above average: " + aboveAverage);

        // 5. Group by department
        Map<Department, List<String>> byDept = employees.stream()
            .collect(Collectors.groupingBy(
                Employee::department,
                Collectors.mapping(Employee::name, Collectors.toList())
            ));
        System.out.println("\nEmployees by department:");
        byDept.forEach((dept, names) ->
            System.out.println("  " + dept + ": " + names));

        // 6. Average salary by department
        Map<Department, Double> avgByDept = employees.stream()
            .collect(Collectors.groupingBy(
                Employee::department,
                Collectors.averagingDouble(Employee::salary)
            ));
        System.out.println("\nAverage salary by department:");
        avgByDept.entrySet().stream()
            .sorted(Map.Entry.<Department, Double>comparingByValue().reversed())
            .forEach(e -> System.out.println("  " + e.getKey() + ": $" +
                String.format("%,.2f", e.getValue())));

        // 7. Department with highest average salary
        avgByDept.entrySet().stream()
            .max(Map.Entry.comparingByValue())
            .ifPresent(e -> System.out.println("\nHighest avg dept: " + e.getKey() +
                " ($" + String.format("%,.2f", e.getValue()) + ")"));

        // 8. Count employees per department
        Map<Department, Long> countByDept = employees.stream()
            .collect(Collectors.groupingBy(Employee::department, Collectors.counting()));
        System.out.println("\nHeadcount by department: " + countByDept);

        // 9. Partition into senior (5+ years) and junior
        Map<Boolean, List<Employee>> seniorPartition = employees.stream()
            .collect(Collectors.partitioningBy(e -> e.yearsOfExperience() >= 5));
        System.out.println("\nSenior employees (5+ years):");
        seniorPartition.get(true).forEach(e ->
            System.out.println("  " + e.name() + " - " + e.yearsOfExperience() + " years"));
        System.out.println("Junior employees (< 5 years):");
        seniorPartition.get(false).forEach(e ->
            System.out.println("  " + e.name() + " - " + e.yearsOfExperience() + " years"));

        // 10. Salary statistics by department
        System.out.println("\nSalary statistics by department:");
        employees.stream()
            .collect(Collectors.groupingBy(
                Employee::department,
                Collectors.summarizingDouble(Employee::salary)
            ))
            .forEach((dept, stats) -> System.out.printf(
                "  %s: count=%d, min=$%,.0f, max=$%,.0f, avg=$%,.0f, total=$%,.0f%n",
                dept, stats.getCount(), stats.getMin(), stats.getMax(),
                stats.getAverage(), stats.getSum()));

        // 11. Top 3 earners across the company
        System.out.println("\nTop 3 earners:");
        employees.stream()
            .sorted(Comparator.comparingDouble(Employee::salary).reversed())
            .limit(3)
            .forEach(e -> System.out.println("  " + e.name() + " - $" +
                String.format("%,.2f", e.salary())));

        // 12. Build a salary report string
        String report = employees.stream()
            .sorted(Comparator.comparing(Employee::name))
            .map(e -> String.format("%-10s | %-12s | $%,10.2f | %2d yrs",
                e.name(), e.department(), e.salary(), e.yearsOfExperience()))
            .collect(Collectors.joining("\n"));
        System.out.println("\n--- Employee Report ---");
        System.out.println(String.format("%-10s | %-12s | %11s | %s",
            "Name", "Department", "Salary", "Experience"));
        System.out.println("-".repeat(55));
        System.out.println(report);
    }
}

Expected Output (partial)


Total salary expense: $1,283,000.00
Average salary: $85,533.33
Highest paid: Eve ($125,000.00)
Above average: [Eve ($125,000.00), Bob ($110,000.00), Laura ($105,000.00), Oscar ($97,000.00), Alice ($95,000.00), Julia ($92,000.00), Grace ($88,000.00)]

Employees by department:
  ENGINEERING: [Alice, Bob, Eve, Laura]
  MARKETING: [Charlie, Hannah, Nina]
  SALES: [Diana, Ivan, Mike]
  HR: [Frank, Kevin]
  FINANCE: [Grace, Julia, Oscar]

Average salary by department:
  ENGINEERING: $108,750.00
  FINANCE: $92,333.33
  MARKETING: $77,333.33
  SALES: $71,333.33
  HR: $62,500.00

Top 3 earners:
  Eve - $125,000.00
  Bob - $110,000.00
  Laura - $105,000.00

Key Takeaways

  • Streams are declarative -- you describe what you want, not how to iterate.
  • Pipelines are composable -- chain filter, map, sort, and collect in a readable flow.
  • Lazy evaluation means only the necessary work is performed.
  • Collectors are the Swiss army knife for aggregation -- grouping, counting, averaging, joining.
  • Parallel streams are powerful but require thread-safe, stateless operations.
  • Primitive streams avoid boxing overhead for numeric operations.
  • Use streams for data transformation and loops when you need fine-grained control.