Skip to main content

Chapter 10 โ€” Streams

Exam Domain: Working with Streams and Lambda Expressions

Key Topics: Optional API, Stream Pipeline Lifecycle, Intermediate vs. Terminal Operations, Primitive Streams, Collectors (groupingBy, partitioningBy, teeing), Spliterator, and checked exceptions in lambdas.


๐ŸŸฆ New Learner: Stream Pipelinesโ€‹

1. Understanding Optionalโ€‹

Optional<T> is a container object introduced in Java 8 to represent the presence or absence of a non-null value, avoiding runtime NullPointerExceptions.

Creating Optionalsโ€‹

  • Optional.empty(): Returns an empty Optional instance.
  • Optional.of(value): Returns an Optional describing the specified non-null value. Throws NullPointerException if the value is null.
  • Optional.ofNullable(value): Returns an Optional describing the specified value if non-null, otherwise returns an empty Optional.
Optional<String> o1 = Optional.empty();
Optional<String> o2 = Optional.of("Hello"); // Throws NPE if argument is null
Optional<String> o3 = Optional.ofNullable(null); // Returns Optional.empty()

Retrieving Values safelyโ€‹

  • get(): Returns the value if present, otherwise throws NoSuchElementException. (Avoid calling without guard).
  • isPresent(): Returns true if there is a value, otherwise false.
  • isEmpty(): Returns true if empty, otherwise false (Java 11+).
  • ifPresent(Consumer<? super T> action): If a value is present, performs the given action with the value.
  • ifPresentOrElse(Consumer<? super T> action, Runnable emptyAction): Java 9+ method that performs the action if present, otherwise runs the empty action.
  • orElse(T other): Returns the value if present, otherwise returns other.
  • orElseGet(Supplier<? super T> supplier): Returns the value if present, otherwise returns the result of the supplier. Useful for lazy loading.
  • orElseThrow(): Returns the value if present, otherwise throws NoSuchElementException (Java 10+).
  • orElseThrow(Supplier<? extends X> exceptionSupplier): Returns the value if present, otherwise throws an exception produced by the supplier.

2. Stream Pipelines and Sourcesโ€‹

A Java Stream pipeline is a sequence of elements supporting sequential and parallel aggregate operations. It has three parts:

  1. Source: Creates the stream (finite or infinite).
  2. Intermediate Operations: Process elements, yielding another stream. Lazy execution: elements are processed only when a terminal operation starts.
  3. Terminal Operation: Consumes the stream, triggers pipeline execution, and returns a non-stream result (e.g., list, count, void).
Source โ”€โ”€[Intermediate Ops (Lazy)]โ”€โ”€โ–บ Terminal Op (Triggers Execution)

Creating Stream Sourcesโ€‹

  • Finite Sources:
    • Stream.empty(): Creates an empty stream.
    • Stream.of(varargs): Creates a stream of specified values.
    • list.stream(): Creates a stream from a Collection.
    • Arrays.stream(array): Creates a stream from an array.
  • Infinite Sources:
    • Stream.generate(Supplier<T> s): Creates an infinite stream where each element is generated by the supplier.
    • Stream.iterate(T seed, UnaryOperator<T> f): Creates an infinite stream by applying f iteratively to seed.
    • Stream.iterate(T seed, Predicate<? super T> hasNext, UnaryOperator<T> next): Java 9+ version creating a finite stream similar to a for loop.
Stream<Double> randoms = Stream.generate(Math::random); // Infinite
Stream<Integer> evens = Stream.iterate(0, n -> n + 2); // Infinite: 0, 2, 4, 6...
Stream<Integer> limited = Stream.iterate(0, n -> n < 10, n -> n + 2); // Finite: 0, 2, 4, 6, 8

3. Terminal Operationsโ€‹

A stream is not processed until a terminal operation is called. Terminal operations consume the stream; once completed, the stream is closed and cannot be reused.

MethodReturn TypeReduction?Description
count()longYesReturns the number of elements in the stream.
min(Comparator)Optional<T>YesReturns the minimum element based on the comparator.
max(Comparator)Optional<T>YesReturns the maximum element based on the comparator.
findFirst()Optional<T>No (Short-circuits)Returns the first element of the stream (ordered streams).
findAny()Optional<T>No (Short-circuits)Returns any element of the stream (ideal for parallel streams).
anyMatch(Predicate)booleanNo (Short-circuits)Returns true if any element matches.
allMatch(Predicate)booleanNo (Short-circuits)Returns true if all elements match.
noneMatch(Predicate)booleanNo (Short-circuits)Returns true if no elements match.
forEach(Consumer)voidNoPerforms an action for each element.
reduce(...)T or Optional<T>YesCombines stream elements into a single summary value.
collect(Collector)RYes (Mutable)Accumulates elements into a mutable container (e.g., List, Set).

The reduce() Operationโ€‹

Reduces stream elements into a single element. There are three overloads:

  1. reduce(BinaryOperator<T> accumulator): Returns Optional<T> because the stream might be empty.
  2. reduce(T identity, BinaryOperator<T> accumulator): Returns T. The identity value is used if the stream is empty.
  3. reduce(U identity, BiFunction<U, ? super T, U> accumulator, BinaryOperator<U> combiner): Returns U. Used in parallel processing to combine results of sub-threads.
// Overload 1 (returns Optional)
Optional<Integer> sumOpt = Stream.of(1, 2, 3).reduce((a, b) -> a + b);

// Overload 2 (returns value)
int sum = Stream.of(1, 2, 3).reduce(0, (a, b) -> a + b);

// Overload 3 (parallel combiner)
int lengthSum = Stream.of("a", "bb", "ccc").reduce(0, (i, s) -> i + s.length(), Integer::sum);

4. Intermediate Operationsโ€‹

Intermediate operations return a new stream. They do not run until the terminal operation is called.

  • filter(Predicate<? super T> p): Keeps elements that match the predicate.
  • distinct(): Removes duplicates based on equals().
  • limit(long maxSize): Truncates the stream to be no longer than maxSize (Short-circuiting).
  • skip(long n): Discards the first n elements.
  • map(Function<? super T, ? extends R> mapper): Converts each element of type T to type R.
  • flatMap(Function<? super T, ? extends Stream<? extends R>> mapper): Replaces each element of type T with a stream, then flattens all nested streams into a single stream.
  • sorted() / sorted(Comparator<? super T> comparator): Sorts elements using natural ordering or a custom comparator.
  • peek(Consumer<? super T> action): Performs an action on each element as it flows past. Designed for debugging.

๐ŸŸฃ Senior Deep Diveโ€‹

1. The Danger of Modifying State in peek()โ€‹

The peek() intermediate operation is designed strictly to support debugging without altering the stream's contents. Introducing state-changing operations or side effects within peek() can lead to unpredictable behavior, especially under optimization or parallel execution.

// BAD PRACTICE: peek modifying underlying collection elements
var numbers = new ArrayList<>(List.of(1, 2, 3));
var letters = new ArrayList<>(List.of('a', 'b', 'c'));
Stream<List<?>> stream = Stream.of(numbers, letters);
stream.peek(list -> list.remove(0)) // Modifying source data!
.map(List::size)
.forEach(System.out::print); // Prints "22" instead of "33"

2. Primitive Streams and Primitive Optionalsโ€‹

Object streams (Stream<Integer>, etc.) suffer from performance overhead due to autoboxing and unboxing. Java provides optimized primitive streams for int, long, and double primitives:

  • IntStream: For int, short, byte, char
  • LongStream: For long
  • DoubleStream: For double, float

Range Methods (Only on IntStream and LongStream)โ€‹

  • range(inclusiveStart, exclusiveEnd): generates range from start to end - 1.
  • rangeClosed(inclusiveStart, inclusiveEnd): generates range including end.
IntStream.range(1, 6).forEach(System.out::print); // 12345
IntStream.rangeClosed(1, 5).forEach(System.out::print); // 12345

Mapping Between Stream Typesโ€‹

To convert between different stream types, specific map methods must be used:

FromTo Stream<R>To IntStreamTo LongStreamTo DoubleStream
Stream<T>map(Function)mapToInt(ToIntFunction)mapToLong(ToLongFunction)mapToDouble(ToDoubleFunction)
IntStreammapToObj(IntFunction) / boxed()map(IntUnaryOperator)mapToLong(IntToLongFunction)mapToDouble(IntToDoubleFunction)
LongStreammapToObj(LongFunction) / boxed()mapToInt(LongToIntFunction)map(LongUnaryOperator)mapToDouble(LongToDoubleFunction)
DoubleStreammapToObj(DoubleFunction) / boxed()mapToInt(DoubleToIntFunction)mapToLong(DoubleToLongFunction)map(DoubleUnaryOperator)
Stream<String> s = Stream.of("bear", "grizzly");
IntStream lengths = s.mapToInt(String::length); // Stream<String> -> IntStream
Stream<Integer> boxed = lengths.boxed(); // IntStream -> Stream<Integer>

Primitive Optionalsโ€‹

Primitive streams have primitive version equivalents of Optional to avoid boxing:

  • OptionalInt (has getAsInt())
  • OptionalLong (has getAsLong())
  • OptionalDouble (has getAsDouble())
DoubleStream doubles = DoubleStream.of(1.5, 3.0);
OptionalDouble max = doubles.max();
System.out.println(max.getAsDouble()); // 3.0

Summarizing Statisticsโ€‹

Running multiple calculations (min, max, average, sum, count) would normally require running multiple terminal operations, which is illegal on a single stream. summaryStatistics() solves this by performing all calculations in a single pass.

IntSummaryStatistics stats = IntStream.of(1, 2, 3, 4, 5).summaryStatistics();
System.out.println("Max: " + stats.getMax()); // 5
System.out.println("Min: " + stats.getMin()); // 1
System.out.println("Average: " + stats.getAverage()); // 3.0
System.out.println("Count: " + stats.getCount()); // 5
System.out.println("Sum: " + stats.getSum()); // 15

3. Advanced Collectorsโ€‹

The Collectors class provides a suite of collectors to group, partition, map, and accumulate stream elements.

Grouping elements (groupingBy)โ€‹

Groups stream elements into a Map.

  • groupingBy(classifier): Groups elements by the classifier. Values are List<T>.
  • groupingBy(classifier, downstreamCollector): Groups elements and applies the downstream collector to the values.
  • groupingBy(classifier, mapSupplier, downstreamCollector): Customizes the map type (e.g., TreeMap::new).
var animals = Stream.of("lions", "tigers", "bears");
// Simple grouping -> Map<Integer, List<String>>
Map<Integer, List<String>> byLength = animals.collect(Collectors.groupingBy(String::length)); // {5=[lions, bears], 6=[tigers]}

// Downstream grouping -> Map<Integer, Set<String>>
Map<Integer, Set<String>> setByLength = Stream.of("lions", "tigers", "bears")
.collect(Collectors.groupingBy(String::length, Collectors.toSet()));

// Grouping to custom Map -> TreeMap<Integer, Set<String>>
TreeMap<Integer, Set<String>> treeMap = Stream.of("lions", "tigers", "bears")
.collect(Collectors.groupingBy(String::length, TreeMap::new, Collectors.toSet()));

Partitioning elements (partitioningBy)โ€‹

Partitioning is a special case of grouping where the keys are always Boolean (true and false).

  • partitioningBy(predicate): Splits elements into lists based on the predicate.
  • partitioningBy(predicate, downstreamCollector): Applies downstream collector.
var animals = Stream.of("lions", "tigers", "bears");
Map<Boolean, List<String>> partitioned = animals.collect(
Collectors.partitioningBy(s -> s.length() <= 5)
); // {false=[tigers], true=[lions, bears]}

[!NOTE] Unlike groupingBy(), partitioningBy() always contains both true and false keys in the resulting map, even if one of the corresponding value lists is empty.

Downstream Collectors (mapping, flatMapping)โ€‹

Allows running a secondary operation inside a grouping or partitioning collector.

  • Collectors.mapping(mapper, downstream): Transforms the elements before sending them to the downstream collector.
  • Collectors.flatMapping(mapper, downstream): Flattens streams of elements inside the grouping operation.
// Group animals by length and collect their first character
Map<Integer, Optional<Character>> map = Stream.of("lions", "tigers", "bears").collect(
Collectors.groupingBy(
String::length,
Collectors.mapping(s -> s.charAt(0), Collectors.minBy(Comparator.naturalOrder()))
)
); // {5=Optional[b], 6=Optional[t]}

Teeing Collectors (teeing)โ€‹

Introduced in Java 12, teeing() splits a stream into two independent collectors and then merges their results using a user-defined function.

record Aggregation(long count, double sum) {}

Aggregation result = Stream.of(10, 20, 30, 40).collect(
Collectors.teeing(
Collectors.counting(),
Collectors.summingDouble(Integer::doubleValue),
(count, sum) -> new Aggregation(count, sum)
)
); // Aggregation[count=4, sum=100.0]

4. Working with Spliteratorโ€‹

A Spliterator ("splitable iterator") is used to traverse and partition elements of a source. It is the core engine behind parallel streams.

Common Methodsโ€‹

  • trySplit(): Attempts to partition the elements of the current spliterator into a new one. It typically divides the elements roughly in half. Returns null if the data cannot be split (e.g. empty or too small).
  • tryAdvance(Consumer<? super T> action): If a remaining element exists, performs the action on it and returns true; else returns false.
  • forEachRemaining(Consumer<? super T> action): Performs the action on each remaining element sequentially in the current thread.
var data = List.of("bird", "bunny", "cat", "dog", "fish", "lamb");
Spliterator<String> mainSpliterator = data.spliterator();
Spliterator<String> splitSpliterator = mainSpliterator.trySplit(); // Splits off "bird", "bunny", "cat"

splitSpliterator.forEachRemaining(System.out::print); // birdbunnycat
mainSpliterator.forEachRemaining(System.out::print); // dogfishlamb

5. Checked Exceptions and Functional Interfacesโ€‹

Standard functional interfaces (e.g., Supplier, Function, Predicate) do not declare checked exceptions. If a lambda calls code that throws a checked exception, you must handle it.

Workaroundsโ€‹

  1. Catch and Wrap: Wrap the checked exception in a RuntimeException.
  2. Helper Method: Write a helper method that performs the try-catch block and call it via method reference.
// IOException helper
static List<String> readFile() throws IOException {
throw new IOException();
}

// Wrapper method
static List<String> readFileSafe() {
try {
return readFile();
} catch (IOException e) {
throw new RuntimeException(e);
}
}

// In the stream pipeline
Supplier<List<String>> s1 = () -> {
try { return readFile(); }
catch (IOException e) { throw new RuntimeException(e); }
}; // Verbose lambda

Supplier<List<String>> s2 = ExceptionClass::readFileSafe; // Cleaner method reference

๐Ÿ“ Exam Quick Referenceโ€‹

Stream Terminal Operations & Propertiesโ€‹

OperationTypeReturn TypeShort-circuiting?Reduction?
allMatchTerminalbooleanYesNo
anyMatchTerminalbooleanYesNo
noneMatchTerminalbooleanYesNo
collectTerminalRNoYes (Mutable)
countTerminallongNoYes
findAnyTerminalOptional<T>YesNo
findFirstTerminalOptional<T>YesNo
forEachTerminalvoidNoNo
min / maxTerminalOptional<T>NoYes
reduceTerminalT or Optional<T>NoYes

Optional Cheat Sheetโ€‹

  • Factory: Optional.empty(), Optional.of(nonNull), Optional.ofNullable(nullable)
  • Presence: isPresent(), isEmpty()
  • Action: ifPresent(Consumer), ifPresentOrElse(Consumer, Runnable)
  • Fallback: orElse(val), orElseGet(Supplier), orElseThrow(), orElseThrow(ExceptionSupplier)

๐Ÿšจ Extra Exam Tipsโ€‹

Top Traps in Chapter 10

Trap 1 โ€” Reusing a Closed Stream: Calling any operation on a stream that has already had a terminal operation executed on it throws an IllegalStateException.

Stream<Integer> s = Stream.of(1, 2, 3);
s.count(); // Stream is now closed!
s.forEach(System.out::println); // โŒ Throws IllegalStateException

Trap 2 โ€” peek() Not Executing Due to Short-Circuiting or Lack of Terminal Op: Because streams are lazy, intermediate operations like peek() will not run unless a terminal operation demands elements.

Stream.of(1, 2, 3).peek(System.out::println); // Prints nothing! (No terminal operation)

Trap 3 โ€” allMatch() / noneMatch() on Empty Streams: On an empty stream, allMatch() and noneMatch() return true (vacuously true), while anyMatch() returns false.

Stream<String> empty = Stream.empty();
System.out.println(empty.allMatch(s -> s.length() > 5)); // true
System.out.println(empty.noneMatch(s -> s.length() > 5)); // true
System.out.println(empty.anyMatch(s -> s.length() > 5)); // false

Trap 4 โ€” Calling Optional.get() on an Empty Optional: Calling .get() on an empty optional results in a NoSuchElementException at runtime.

Optional<Integer> opt = Optional.empty();
opt.get(); // โŒ Throws NoSuchElementException at runtime

Trap 5 โ€” Collectors.toMap Duplicate Keys: If the stream contains elements that map to duplicate keys, Collectors.toMap throws an IllegalStateException unless a merge function is provided.

// โŒ Throws IllegalStateException: Duplicate key 5
Stream.of("lions", "bears").collect(Collectors.toMap(String::length, s -> s));

// โœ… Correct with merge function:
Stream.of("lions", "bears").collect(Collectors.toMap(String::length, s -> s, (s1, s2) -> s1 + "," + s2));

Trap 6 โ€” sorted() without Comparator on Non-Comparable Classes: Calling sorted() without passing a comparator on a stream containing objects of a class that does not implement Comparable compiles but throws a ClassCastException at runtime.

class Lion {}
Stream.of(new Lion(), new Lion()).sorted(); // โŒ Throws ClassCastException at runtime

Trap 7 โ€” Side-effect State Changes inside stream lambdas: Avoid modifying external state variables inside lambda blocks inside forEach or map.

private static int count = 0;
// โŒ Side-effects can cause race conditions or incorrect calculations in parallel execution
Stream.iterate(1, n -> n + 1).limit(10).forEach(n -> count++);

Trap 8 โ€” range() vs rangeClosed():

  • IntStream.range(1, 5) returns numbers 1, 2, 3, 4 (exclusive).
  • IntStream.rangeClosed(1, 5) returns numbers 1, 2, 3, 4, 5 (inclusive).

Trap 9 โ€” Infinite Stream in sorted() or reduce(): Running a stateful intermediate operation like sorted() or a terminal operation like reduce(), count(), min(), or max() on an infinite stream will hang and cause an OutOfMemoryError.

Stream.generate(() -> "A").sorted().limit(2); // โŒ Hangs! sorted() tries to consume the entire infinite stream first.
Stream.generate(() -> "A").limit(2).sorted(); // โœ… Works! limit() makes it a finite stream first.

Trap 10 โ€” flatMapping vs mapping parameter orders: Keep in mind that Collectors.mapping() and Collectors.flatMapping() are downstream collectors that take the mapping function as the first parameter, and the downstream collector as the second.

// Correct format: mapping(mapperFunction, downstreamCollector)
Collectors.mapping(s -> s.charAt(0), Collectors.toList())
Spring/Senior Relevance
  • Database Cursor Safety: Spring Data methods returning Stream<T> must be closed using a try-with-resources statement because they hold a database connection cursor open until consumed.
  • Reactive Pipelines: Frameworks like Project Reactor (Flux/Mono) use the exact same terminology (map, flatMap, filter, reduce) as Java Streams. Understanding the stream lifecycle is vital for reactive coding.
  • Grouping and Mapping: Stream API operations like Collectors.groupingBy() are often used in data aggregation layers within Spring Service classes to structure entity database results before passing them to controller views or REST DTOs.

๐Ÿ”— Review Questions Focusโ€‹

  1. What is the output of Stream.of(1, 2).peek(System.out::print).map(x -> x * 2);? Why?
  2. If you have an empty Optional<String>, what happens if you run opt.orElseGet(null)? What about opt.orElse(null)?
  3. Why does Stream.generate(() -> "Elsa").filter(s -> s.length() == 4).sorted().limit(2).count() hang, but swapping .limit(2) before .sorted() runs successfully?
  4. What are the three arguments of the parallel-friendly reduce() overload, and what does the third argument (the combiner) do?
  5. How do the return types of Collectors.groupingBy() and Collectors.partitioningBy() differ when they are given the same element type?
  6. How does a Spliterator behaves when calling trySplit() on an infinite stream source compared to a finite List source?
  7. Under what conditions does Collectors.toMap(keyMapper, valueMapper) throw an exception, and how can it be avoided?
  8. Which mapping methods are used to transition a Stream<String> to an IntStream, and then back to a Stream<Integer>?
  9. Why does the functional interface Supplier not allow code that throws checked exceptions, and what are the two common ways to bypass this?
  10. Does IntStream.rangeClosed(1, 10).summaryStatistics().getAverage() return a float or a double?