Why this matters
- Laziness is the single idea that explains why
filterbeforemapmatters, whyfindFirstcan stop early, and why a stream with no terminal operation does nothing at all. - The collectors are where most real stream work happens, and three or four of them cover nearly every case.
- Parallel streams look like free speed and are usually not, and knowing exactly why is a frequent interview distinction.
The pipeline
List<String> result = orders.stream() // source
.filter(order -> order.total().compareTo(MIN) > 0) // intermediate — lazy
.map(Order::customerName) // intermediate — lazy
.distinct() // intermediate — lazy
.sorted() // intermediate — lazy
.limit(10) // intermediate — lazy
.toList(); // terminal — runs everything
Three properties that follow from laziness
- Nothing runs without a terminal operation. A pipeline ending at
mapis dead code. - Elements flow one at a time, not stage by stage. Each element goes through filter, map and distinct before the next element starts.
- Short-circuiting stops early.
findFirst,anyMatchandlimitstop consuming the source as soon as the answer is known.
The one-at-a-time behaviour is observable:
List.of("a", "bb", "ccc").stream()
.peek(value -> System.out.println("filter sees " + value))
.filter(value -> value.length() > 1)
.peek(value -> System.out.println("map sees " + value))
.map(String::toUpperCase)
.findFirst();
filter sees a
filter sees bb
map sees bb
Only two elements were examined, and "ccc" was never touched. A stage-by-stage implementation would have
processed all three twice.
Creating streams
collection.stream(); // from any Collection
Arrays.stream(array); // from an array
Stream.of("a", "b", "c"); // from explicit values
Stream.empty();
IntStream.range(0, 10); // 0..9, primitive, no boxing
IntStream.rangeClosed(1, 10); // 1..10
Stream.iterate(1, n -> n * 2).limit(10); // infinite, must be bounded
Stream.iterate(1, n -> n < 100, n -> n * 2); // with a condition, Java 9+
Stream.generate(Math::random).limit(5);
Files.lines(path, UTF_8); // lazy over a file — close it
"a,b,c".chars(); // an IntStream of code points
Pattern.compile(",").splitAsStream("a,b,c");
Intermediate operations
.filter(predicate) // keep matching elements
.map(function) // transform each element
.flatMap(function) // transform each into a stream, then flatten
.mapMulti(consumer) // Java 16+, flatMap without allocating a stream per element
.distinct() // remove duplicates using equals
.sorted() // natural order
.sorted(comparator)
.limit(n) // at most n elements — short-circuits
.skip(n) // drop the first n
.takeWhile(predicate) // Java 9+, stop at the first failure
.dropWhile(predicate) // Java 9+, skip until the first failure
.peek(consumer) // observe without changing — for debugging only
.mapToInt(f) / .boxed() // move between object and primitive streams
flatMap is the one worth a worked example, because the shape is not obvious:
List<List<String>> nested = List.of(List.of("a", "b"), List.of("c"), List.of());
List<String> flat = nested.stream()
.flatMap(List::stream) // each inner list becomes a stream; all are concatenated
.toList(); // [a, b, c]
// A common real use: all distinct tags across all orders
Set<String> allTags = orders.stream()
.flatMap(order -> order.tags().stream())
.collect(Collectors.toSet());
Terminal operations
.toList() // Java 16+, unmodifiable — the modern default
.collect(Collectors.toList()) // modifiable ArrayList
.collect(Collectors.toSet())
.forEach(consumer) // no ordering guarantee in parallel
.forEachOrdered(consumer)
.count()
.min(comparator) / .max(comparator) // return Optional
.findFirst() / .findAny() // return Optional; findAny is better in parallel
.anyMatch(p) / .allMatch(p) / .noneMatch(p)
.reduce(BinaryOperator) // Optional result
.reduce(identity, BinaryOperator) // always a result
.sum() / .average() / .summaryStatistics() // primitive streams only
.toArray(String[]::new)
// allMatch and noneMatch on an empty stream are both true — vacuous truth
System.out.println(Stream.<String>empty().allMatch(s -> false)); // true
That surprises people and is mathematically correct: there is no element that fails the predicate.
Collectors
This is where most real work happens.
// Counting by a key — the most used collector in practice
Map<String, Long> byDepartment = employees.stream()
.collect(Collectors.groupingBy(Employee::department, Collectors.counting()));
// Grouping into lists
Map<String, List<Employee>> grouped = employees.stream()
.collect(Collectors.groupingBy(Employee::department));
// Grouping with a downstream transformation
Map<String, List<String>> namesByDepartment = employees.stream()
.collect(Collectors.groupingBy(Employee::department,
Collectors.mapping(Employee::name, Collectors.toList())));
// Summing and averaging per group
Map<String, Integer> payroll = employees.stream()
.collect(Collectors.groupingBy(Employee::department,
Collectors.summingInt(Employee::salary)));
// Splitting into exactly two groups
Map<Boolean, List<Employee>> split = employees.stream()
.collect(Collectors.partitioningBy(employee -> employee.salary() > 100_000));
// Building a map, with an explicit merge for duplicate keys
Map<String, Employee> byId = employees.stream()
.collect(Collectors.toMap(Employee::id, employee -> employee,
(existing, replacement) -> existing));
// Joining strings
String names = employees.stream()
.map(Employee::name)
.collect(Collectors.joining(", ", "[", "]"));
// Everything at once
IntSummaryStatistics stats = employees.stream()
.collect(Collectors.summarizingInt(Employee::salary));
System.out.println(stats.getMax() + " " + stats.getAverage());
reduce
// Sum, with an identity so the result is never Optional
int total = numbers.stream().reduce(0, Integer::sum);
// Without an identity, the result may be absent
Optional<Integer> product = numbers.stream().reduce((a, b) -> a * b);
// Prefer the primitive specialisation when it exists
int sum = numbers.stream().mapToInt(Integer::intValue).sum();
The identity must be a true identity for the operation — 0 for addition, 1 for multiplication, "" for
concatenation. A wrong identity gives a wrong answer in parallel, because it is combined once per partition
rather than once overall.
Primitive streams
Stream<Integer> boxes every element. The primitive specialisations do not.
int sum = IntStream.rangeClosed(1, 1_000_000).sum(); // no boxing at all
OptionalDouble average = IntStream.of(1, 2, 3).average();
IntStream.of(1, 2, 3).boxed().toList(); // back to objects when needed
// Mapping between them
employees.stream().mapToInt(Employee::salary).max();
IntStream.range(0, 5).mapToObj(i -> "item-" + i).toList();
For a million elements the difference is a million avoided allocations, which is a measurable win in a hot path.
Parallel streams, honestly
long count = hugeList.parallelStream()
.filter(this::expensiveCheck)
.count();
| Aspect | Parallel helps when | Parallel hurts when |
|---|---|---|
| Data size | Tens of thousands of elements or more | Small collections — overhead dominates |
| Per-element cost | Expensive computation | Trivial work like a field access |
| Source | ArrayList or array — splits evenly | LinkedList — cannot split well |
| Operations | Stateless and independent | sorted, distinct, limit — need coordination |
| Work type | CPU-bound | Blocking I/O — occupies the shared pool |
| Ordering | Order does not matter | forEachOrdered serialises the output anyway |
Data size
Parallel helps whenTens of thousands of elements or moreParallel hurts whenSmall collections — overhead dominatesPer-element cost
Parallel helps whenExpensive computationParallel hurts whenTrivial work like a field accessSource
Parallel helps whenArrayList or array — splits evenlyParallel hurts whenLinkedList — cannot split wellOperations
Parallel helps whenStateless and independentParallel hurts whensorted, distinct, limit — need coordinationWork type
Parallel helps whenCPU-boundParallel hurts whenBlocking I/O — occupies the shared poolOrdering
Parallel helps whenOrder does not matterParallel hurts whenforEachOrdered serialises the output anyway
Parallel streams use the shared ForkJoinPool.commonPool(), so one slow pipeline affects the whole JVM.
// Broken: shared mutable state, even with a synchronized list
List<String> results = Collections.synchronizedList(new ArrayList<>());
source.parallelStream().forEach(results::add); // order lost, contention high
// Correct: let the collector handle combining
List<String> results = source.parallelStream().toList();
Common misreadings
- "A stream is a collection." It holds no elements. It is a pipeline over a source.
- "
filterruns over everything, thenmapruns over the result." Each element traverses the whole pipeline before the next begins. - "A stream can be reused." It is single-use. A second terminal operation throws.
- "
peekis a good place for logging side effects." It may be skipped entirely. Use it only while debugging. - "
parallelStream()makes things faster." Only for large, CPU-bound, independent work on a splittable source. - "
Collectors.toList()is unmodifiable." It returns a modifiableArrayList..toList()is the unmodifiable one. - "
toMaphandles duplicate keys." It throws. Supply a merge function. - "
findAnyis non-deterministic in a sequential stream." In practice it returns the first element; the lack of a guarantee matters only in parallel.
Quick recall
Everything you need if you only revisit this box.
- A stream is a lazy pipeline: intermediate operations build it, a terminal operation runs it, and nothing happens without one.
- Elements flow one at a time through every stage, which is why short-circuiting works. Put
filterandlimitearly. - Streams are single-use.
- Know
mapvsflatMap(flatten nested structures), andtakeWhile/dropWhilefor ordered prefixes. - The collectors that matter:
groupingBy(often withcounting,mapping,summingInt),partitioningBy,toMapwith a merge function,joining,summarizingInt. reduceneeds a true identity, or it breaks in parallel. PreferIntStream.sum()where it exists.- Primitive streams avoid boxing — use
mapToInt,boxed,mapToObj. - Parallel needs large data, expensive per-element work, a splittable source and no blocking. It shares
commonPool()with the whole JVM.
Test yourself
Answer these before moving on — recall is what makes it stick.