Streams API
Stream vs Collection
Section titled “Stream vs Collection”A Collection is an in-memory data structure that holds elements. A Stream is a sequence of elements supporting sequential and parallel aggregate operations computed on demand from a source. The distinction is fundamental and understanding it prevents entire categories of bugs.
| Property | Collection | Stream |
|---|---|---|
| Storage | Holds elements in memory | Does not store elements; computes from a source |
| Evaluation | Eager (all elements materialized at once) | Lazy (elements computed on demand) |
| Consumability | Can be traversed multiple times | Single-use; consuming a terminal operation closes it |
| Mutability | Elements can be added, removed, replaced | Elements are never modified; operations produce new streams |
| Iteration | External (user controls the loop) | Internal (library controls the iteration) |
| Purpose | Store and organize data | Compute aggregate results and transform data |
List<String> names = List.of("Alice", "Bob", "Charlie", "Diana");
// Collection -- you own the data, you can iterate repeatedlyfor (String name : names) { System.out.println(name);}for (String name : names) { // fine -- collections are reusable System.out.println(name.toUpperCase());}
// Stream -- you describe WHAT you want, not HOW to iterateStream<String> stream = names.stream() .filter(n -> n.length() > 3) .map(String::toUpperCase);
stream.forEach(System.out::println); // OK -- first consumption// stream.forEach(System.out::println); // IllegalStateException: stream has already been operated upon or closedDesign Decision: Why Streams Are Lazy
Section titled “Design Decision: Why Streams Are Lazy”Streams are lazy for three reasons:
Performance — avoid unnecessary work. If you filter a million elements and then call
findFirst()A lazy stream processes only the elements up to the first match. An eager approach would filter all one million elements before returning the first. Laziness enables short-circuiting, which can turn an O(n) operation into an O(k) operation where k is the number of elements actually needed.Composability — enable infinite streams.
Stream.generate()andStream.iterate()can produce infinite sequences. These are only useful because intermediate operations are lazy — they describe transformations without materializing elements. Only when a terminal operation is invoked does the pipeline begin pulling elements, and a short-circuiting terminal operation likelimit()prevents infinite processing.Fusion — enable internal optimization. Because the stream pipeline is a description of operations rather than a sequence of concrete steps, the runtime can fuse multiple operations into a single pass over the data. For example,
filter().map().filter().map()is fused into a single traversal that applies all four predicates and functions per element, avoiding the creation of intermediate collections between each step.
// Lazy evaluation in action -- only 3 elements are ever processed// even though the source could be infiniteIntStream.iterate(1, n -> n + 1) // infinite: 1, 2, 3, 4, ... .filter(n -> n % 2 == 0) // lazy: 2, 4, 6, ... .map(n -> n * n) // lazy: 4, 16, 36, ... .limit(3) // short-circuiting: takes only 3 .forEach(System.out::println); // terminal: triggers the pipeline// Output: 4, 16, 36// Only 6 elements were tested by the filter (1,2,3,4,5,6)// Only 3 elements were mapped (2,4,6)Stream Pipeline Architecture
Section titled “Stream Pipeline Architecture”A stream pipeline consists of three parts: a source, zero or more intermediate operations, and one terminal operation.
graph LR
subgraph Source
S["Source<br/>(Collection, array,<br/>I/O channel, generator)"]
end
subgraph Intermediate ["Intermediate Operations (lazy)"]
F["filter()"]
M["map()"]
FL["flatMap()"]
D["distinct()"]
SO["sorted()"]
L["limit()"]
SK["skip()"]
end
subgraph Terminal ["Terminal Operation (eager)"]
C["collect()"]
FE["forEach()"]
R["reduce()"]
CO["count()"]
end
S --> F --> M --> FL --> D --> SO --> L --> SK --> C
S -.->|"alternate path"| FE
S -.->|"alternate path"| R
S -.->|"alternate path"| CO
style Source fill:#d4e6f1
style Intermediate fill:#d5f5e3
style Terminal fill:#fdebd0sequenceDiagram
participant T as Terminal Op
participant SK as skip()
participant SO as sorted()
participant F as filter()
participant M as map()
participant S as Source
Note over T,S: No data flows until terminal op is called
T->>SK: pull element
SK->>SO: pull element
SO->>F: pull element
F->>M: pull element
M->>S: pull element
S-->>M: element 1
M-->>F: mapped 1
F-->>SO: passes filter
SO-->>SK: sorted buffer
SK-->>T: skip check
Note over T,S: Repeat until terminal is satisfiedIntermediate operations return a new stream and are lazy — they do not process any elements until a terminal operation is invoked. Terminal operations produce a result or a side effect and close the stream.
Creating Streams
Section titled “Creating Streams”Stream.of
Section titled “Stream.of”Creates a stream from explicit values.
Stream<String> stream = Stream.of("a", "b", "c");Stream<String> single = Stream.of("only");
// Stream.of with null -- the stream will contain one null elementStream<String> withNull = Stream.of((String) null);
// Stream.ofNullable (Java 9+) -- returns empty stream if the value is nullStream<String> emptyIfNull = Stream.ofNullable(null); // empty streamStream<String> present = Stream.ofNullable("hello"); // Stream["hello"]Collection.stream / Collection.parallelStream
Section titled “Collection.stream / Collection.parallelStream”Every Collection implementation provides stream() and parallelStream() methods via the Collection interface default method.
List<String> names = List.of("Alice", "Bob", "Charlie");Stream<String> sequential = names.stream(); // sequential streamStream<String> parallel = names.parallelStream(); // parallel streamPrimitive Specializations
Section titled “Primitive Specializations”IntStream``LongStreamAnd DoubleStream avoid the overhead of boxing and unboxing. Each provides range generation, summary statistics, and specialized reduction operations.
IntStream intStream = IntStream.range(1, 10); // 1..9 (exclusive end)IntStream closedRange = IntStream.rangeClosed(1, 10); // 1..10 (inclusive end)IntStream fromArray = IntStream.of(1, 2, 3, 4, 5);
// Conversion between object and primitive streamsIntStream ints = names.stream().mapToInt(String::length);Stream<Integer> boxed = ints.boxed();IntStream unboxed = Stream.of(1, 2, 3).mapToInt(Integer::intValue);
// Primitive streams have specialized reduction and summary methodsIntSummaryStatistics stats = IntStream.of(10, 20, 30, 40).summaryStatistics();stats.getCount(); // 4stats.getSum(); // 100stats.getAverage(); // 25.0stats.getMin(); // 10stats.getMax(); // 40Arrays.stream
Section titled “Arrays.stream”Creates a stream from an array, with optional range parameters.
int[] numbers = {1, 2, 3, 4, 5};IntStream fromArray = Arrays.stream(numbers);IntStream range = Arrays.stream(numbers, 1, 4); // elements at index 1..3: 2, 3, 4
String[] words = {"hello", "world"};Stream<String> wordStream = Arrays.stream(words);Stream.builder
Section titled “Stream.builder”For building streams when the elements are not known in advance. Prefer Stream.of() or collection-based creation when elements are known at compile time.
Stream<String> stream = Stream.<String>builder() .add("first") .add("second") .add("third") .build();
// Builder accepts null values (unlike List.of)Stream<String> withNull = Stream.<String>builder() .add("present") .add(null) .build();Stream.generate / Stream.iterate
Section titled “Stream.generate / Stream.iterate”Create potentially infinite streams. Always use with limit() or a short-circuiting terminal operation.
// generate -- takes a Supplier, produces an infinite streamStream<Double> randoms = Stream.generate(Math::random).limit(5);Stream<String> constant = Stream.generate(() -> "hello").limit(3);
// iterate (Java 8) -- seed + unary operatorStream<Integer> naturals = Stream.iterate(1, n -> n + 1).limit(10);// 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
// iterate (Java 9+) -- seed + predicate + unary operator// The predicate determines when to stop (replaces the need for limit)Stream<Integer> bounded = Stream.iterate( 1, // seed n -> n <= 100, // hasNext predicate n -> n * 2 // next);// 1, 2, 4, 8, 16, 32, 64Other Sources
Section titled “Other Sources”// Empty streamStream<String> empty = Stream.empty();
// String lines (Java 11+)Stream<String> lines = "line1\nline2\nline3".lines();
// Regex splitStream<String> tokens = Pattern.compile("\\s+").splitAsStream("one two three");
// File linestry (Stream<String> fileLines = Files.lines(Path.of("data.txt"))) { fileLines.filter(line -> !line.isBlank()).forEach(System.out::println);}
// ConcatenationStream<String> combined = Stream.concat(Stream.of("a", "b"), Stream.of("c", "d"));Intermediate Operations
Section titled “Intermediate Operations”Intermediate operations are lazy. They return a new stream and do not trigger any processing until a terminal operation is invoked on the pipeline.
filter
Section titled “filter”Returns a stream containing only elements that match the given predicate.
List<String> longNames = names.stream() .filter(name -> name.length() > 4) .collect(Collectors.toList());// [Alice, Charlie]Applies a function to each element, producing a stream of the results. The output stream may have a different type than the input stream.
List<Integer> lengths = names.stream() .map(String::length) .collect(Collectors.toList());// [5, 3, 7, 5]
// Changing typeList<String> greetings = names.stream() .map(name -> "Hello, " + name) .collect(Collectors.toList());flatMap
Section titled “flatMap”Maps each element to a stream, then flattens all resulting streams into a single stream. This is the stream equivalent of a nested loop.
// Flatten a list of listsList<List<Integer>> nested = List.of( List.of(1, 2, 3), List.of(4, 5), List.of(6));List<Integer> flat = nested.stream() .flatMap(Collection::stream) .collect(Collectors.toList());// [1, 2, 3, 4, 5, 6]
// Practical example: one-to-many relationshiprecord Author(String name, List<String> books) {}List<Author> authors = List.of( new Author("Alice", List.of("Book A", "Book B")), new Author("Bob", List.of("Book C")));List<String> allBooks = authors.stream() .flatMap(author -> author.books().stream()) .collect(Collectors.toList());// [Book A, Book B, Book C]distinct
Section titled “distinct”Returns a stream with distinct elements. Uses equals() to determine equality. For ordered streams, the first occurrence is kept; for unordered streams, any element may be selected.
List<Integer> unique = Stream.of(1, 2, 2, 3, 1, 4, 3) .distinct() .collect(Collectors.toList());// [1, 2, 3, 4]Returns a stream sorted according to natural order or a provided Comparator. This is a stateful operation — it must buffer all elements before producing output.
List<String> sorted = Stream.of("Charlie", "Alice", "Bob") .sorted() .collect(Collectors.toList());// [Alice, Bob, Charlie]
List<String> byLength = Stream.of("Charlie", "Alice", "Bob") .sorted(Comparator.comparingInt(String::length)) .collect(Collectors.toList());// [Bob, Alice, Charlie]
// Chained comparatorList<String> byLengthThenAlpha = Stream.of("aa", "b", "cc", "a") .sorted(Comparator.comparingInt(String::length).thenComparing(Comparator.naturalOrder())) .collect(Collectors.toList());// [a, b, aa, cc]Returns a stream identical to the input, but invokes the provided Consumer on each element as it is consumed. Primarily intended for debugging.
List<String> result = Stream.of("Alice", "Bob", "Charlie") .filter(name -> name.length() > 3) .peek(name -> System.out.println("Filtered: " + name)) .map(String::toUpperCase) .peek(name -> System.out.println("Mapped: " + name)) .collect(Collectors.toList());// Filtered: Alice// Mapped: ALICE// Filtered: Charlie// Mapped: CHARLIETruncates the stream to at most maxSize elements. This is a short-circuiting stateful operation.
List<Integer> first3 = IntStream.iterate(1, n -> n + 1) .limit(3) .boxed() .collect(Collectors.toList());// [1, 2, 3]Discards the first n elements of the stream. This is a stateful operation.
List<Integer> afterFirst2 = Stream.of(1, 2, 3, 4, 5) .skip(2) .collect(Collectors.toList());// [3, 4, 5]takeWhile / dropWhile (Java 9+)
Section titled “takeWhile / dropWhile (Java 9+)”takeWhile returns elements while the predicate is true and stops at the first false. dropWhile discards elements while the predicate is true and returns the rest.
// takeWhile -- stops at the first element that fails the predicateList<Integer> taken = Stream.of(1, 2, 3, 4, 5, 1, 2) .takeWhile(n -> n < 4) .collect(Collectors.toList());// [1, 2, 3] -- stops at 4, even though 1,2 after it would pass
// dropWhile -- drops elements while predicate is true, returns the restList<Integer> dropped = Stream.of(1, 2, 3, 4, 5, 1, 2) .dropWhile(n -> n < 4) .collect(Collectors.toList());// [4, 5, 1, 2] -- drops 1,2,3, returns everything from 4 onwardTerminal operations trigger the processing of the entire pipeline and produce a result or a side effect. After a terminal operation, the stream is consumed and cannot be reused.
forEach
Section titled “forEach”Performs an action for each element. In sequential streams, elements are processed in encounter order. In parallel streams, the order is not guaranteed unless the stream has an encounter order and the stream is explicitly ordered.
names.stream() .map(String::toUpperCase) .forEach(System.out::println);
// Use forEachOrdered for guaranteed order in parallel streamsnames.parallelStream() .map(String::toUpperCase) .forEachOrdered(System.out::println);collect
Section titled “collect”Transforms the elements of the stream into a different form, most commonly a Collection. The collect operation takes a Collector that encapsulates the reduction strategy.
List<String> result = names.stream() .filter(n -> n.length() > 3) .collect(Collectors.toList());
Set<String> unique = names.stream() .map(String::toLowerCase) .collect(Collectors.toSet());reduce
Section titled “reduce”Performs a reduction on the elements, combining them into a single result using an associative accumulation function.
// Without identity -- returns Optional (stream may be empty)Optional<Integer> sum = Stream.of(1, 2, 3, 4, 5).reduce(Integer::sum);// Optional[15]
// With identity -- returns the identity if stream is emptyint sumWithIdentity = Stream.of(1, 2, 3, 4, 5).reduce(0, Integer::sum);// 15
// With identity and combiner (used for parallel streams)int sumParallel = Stream.of(1, 2, 3, 4, 5).parallel().reduce(0, Integer::sum, Integer::sum);
// Reduction to a different typeString concatenated = Stream.of("a", "b", "c") .reduce("", (acc, s) -> acc + s);// "abc"Returns the number of elements in the stream.
long count = names.stream() .filter(n -> n.startsWith("A")) .count();min / max
Section titled “min / max”Returns the minimum or maximum element according to a Comparator. Returns Optional because the stream may be empty.
Optional<String> longest = names.stream() .max(Comparator.comparingInt(String::length));// Optional[Charlie]
Optional<Integer> smallest = Stream.of(5, 3, 8, 1, 9) .min(Comparator.naturalOrder());// Optional[1]anyMatch / allMatch / noneMatch
Section titled “anyMatch / allMatch / noneMatch”Short-circuiting terminal operations that test whether elements match a predicate.
// anyMatch -- returns true if ANY element matches (short-circuits on first match)boolean hasLong = names.stream() .anyMatch(n -> n.length() > 6);// true (Charlie has 7 chars)
// allMatch -- returns true if ALL elements match (short-circuits on first non-match)boolean allLong = names.stream() .allMatch(n -> n.length() > 3);// false (Bob has 3 chars)
// noneMatch -- returns true if NO elements match (short-circuits on first match)boolean noEmpty = names.stream() .noneMatch(String::isEmpty);// truefindFirst / findAny
Section titled “findFirst / findAny”Returns an Optional describing the first (or any) element of the stream. Both are short-circuiting.
Optional<String> first = names.stream() .filter(n -> n.length() > 3) .findFirst();// Optional[Alice]
Optional<String> any = names.parallelStream() .filter(n -> n.length() > 3) .findAny();// Optional[?] -- any matching element, not guaranteed to be firstConverts the stream elements into an array.
// Returns Object[] -- no type informationObject[] array = names.stream().toArray();
// Returns String[] -- using a generator functionString[] typedArray = names.stream().toArray(String[]::new);
// The generator function is called once with the size, allocates the array// Functionally equivalent to: String[] a = new String[size]; // then fillCollectors
Section titled “Collectors”The Collectors utility class provides factory methods for common reduction operations. A Collector encapsulates the supplier, accumulator, combiner, and finisher functions that define a mutable reduction.
toList / toUnmodifiableList
Section titled “toList / toUnmodifiableList”// Collectors.toList() -- returns a mutable ArrayListList<String> mutable = names.stream().collect(Collectors.toList());
// Collectors.toUnmodifiableList() (Java 10+) -- returns an immutable listList<String> immutable = names.stream().collect(Collectors.toUnmodifiableList());toSet / toUnmodifiableSet
Section titled “toSet / toUnmodifiableSet”Set<String> unique = names.stream().map(String::toLowerCase).collect(Collectors.toSet());Set<String> immutableSet = names.stream().map(String::toLowerCase).collect(Collectors.toUnmodifiableSet());record Person(String name, int age) {}List<Person> people = List.of( new Person("Alice", 30), new Person("Bob", 25), new Person("Charlie", 35));
// Basic toMap -- key mapper, value mapperMap<String, Integer> nameToAge = people.stream() .collect(Collectors.toMap(Person::name, Person::age));
// toMap with merge function -- handles duplicate keysMap<Integer, String> ageToName = people.stream() .collect(Collectors.toMap( Person::age, Person::name, (existing, replacement) -> existing + ", " + replacement // merge on conflict ));
// toMap with specific map supplierLinkedHashMap<String, Integer> ordered = people.stream() .collect(Collectors.toMap( Person::name, Person::age, (a, b) -> a, LinkedHashMap::new ));Concatenates stream elements into a single String.
// No delimiterString all = names.stream().collect(Collectors.joining());// "AliceBobCharlie"
// With delimiterString csv = names.stream().collect(Collectors.joining(", "));// "Alice, Bob, Charlie"
// With delimiter, prefix, and suffixString formatted = names.stream() .collect(Collectors.joining(", ", "[", "]"));// "[Alice, Bob, Charlie]"groupingBy
Section titled “groupingBy”Groups elements by a classification function, producing a Map<K, List<T>>.
// Basic groupingMap<Integer, List<String>> byLength = names.stream() .collect(Collectors.groupingBy(String::length));// {3=[Bob], 5=[Alice, Diana], 7=[Charlie]}
// groupingBy with downstream collectorMap<Integer, Long> countByLength = names.stream() .collect(Collectors.groupingBy(String::length, Collectors.counting()));// {3=1, 5=2, 7=1}
// groupingBy with mapped downstreamMap<Integer, Set<String>> namesByLength = names.stream() .collect(Collectors.groupingBy( String::length, Collectors.mapping(String::toUpperCase, Collectors.toSet()) ));// {3=[BOB], 5=[ALICE, DIANA], 7=[CHARLIE]}
// groupingBy with specific map factoryTreeMap<Integer, List<String>> sortedGroups = names.stream() .collect(Collectors.groupingBy( String::length, TreeMap::new, Collectors.toList() ));partitioningBy
Section titled “partitioningBy”A special case of groupingBy with a Predicate as the classifier. Always produces a Map<Boolean, List<T>> with exactly two entries.
Map<Boolean, List<String>> partitioned = names.stream() .collect(Collectors.partitioningBy(n -> n.length() > 4));// {false=[Bob], true=[Alice, Charlie, Diana]}
// partitioningBy with downstream collectorMap<Boolean, Long> counts = names.stream() .collect(Collectors.partitioningBy( n -> n.length() > 4, Collectors.counting() ));// {false=1, true=3}Reduction collectors for numeric aggregates.
long count = names.stream().collect(Collectors.counting());// Equivalent to: names.stream().count()
int totalLength = names.stream().collect(Collectors.summingInt(String::length));// 20
double avgLength = names.stream().collect(Collectors.averagingInt(String::length));// 5.0
// Collect all statistics in one passIntSummaryStatistics stats = names.stream() .collect(Collectors.summarizingInt(String::length));stats.getCount(); // 4stats.getSum(); // 20stats.getAverage(); // 5.0stats.getMin(); // 3stats.getMax(); // 7collectingAndThen
Section titled “collectingAndThen”Wraps a collector with a finishing function, allowing post-processing of the result.
// Produce an unmodifiable list (pre-Java 16)List<String> unmodifiable = names.stream() .collect(Collectors.collectingAndThen( Collectors.toList(), Collections::unmodifiableList ));
// Get the max as a plain value with a defaultint maxLength = names.stream() .collect(Collectors.collectingAndThen( Collectors.maxBy(Comparator.comparingInt(String::length)), opt -> opt.orElse(0) ));mapping / flatMapping
Section titled “mapping / flatMapping”Downstream collectors that transform elements before collecting.
// mapping -- applies a function to each element before collectingMap<Integer, List<String>> byLengthUpper = names.stream() .collect(Collectors.groupingBy( String::length, Collectors.mapping(String::toUpperCase, Collectors.toList()) ));
// flatMapping (Java 9+) -- applies a function that returns a stream, then flattensrecord Department(String name, List<String> employees) {}List<Department> departments = List.of( new Department("Engineering", List.of("Alice", "Bob")), new Department("Sales", List.of("Charlie", "Diana", "Eve")));
// Group by name length, then flatten all employees into a single list per groupMap<Integer, List<String>> employeesByLength = departments.stream() .collect(Collectors.groupingBy( dept -> dept.name().length(), Collectors.flatMapping( dept -> dept.employees().stream(), Collectors.toList() ) ));// {11=[Alice, Bob], 5=[Charlie, Diana, Eve]}Parallel Streams
Section titled “Parallel Streams”Design Decision: Why Parallel Streams Use ForkJoinPool
Section titled “Design Decision: Why Parallel Streams Use ForkJoinPool”Parallel streams use the common ForkJoinPool (accessed via ForkJoinPool.commonPool()) rather than creating a new thread pool for each parallel stream operation. This design decision was made for three reasons:
Resource efficiency. Creating and destroying thread pools is expensive. A shared pool amortizes this cost across all parallel stream operations in the JVM. The common pool is lazily initialized on first use and has a target parallelism equal to
Runtime.getRuntime().availableProcessors() - 1.Work-stealing. The
ForkJoinPooluses a work-stealing scheduler where idle threads steal tasks from busy threads’ queues. This is ideal for stream pipelines because different pipeline stages may have different per-element costs, leading to load imbalance. Work-stealing automatically rebalances work across threads without explicit partitioning.Thread confinement. The lambda functions passed to stream operations are often non-thread-safe. Using a managed pool with a known size prevents the creation of unbounded numbers of threads, which could lead to resource exhaustion.
// Basic parallel streamlong count = IntStream.rangeClosed(1, 10_000_000) .parallel() .filter(PrimeUtils::isPrime) .count();
// Control parallelism by submitting to a custom ForkJoinPoolForkJoinPool customPool = new ForkJoinPool(4);int result = customPool.submit(() -> IntStream.rangeClosed(1, 1_000_000) .parallel() .sum()).join();When to Use Parallel Streams
Section titled “When to Use Parallel Streams”// GOOD candidate for parallelism:// 1. Large dataset (N > 10,000)// 2. CPU-bound processing (not I/O-bound)// 3. Stateless, non-interfering, associative operations// 4. Source is efficiently splittable (ArrayList, IntStream.range, arrays)
List<Integer> largeList = IntStream.rangeClosed(1, 1_000_000).boxed().toList();int sum = largeList.parallelStream() .mapToInt(Integer::intValue) .filter(n -> n % 2 == 0) .sum();Pitfalls of Parallel Streams
Section titled “Pitfalls of Parallel Streams”Optional<String> name = Optional.of("Alice");
// map -- applies a function if the value is presentOptional<Integer> length = name.map(String::length);// Optional[5]
// flatMap -- applies a function that returns an Optional, flattening the result// Required when the mapping function itself returns an OptionalOptional<String> upper = name.flatMap(n -> Optional.of(n.toUpperCase()));// Optional[ALICE]
// filter -- returns the Optional if the predicate matches, otherwise emptyOptional<String> longName = name.filter(n -> n.length() > 4);// Optional[Alice]Optional<String> shortName = name.filter(n -> n.length() < 3);// Optional.emptyChecking Presence
Section titled “Checking Presence”Optional<String> opt = Optional.of("hello");
// isPresent -- true if a value is presentif (opt.isPresent()) { String value = opt.get(); // safe because we checked isPresent}
// ifPresent -- executes an action if a value is present (preferred over isPresent + get)opt.ifPresent(value -> System.out.println("Value: " + value));- Collections Framework — Streams are created from collections using stream(), connecting the collections API to functional processing.
- Generics — Stream operations use generic type parameters for type-safe transformations and predicates.
- Concurrency — Parallel streams use the ForkJoinPool for concurrent processing of large data sets.
Intuition
Section titled “Intuition”Java Streams are a declarative way to process collections — you describe what transformations to perform (filter, map, sort) rather than how to loop through elements. The key insight is laziness: intermediate operations like filter and map don’t actually do any work. They build up a recipe of operations that only executes when you call a terminal operation like collect or reduce. This means the JVM can fuse multiple operations into a single pass over the data, short-circuit early, and avoid creating intermediate collections between each step.
Think of a stream pipeline as an assembly line. Data flows through each transformation stage one element at a time. filter drops elements that don’t match, map transforms each element, and collect gathers the results into a final container. Because elements flow through one at a time (or in small batches for parallel streams), you can process datasets far larger than memory — Files.lines() streams a file without loading it entirely, and Stream.iterate() generates infinite sequences.
Parallel streams split the data across multiple threads using the shared ForkJoinPool, but this only helps when the dataset is large, the operations are CPU-bound, and the source can be efficiently split (like arrays or ArrayLists). For small datasets or I/O-bound work, the overhead of thread coordination makes parallel streams slower. Optional complements streams by making the absence of a value explicit at the type level — a method returning Optional<T> signals that the result might not exist, forcing the caller to handle both cases rather than getting a surprise NullPointerException.