Chapter 10 โ Streams
Key Topics:
OptionalAPI, 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 emptyOptionalinstance.Optional.of(value): Returns anOptionaldescribing the specified non-null value. ThrowsNullPointerExceptionif the value is null.Optional.ofNullable(value): Returns anOptionaldescribing the specified value if non-null, otherwise returns an emptyOptional.
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 throwsNoSuchElementException. (Avoid calling without guard).isPresent(): Returnstrueif there is a value, otherwisefalse.isEmpty(): Returnstrueif empty, otherwisefalse(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 returnsother.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 throwsNoSuchElementException(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:
- Source: Creates the stream (finite or infinite).
- Intermediate Operations: Process elements, yielding another stream. Lazy execution: elements are processed only when a terminal operation starts.
- 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 applyingfiteratively toseed.Stream.iterate(T seed, Predicate<? super T> hasNext, UnaryOperator<T> next): Java 9+ version creating a finite stream similar to aforloop.
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.
| Method | Return Type | Reduction? | Description |
|---|---|---|---|
count() | long | Yes | Returns the number of elements in the stream. |
min(Comparator) | Optional<T> | Yes | Returns the minimum element based on the comparator. |
max(Comparator) | Optional<T> | Yes | Returns 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) | boolean | No (Short-circuits) | Returns true if any element matches. |
allMatch(Predicate) | boolean | No (Short-circuits) | Returns true if all elements match. |
noneMatch(Predicate) | boolean | No (Short-circuits) | Returns true if no elements match. |
forEach(Consumer) | void | No | Performs an action for each element. |
reduce(...) | T or Optional<T> | Yes | Combines stream elements into a single summary value. |
collect(Collector) | R | Yes (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:
reduce(BinaryOperator<T> accumulator): ReturnsOptional<T>because the stream might be empty.reduce(T identity, BinaryOperator<T> accumulator): ReturnsT. The identity value is used if the stream is empty.reduce(U identity, BiFunction<U, ? super T, U> accumulator, BinaryOperator<U> combiner): ReturnsU. 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 onequals().limit(long maxSize): Truncates the stream to be no longer thanmaxSize(Short-circuiting).skip(long n): Discards the firstnelements.map(Function<? super T, ? extends R> mapper): Converts each element of typeTto typeR.flatMap(Function<? super T, ? extends Stream<? extends R>> mapper): Replaces each element of typeTwith 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: Forint,short,byte,charLongStream: ForlongDoubleStream: Fordouble,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:
| From | To Stream<R> | To IntStream | To LongStream | To DoubleStream |
|---|---|---|---|---|
Stream<T> | map(Function) | mapToInt(ToIntFunction) | mapToLong(ToLongFunction) | mapToDouble(ToDoubleFunction) |
IntStream | mapToObj(IntFunction) / boxed() | map(IntUnaryOperator) | mapToLong(IntToLongFunction) | mapToDouble(IntToDoubleFunction) |
LongStream | mapToObj(LongFunction) / boxed() | mapToInt(LongToIntFunction) | map(LongUnaryOperator) | mapToDouble(LongToDoubleFunction) |
DoubleStream | mapToObj(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(hasgetAsInt())OptionalLong(hasgetAsLong())OptionalDouble(hasgetAsDouble())
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 areList<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 bothtrueandfalsekeys 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. Returnsnullif 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 returnstrue; else returnsfalse.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โ
- Catch and Wrap: Wrap the checked exception in a
RuntimeException. - 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โ
| Operation | Type | Return Type | Short-circuiting? | Reduction? |
|---|---|---|---|---|
allMatch | Terminal | boolean | Yes | No |
anyMatch | Terminal | boolean | Yes | No |
noneMatch | Terminal | boolean | Yes | No |
collect | Terminal | R | No | Yes (Mutable) |
count | Terminal | long | No | Yes |
findAny | Terminal | Optional<T> | Yes | No |
findFirst | Terminal | Optional<T> | Yes | No |
forEach | Terminal | void | No | No |
min / max | Terminal | Optional<T> | No | Yes |
reduce | Terminal | T or Optional<T> | No | Yes |
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โ
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 numbers1, 2, 3, 4(exclusive).IntStream.rangeClosed(1, 5)returns numbers1, 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())
- 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โ
- What is the output of
Stream.of(1, 2).peek(System.out::print).map(x -> x * 2);? Why? - If you have an empty
Optional<String>, what happens if you runopt.orElseGet(null)? What aboutopt.orElse(null)? - Why does
Stream.generate(() -> "Elsa").filter(s -> s.length() == 4).sorted().limit(2).count()hang, but swapping.limit(2)before.sorted()runs successfully? - What are the three arguments of the parallel-friendly
reduce()overload, and what does the third argument (the combiner) do? - How do the return types of
Collectors.groupingBy()andCollectors.partitioningBy()differ when they are given the same element type? - How does a
Spliteratorbehaves when callingtrySplit()on an infinite stream source compared to a finiteListsource? - Under what conditions does
Collectors.toMap(keyMapper, valueMapper)throw an exception, and how can it be avoided? - Which mapping methods are used to transition a
Stream<String>to anIntStream, and then back to aStream<Integer>? - Why does the functional interface
Suppliernot allow code that throws checked exceptions, and what are the two common ways to bypass this? - Does
IntStream.rangeClosed(1, 10).summaryStatistics().getAverage()return afloator adouble?