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, andreduceto 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.