A stream becomes useful when you know two or three intermediate operations and one terminal operation. The intermediate ones reshape the data on its way through the pipeline; the terminal one consumes it and gives you a result. Learn this set and you can read and write most stream code without looking anything up.
Which intermediate operations matter?
Intermediate operations return a stream, so they chain. The five you will use most are filter, map, flatMap, sorted and peek, with limit and distinct close behind.
import java.util.List;
public class IntermediateOps {
public static void main(String[] args) {
List<String> words = List.of("apple", "banana", "cherry", "date", "elderberry");
System.out.println("filter+limit : " + words.stream().filter(w -> w.length() > 4).limit(2).toList());
System.out.println("distinct : " + List.of("a", "a", "b").stream().distinct().toList());
System.out.println("sorted limit : " + words.stream().sorted().limit(3).toList());
System.out.println("peek : " + words.stream().peek(w -> System.out.println(" peeking " + w)).count());
System.out.println("map to length : " + words.stream().map(String::length).toList());
List<List<String>> nested = List.of(List.of("a", "b"), List.of("c"));
System.out.println("flatMap : " + nested.stream().flatMap(List::stream).toList());
}
}filter+limit : [apple, banana]
distinct : [a, b]
sorted limit : [apple, banana, cherry]
peek : 5
map to length : [5, 6, 6, 4, 10]
flatMap : [a, b, c]filter and map are the bread and butter: one decides what stays, the other changes each element. flatMap is the one that confuses people, and the example shows why it exists - a list of lists becomes a single list, because the inner lists are flattened into the stream. peek is for observing, usually for debugging, and it is best avoided in production code because it can change the timing of the pipeline.
Which terminal operations matter?
The terminal operation is where the result appears. There are two families: the ones that produce a single value - count, sum, max, min, average - and the ones that answer a question - anyMatch, allMatch, noneMatch, findFirst, findAny.
import java.util.List;
public class TerminalOps {
public static void main(String[] args) {
List<Integer> numbers = List.of(2, 4, 6, 8);
System.out.println("count : " + numbers.stream().count());
System.out.println("sum : " + numbers.stream().mapToInt(Integer::intValue).sum());
System.out.println("max : " + numbers.stream().max(Integer::compare).orElse(-1));
System.out.println("min : " + numbers.stream().min(Integer::compare).orElse(-1));
System.out.println("anyMatch : " + numbers.stream().anyMatch(n -> n > 7));
System.out.println("allMatch : " + numbers.stream().allMatch(n -> n % 2 == 0));
System.out.println("noneMatch : " + numbers.stream().noneMatch(n -> n > 100));
System.out.println("findFirst : " + numbers.stream().filter(n -> n > 5).findFirst().orElse(-1));
System.out.println("forEach : ");
numbers.forEach(n -> System.out.println(" " + n));
}
}count : 4
sum : 20
max : 8
min : 2
anyMatch : true
allMatch : true
noneMatch : true
findFirst : 6
forEach :
2
4
6
8max and min return an Optional, because an empty stream has no maximum. That is why the call site needs orElse or another Optional method. findAny is the parallel-friendly sibling of findFirst: in a parallel stream it may return any matching element, which is faster but gives up the ordering guarantee.
What is short-circuiting?
Some operations stop the pipeline early. limit stops after a number of elements, findFirst stops at the first match, and the three match operations stop as soon as the answer is known. This is a real performance property, not a detail: if the source is expensive to produce, the elements after the stopping point are never touched.
import java.util.List;
import java.util.stream.IntStream;
public class ShortCircuit {
public static void main(String[] args) {
IntStream.rangeClosed(1, 10).peek(n -> System.out.println(" visited " + n))
.filter(n -> n % 3 == 0)
.findFirst()
.ifPresent(n -> System.out.println("first multiple of three : " + n));
boolean anyEven = List.of(1, 3, 5, 7, 8).stream().anyMatch(n -> n % 2 == 0);
System.out.println("any even : " + anyEven);
long count = List.of("a", "bb", "ccc", "dddd").stream().filter(s -> s.length() > 2).count();
System.out.println("longer than two : " + count);
}
} visited 1
visited 2
visited 3
first multiple of three : 3
any even : true
longer than two : 2The output shows the mechanism precisely. The pipeline visits 1, 2 and 3, finds that 3 is a multiple of three, and stops - the numbers 4 through 10 are never visited, even though the source would have supplied them. In the second case the pipeline stops at 8 because 8 is the first even number, and the remaining element is never examined.
What about streams of numbers?
There is a separate family for primitives. IntStream, LongStream and DoubleStream avoid the cost of boxing every element into an object, and they carry operations that make no sense on objects: range, rangeClosed, sum, average and summaryStatistics.
import java.util.List;
import java.util.stream.IntStream;
import java.util.stream.Stream;
public class NumericStreams {
public static void main(String[] args) {
System.out.println("sum of squares : " + IntStream.rangeClosed(1, 5).map(n -> n * n).sum());
System.out.println("average : " + List.of(2, 4, 6).stream().mapToInt(Integer::intValue).average().orElse(0.0));
System.out.println("box then unbox : " + IntStream.of(1, 2, 3).boxed().toList());
System.out.println("sum of long : " + Stream.of(1L, 2L, 3L).mapToLong(Long::longValue).sum());
System.out.println("empty average : " + List.<Integer>of().stream().mapToInt(Integer::intValue).average().orElse(-1.0));
}
}sum of squares : 55
average : 4.0
box then unbox : [1, 2, 3]
sum of long : 6
empty average : -1.0Notice the boxed call in the third line - that is how you go back to a stream of objects, and mapToInt is how you go the other way. The last line shows that average also returns an Optional, so an empty stream needs a fallback just like max does.
Which operation should you reach for?
| Goal | Operation | Note |
|---|---|---|
| Keep matching elements | filter | The predicate returns a boolean |
| Transform each element | map | One output per input |
| Flatten nested streams | flatMap | For streams of streams or collections |
| Take the first few | limit | Short-circuits |
| Remove duplicates | distinct | Relies on equals and hashCode |
| Order the elements | sorted | Needs a comparator for custom types |
| Count or total | count, sum | Terminal |
| Test a condition | anyMatch, allMatch | Short-circuits |
| Find one element | findFirst, findAny | Returns an Optional |
| Collect the result | toList, collect | Terminal |
What is the difference between map, flatMap and mapToInt?
All three transform elements, and they differ in the shape of the result. map takes one element and returns one element. flatMap takes one element and returns a stream of several, and the pipeline flattens that into a single sequence. mapToInt is the bridge to the primitive streams.
import java.util.List;
public class MapShapes {
public static void main(String[] args) {
List<List<String>> nested = List.of(List.of("a", "b"), List.of("c"));
List<Integer> lengths = nested.stream().map(List::size).toList();
List<String> flat = nested.stream().flatMap(List::stream).toList();
int total = nested.stream().flatMap(List::stream).mapToInt(String::length).sum();
System.out.println("sizes : " + lengths);
System.out.println("flattened : " + flat);
System.out.println("characters : " + total);
}
}sizes : [2, 1]
flattened : [a, b, c]
characters : 3Read the first two lines together. map over a list of lists gives you a list of counts, because each inner list produced exactly one number. flatMap over the same data gives you the inner elements themselves, because each inner list produced a stream that was then flattened into one sequence.
The third line is the combination you will write most often: flatten a nested structure, transform the leaves, and reduce the result to a number. The mapToInt step is what lets sum exist, because a stream of objects has no sum.
The rules to remember
- Intermediate operations return streams; the terminal operation returns the result.
- One terminal operation per stream - a stream cannot be used twice.
filterandmaptake one argument and return a stream of the same length or a new type.mapchanges the element type;flatMapchanges the shape.- Prefer
mapoverpeekwhen you are changing values. - Use
toListfor an unmodifiable list, andcollectwhen you need something else.
Try it yourself: write a pipeline over a list of products that removes the discontinued ones, keeps only the ones in stock, calculates the total value of each, and finds the most expensive. Then rewrite the same logic using collect into a map keyed by category, and compare which version you would rather read in a code review.