Stream Basics
Understand what a stream is, how a source, intermediate steps and one terminal operation form a lazy pipeline, and when a plain loop is still the better tool.
List<R> result = source.stream()
.filter(predicate)
.map(mapper)
.toList();A stream is a pipeline that reads values from a source, passes them through a series of steps and produces one result. It is not a collection: it stores nothing, it never changes the list it came from, and it can be consumed only once.
Three parts of a pipeline
- Source:
list.stream(),Stream.of(...),Arrays.stream(array),IntStream.range(0, n)orFiles.lines(path). - Intermediate operations:
filter,map,sorted,distinct,limit. Each returns a new stream and does no work yet. - Terminal operation:
toList(),collect,count,forEach,reduce,findFirst. This starts the pipeline and closes the stream.
List<String> paidIds = orders.stream()
.filter(o -> o.status().equals("PAID"))
.map(Order::id)
.toList();Lazy, one element at a time
Nothing runs until the terminal operation is called. Then each element travels through the whole chain before the next one starts, so a filter followed by map does not build an intermediate list. This is also why findFirst or limit can stop early without touching the rest of the source.
Mistakes people make
- Storing a stream in a field or variable and using it twice. The second terminal call throws
IllegalStateException. Keep the collection and callstream()again. - Adding to the result of
toList(). Since Java 16 it returns an unmodifiable list, soaddthrowsUnsupportedOperationException. - Using
forEachto fill another list. Ask the stream for the result withtoList()or a collector instead. - Boxing numbers needlessly.
mapToIntgives anIntStreamwithsum,averageandmax, without creating anIntegerper element.
Where streams fit in a service
Most back-end stream code turns rows from a repository into response objects, totals a cart, or picks the records a request asked for. Keep each pipeline short and free of side effects so it reads as a description of the result. When the logic needs early returns, checked exceptions or several mutable counters, a plain for loop is often clearer, and that is fine. A stream that reads a file or a database cursor holds a resource, so open it in try-with-resources.
Example
When to use it
- Turn the rows a repository returns into the list of ids a REST endpoint sends back.
- Total the paid orders for a customer dashboard with mapToInt and sum.
- Stop reading a large result as soon as the first matching record is found with findFirst.
More examples
Nothing runs until the terminal operation
Building the pipeline prints nothing; the work starts at toList(). Each name goes through filter and then map before the next name is read, and cy never reaches map.
import java.util.List;
import java.util.stream.Stream;
void main() {
Stream<String> pipeline = Stream.of("ada", "bob", "cy")
.filter(s -> {
IO.println("filter " + s);
return s.length() > 2;
})
.map(s -> {
IO.println("map " + s);
return s.toUpperCase();
});
IO.println("pipeline built");
List<String> result = pipeline.toList();
IO.println(result);
}
// Output:
// pipeline built
// filter ada
// map ada
// filter bob
// map bob
// filter cy
// [ADA, BOB]A stream can only be used once
The second count() on the same stream throws. A Supplier that builds a fresh stream each time, or simply calling stream() again on the list, avoids the problem.
import java.util.List;
import java.util.function.Supplier;
import java.util.stream.Stream;
void main() {
List<String> emails = List.of("[email protected]", "[email protected]", "[email protected]");
Stream<String> stream = emails.stream();
IO.println(stream.count());
try {
stream.count();
} catch (IllegalStateException e) {
IO.println(e.getMessage());
}
Supplier<Stream<String>> comAddresses =
() -> emails.stream().filter(email -> email.endsWith(".com"));
IO.println(comAddresses.get().count());
IO.println(comAddresses.get().toList());
}
// Output:
// 3
// stream has already been operated upon or closed
// 2
// [[email protected], [email protected]]toList() returns a read-only list
Stream.toList() gives an unmodifiable list, which is usually what a response body or a cached value should be. Copy it into an ArrayList when you really need to add to it.
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
void main() {
List<String> names = Stream.of("ada", "bob").toList();
try {
names.add("cy");
} catch (UnsupportedOperationException e) {
IO.println("read-only: " + e.getClass().getSimpleName());
}
List<String> editable = new ArrayList<>(names);
editable.add("cy");
IO.println(editable);
}
// Output:
// read-only: UnsupportedOperationException
// [ada, bob, cy]
Discussion