Java Stream API Methods: Terminal and Intermediate Operations
Look at this pipeline and try to guess how many times map() runs:
Stream.of("sun", "pool", "beach", "kid", "island", "sea", "sand")
.map(str -> {
System.out.println("Mapping: " + str);
return str.length();
})
.filter(i -> {
System.out.println("Filtering: " + i);
return i > 3;
})
.limit(2)
.forEach(System.out::println); There are seven strings in the stream, but map() executes for three of them: sun, pool and beach. The words kid, island, sea and sand are never read at all — once the second matching element reaches limit(2), the traversal stops. This stops looking like magic the moment you know how terminal methods differ from intermediate ones.
This lesson continues the topic of What is Stream API in Java, where the definition of a stream, its characteristics and the ways to create one are covered. Here we go straight to the operations: which methods Stream has, what each of them returns and when to reach for it.
1. Two kinds of Stream API methods
Every method of the Stream API falls into one of two groups:
- Terminal operations — they start the pipeline, produce a result and close the stream.
- Intermediate operations — they return a new
Streamand compute nothing until a terminal operation is called. They split further into:- stateless —
filter(),map(); - stateful —
sorted(),distinct(),limit().
- stateless —
Every chain has the same shape: source → zero or more intermediate operations → exactly one terminal operation. The return type tells you which is which: if a method returns a Stream (or IntStream, LongStream, DoubleStream), it is intermediate; if it returns a value, a collection, an Optional or void, it is terminal.
2. Terminal operations: table and examples
A terminal operation consumes the stream. Once it has run, the same Stream object cannot be used again — any further call throws IllegalStateException.
| Method | Returns | When to use it |
|---|---|---|
forEach() | void | Run an action for every element when order does not matter |
forEachOrdered() | void | The same, but keeping the source order even in a parallel stream |
collect() | R (List, Set, Map, String…) | Gather elements into a collection or fold them with a collector |
toList() (Java 16+) | List<T> (unmodifiable) | Short form of collect(Collectors.toList()) |
count() | long | Count the elements |
min(), max() | Optional<T> | Find the smallest or largest element by comparator |
findFirst() | Optional<T> | Take the first element in encounter order |
findAny() | Optional<T> | Take any element; cheaper in a parallel stream |
allMatch() | boolean | Check that every element satisfies the predicate |
anyMatch() | boolean | Check that at least one element matches |
noneMatch() | boolean | Check that no element matches |
reduce() | T or Optional<T> | Fold the stream into a single value: sum, product, concatenation |
toArray() | Object[] or T[] | Get an array instead of a collection |
iterator() | Iterator<T> | Switch to manual traversal when you need imperative code or an API that takes an iterator |
spliterator() | Spliterator<T> | Get a splittable iterator: for custom sources and parallel traversal |
sum(), average(), summaryStatistics() | int/long/double, OptionalDouble, *SummaryStatistics | Primitive streams only: IntStream, LongStream, DoubleStream |
The complete list of signatures is always available in the official javadoc for the Stream interface.
A stream is single-use
The classic mistake: store a Stream in a variable and call two terminal operations on it.
int[] digits = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
IntStream s = IntStream.of(digits);
long n = s.count(); // 10 - the stream is now closed
System.out.println(s.findFirst());
// java.lang.IllegalStateException: stream has already been operated upon or closed To walk over the data a second time, create the stream again:
int[] digits = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
long n = IntStream.of(digits).count(); // 10
System.out.println(IntStream.of(digits).findFirst()); // OptionalInt[0] Important
If the same data has to be traversed several times, store the supplier rather than the stream: Supplier<Stream<T>> supplier = list::stream;. Every call to supplier.get() then hands you a fresh stream and IllegalStateException never happens.
A chain of several operations
public class StreamDemo1 {
public static void main(String[] args) {
Stream.of("sun", "pool", "beach", "kid", "island", "sea", "sand")
.map(String::length)
.filter(i -> i > 3)
.limit(2)
.forEach(System.out::println);
}
}
// 4
// 5 Filtering out empty and null values:
public class StreamDemo7 {
public static void main(String[] args) {
List<String> words = Arrays.asList("hello", null, "");
words.stream()
.filter(t -> t != null && !t.isEmpty())
.forEach(System.out::println); // hello
}
} The same thing written shorter, plus removing duplicates and sorting:
public class StreamDemo8 {
public static void main(String[] args) {
List<String> words = Arrays.asList("hello", null, "world", "hi", "hello");
words.stream()
.filter(Objects::nonNull)
.distinct() // drop duplicates first,
.sorted() // then sort fewer elements
.forEach(System.out::println); // hello, hi, world
}
} min(), max(), findFirst() and Optional
Search methods cannot return an element when the stream is empty, so the result is wrapped in Optional. The same applies to findAny(), max() and reduce() without an identity value.
public class StreamDemo3 {
public static void main(String[] args) {
List<String> strings = Arrays.asList("Stream", "Operations", "on", "Collections");
Optional<String> optional = strings.stream()
.min(Comparator.comparing(String::length));
optional.ifPresent(System.out::println); // on
}
} Numeric operations: sum() and average()
sum(), average() and summaryStatistics() are declared on primitive streams only. A plain Stream<T> does not have them — you first need to cross over with mapToInt(), mapToLong() or mapToDouble().
public class StreamDemo4 {
public static void main(String[] args) {
System.out.println(IntStream.of(28, 4, 91, 30).sum()); // 153
System.out.println(IntStream.of(28, 4, 91, 30).average()); // OptionalDouble[38.25]
// to get a number instead of a wrapper:
double avg = IntStream.of(28, 4, 91, 30).average().orElse(0); // 38.25
System.out.println(avg);
}
} reduce(): folding a stream into one value
reduce() applies a binary operator to the elements one after another, accumulating the result. It comes in three forms.
// 1. With an identity value - there is always a result, no Optional needed
int sum = Stream.of(1, 2, 3, 4, 5)
.reduce(0, Integer::sum); // 15
// 2. Without an identity - an empty stream has nothing to return, hence Optional
Optional<Integer> max = Stream.of(1, 2, 3, 4, 5)
.reduce(Integer::max);
System.out.println(max.orElse(0)); // 5
// 3. String concatenation
String sentence = Stream.of("Stream", "API", "reduce")
.reduce("", (a, b) -> a.isEmpty() ? b : a + " " + b);
System.out.println(sentence); // Stream API reduce For numbers and strings the specialised methods are usually more convenient: IntStream.sum() instead of reduce(0, Integer::sum), and Collectors.joining(" ") instead of gluing strings by hand. reduce() earns its place where no ready-made collector exists.
toArray(): from a stream to an array
// with no argument you get an Object[]
Object[] objects = Stream.of("a", "b", "c").toArray();
// with an array constructor reference you get a typed array
String[] letters = Stream.of("a", "b", "c").toArray(String[]::new);
System.out.println(Arrays.toString(letters)); // [a, b, c]
// on primitive streams toArray() returns int[] / long[] / double[] directly
int[] numbers = IntStream.rangeClosed(1, 5).toArray();
System.out.println(Arrays.toString(numbers)); // [1, 2, 3, 4, 5] iterator() and spliterator()
These two methods are terminal as well: they close the stream and hand back an iterator through which elements are pulled manually. They are useful when the result has to go into an older API that expects an Iterator, or when the traversal must stop on a complex condition.
Iterator<String> it = Stream.of("sun", "sea", "sand").iterator();
while (it.hasNext()) {
System.out.println(it.next()); // sun, sea, sand
}
Spliterator<String> sp = Stream.of("sun", "sea", "sand").spliterator();
sp.tryAdvance(s -> System.out.println("first: " + s)); // first: sun
sp.forEachRemaining(System.out::println); // sea, sand A Spliterator (splittable iterator) differs from an ordinary iterator in one key way: it can split itself in half through trySplit(), and that is exactly the mechanism parallel streams are built on.
forEach() and forEachOrdered()
In a sequential stream these two behave identically. The difference shows up as soon as you switch to parallelStream(): forEach() gives no ordering guarantee at all, while forEachOrdered() processes elements strictly in the encounter order of the source.
List<Integer> nums = List.of(1, 2, 3, 4, 5, 6, 7, 8);
nums.parallelStream().forEach(System.out::print);
// for example 56781234 - the order is arbitrary and changes between runs
nums.parallelStream().forEachOrdered(System.out::print);
// always 12345678 Worth knowing
forEachOrdered() is not free: it forces a parallel stream to synchronise how results are emitted and often eats the entire benefit of parallelism. When order really matters, collecting into a list with collect() is usually simpler — collectors preserve the encounter order on their own.
3. The collect() method and Collectors
collect() is the most versatile terminal operation: it takes a collector and turns the stream into a List, a Set, a Map, a string or an aggregate. Ready-made collectors live in the utility class java.util.stream.Collectors.
public class StreamDemo5 {
public static void main(String[] args) {
List<String> phones = new ArrayList<>();
Collections.addAll(phones, "iPhone 8", "HTC U12", "Huawei Nexus 6P",
"Samsung Galaxy S9", "LG G6", "Xiaomi MI6", "ASUS Zenfone 2",
"Sony Xperia Z5", "Meizu Pro 6", "Lenovo S850");
List<String> filteredPhones = phones.stream()
.filter(s -> s.length() < 10)
.collect(Collectors.toList());
System.out.println(filteredPhones); // [iPhone 8, HTC U12, LG G6, Lenovo S850]
}
} Since Java 16 the most common case has a shorter form, stream().toList(), which returns an unmodifiable list.
Collectors.toMap()
Turning a stream of objects into a Map: the first function extracts the key, the second the value.
record ColorBox(int width, int height, int depth, String color) { }
public class StreamDemo9 {
public static void main(String[] args) {
Stream<ColorBox> stream = Stream.of(
new ColorBox(1, 1, 1, "red"),
new ColorBox(2, 2, 2, "green"),
new ColorBox(3, 3, 3, "blue"),
new ColorBox(4, 4, 4, "black"));
Map<String, ColorBox> map = stream
.collect(Collectors.toMap(ColorBox::color, box -> box));
map.forEach((k, v) -> System.out.println(k + " " + v));
}
} If two elements produce the same key, the two-argument toMap() throws IllegalStateException: Duplicate key. To avoid that, pass a third argument — a merge function such as (oldValue, newValue) -> newValue.
Collectors.groupingBy()
Grouping elements by a property: the key is computed by a function and the values are put into lists.
Map<Integer, List<Integer>> grouped = Stream.of(2, 34, 54, 23)
.collect(Collectors.groupingBy(i -> i / 10 * 10));
// {0=[2], 20=[23], 30=[34], 50=[54]} A second argument lets you plug in a downstream collector — for example, counting the elements in each group instead of collecting them:
Map<Integer, Long> counted = Stream.of(2, 34, 54, 23)
.collect(Collectors.groupingBy(i -> i / 10 * 10, Collectors.counting()));
// {0=1, 20=1, 30=1, 50=1} These are only the basic collectors. A detailed walkthrough of Collectors — toSet(), joining(), partitioningBy(), summingInt(), teeing() and writing your own collector — belongs to a separate lesson.
4. Intermediate operations: table and examples
Intermediate methods return a new stream that further operations can be chained onto. All of them are lazy: as long as the chain has no terminal operation, not a single lambda you passed in will run.
| Method | Type | What it does |
|---|---|---|
filter() | Stateless | Keeps the elements for which the predicate is true |
map() | Stateless | Converts every element into another object or type |
mapToInt(), mapToObj(), boxed() | Stateless | Moves between an object stream and a primitive one |
flatMap() | Stateless | Flattens nested structures: turns a stream of streams into one flat stream |
peek() | Stateless | Looks at the elements without changing them. A debugging tool, not a logic tool |
takeWhile() (Java 9+) | Stateful | Takes elements from the start while the predicate holds and stops at the first mismatch |
dropWhile() (Java 9+) | Stateful | Skips elements while the predicate holds and returns the whole remainder |
distinct() | Stateful | Removes duplicates using equals() |
sorted() | Stateful | Sorts elements by natural order or by a comparator |
limit() | Stateful | Truncates the stream to the first n elements |
skip() | Stateful | Discards the first n elements |
parallel(), sequential() and unordered() stand apart. Formally they also return a Stream, but they do not process elements: parallel() and sequential() switch the execution mode of the pipeline, while unordered() drops the requirement to preserve encounter order so the implementation has more freedom to optimise.
takeWhile() and dropWhile()
These methods arrived in Java 9 and are regularly confused with filter(). The difference is that filter() tests every element, whereas takeWhile() and dropWhile() only look at a contiguous prefix of the stream and stop at the first element that breaks the condition.
List<Integer> nums = List.of(1, 2, 3, 10, 4, 5);
System.out.println(nums.stream().takeWhile(n -> n < 5).toList()); // [1, 2, 3]
System.out.println(nums.stream().dropWhile(n -> n < 5).toList()); // [10, 4, 5]
System.out.println(nums.stream().filter(n -> n < 5).toList()); // [1, 2, 3, 4, 5] Where takeWhile() really pays off is with sorted or infinite streams: it aborts the traversal as soon as the condition stops holding instead of scanning the remainder.
List<Integer> powers = Stream.iterate(1, n -> n * 2)
.takeWhile(n -> n < 100)
.toList(); // [1, 2, 4, 8, 16, 32, 64] 5. Stateless and stateful operations
Stateless — the operation handles every element independently and remembers nothing about the previous ones: filter(), map(), flatMap(), peek(). Such operations parallelise perfectly and need no extra memory.
Stateful — the operation must see other elements, sometimes the entire stream, before it can produce a result: sorted(), distinct(), limit(), skip(), takeWhile(), dropWhile(). sorted() and distinct() have to buffer data, while limit() and skip() keep a counter of how many elements have gone through.
The practical takeaway: stateful operations cost more, and on an infinite stream sorted() and distinct() simply hang — they are waiting for an end of data that never arrives.
words.stream()
.filter(Objects::nonNull) // stateless
.distinct() // stateful: remembers what it has seen
.sorted() // stateful: buffers the whole stream
.forEach(System.out::println); 6. Details that are easy to miss
- A chain without a terminal operation never runs. The code compiles, nothing fails, and nothing happens either: intermediate methods only assemble the pipeline.
- The order of operations changes how much work is done.
filter().sorted()sorts the elements that survived the filter;sorted().filter()sorts all of them. The same goes fordistinct()andmap(): narrow the stream first, transform afterwards. peek()may never execute. Since Java 9count()can compute the number of elements without running the pipeline when the size is known in advance — and then the lambda insidepeek()is not invoked even once.findAny()is not required to return the first element. In a sequential stream it usually matchesfindFirst(), but relying on that is a mistake: the contract only promises "some" element.allMatch()returnstrueon an empty stream. That is vacuous truth:Stream.of().allMatch(x -> false)istrue, whileanyMatch()on an empty stream isfalse.- Side effects inside lambdas. Instead of
forEach(result::add), gather data withcollect()ortoList()— writing into a shared list from a parallel stream causes a data race and lost elements.
Tip
Do not use peek() to mutate data or to write logs in production. The javadoc calls it a debugging aid, and the implementation is allowed to skip the chain entirely when the result of the operation cannot affect the outcome. For logging, use map(), which returns the element explicitly.
Frequently asked questions
How do I tell whether a Stream method is terminal?
By its return type. If the method returns Stream, IntStream, LongStream or DoubleStream, it is intermediate and computes nothing. If it returns a concrete value, a collection, an array, an Optional, an Iterator, a Spliterator or void, it is terminal: it triggers the whole pipeline and closes the stream.
Can I call two terminal operations on the same stream?
No. A stream is single-use: after the first terminal operation it is closed, and any further call throws IllegalStateException with the message stream has already been operated upon or closed. Either build a new stream from the source, or keep a Supplier that hands out a fresh stream on every call to get().
What is the difference between takeWhile and filter?
filter tests every element of the stream and keeps all matches wherever they are. takeWhile takes elements only from the beginning and stops at the first element that fails the test, even if matching elements come later. For the list 1, 2, 3, 10, 4, 5 and the condition less than five, filter returns 1, 2, 3, 4, 5 while takeWhile returns only 1, 2, 3.
What is the difference between findFirst and findAny?
findFirst always returns the first element in encounter order. findAny returns any available element and is therefore cheaper in a parallel stream: it does not have to wait for the task that handles the beginning of the data. In a sequential stream the two usually give the same answer, but the contract does not guarantee it. Both return an Optional because the stream may turn out to be empty.
Comments