Java Streams, and When Not to Use Them

3 min read

  • Java

Streams are the part of Java 8 that changed day to day code the most, and also the part most often used where a loop would have been clearer. Both of those are worth understanding.

A pipeline has three parts

List<String> names = users.stream()   // source
    .filter(User::isActive)           // intermediate
    .map(User::getName)               // intermediate
    .toList();                        // terminal

Intermediate operations are lazy. They build up a description of the work and run nothing. The terminal operation is what triggers execution, and without one the pipeline never runs at all:

users.stream().map(this::sendEmail); // sends nothing

That compiles, produces no warning in most setups, and does absolutely nothing.

Laziness is not a detail

Elements are pulled through one at a time, not processed stage by stage. Given three users, the order is filter(a), map(a), filter(b), map(b), and so on, rather than filtering all three and then mapping all three.

This is what makes short circuiting work:

Optional<User> first = users.stream()
    .filter(User::isActive)
    .findFirst();

If the first user is active, the rest are never examined. The same applies to anyMatch, allMatch, noneMatch and limit, and it is why an infinite stream is usable:

Stream.iterate(1, n -> n * 2).limit(10).toList();

Collectors worth knowing by heart

toList() on the stream itself, since Java 16, returns an unmodifiable list and is shorter than collect(Collectors.toList()). Use it unless you specifically need a mutable list, in which case collect(Collectors.toCollection(ArrayList::new)) says so out loud.

Grouping is where collectors earn their place:

Map<Department, List<Employee>> byDept = employees.stream()
    .collect(Collectors.groupingBy(Employee::department));

With a downstream collector it gets considerably more useful:

Map<Department, Long> headcount = employees.stream()
    .collect(Collectors.groupingBy(Employee::department, Collectors.counting()));

Map<Department, Double> averagePay = employees.stream()
    .collect(Collectors.groupingBy(Employee::department,
             Collectors.averagingDouble(Employee::salary)));

toMap needs care:

Map<String, User> byEmail = users.stream()
    .collect(Collectors.toMap(User::email, Function.identity()));

Two users with the same email and this throws IllegalStateException. Supply a merge function whenever duplicates are possible:

.collect(Collectors.toMap(User::email, Function.identity(), (first, second) -> second));

toMap also throws on a null value, which HashMap would have accepted, and that catches people out when mapping a nullable field.

Do not mutate from inside a stream

List<String> names = new ArrayList<>();
users.stream().forEach(u -> names.add(u.getName())); // works, and is wrong

It works sequentially and corrupts the list in parallel, and it throws away the entire point of the API. The collector version is shorter anyway.

The related rule: keep lambdas free of side effects. A map that also writes to the database is a pipeline nobody can reason about, and it will be reordered the moment somebody adds a filter above it.

Parallel streams, briefly

parallelStream() is one word and it is almost never the right call. It uses the common ForkJoinPool, which is shared across the whole JVM, so a slow parallel stream in one request stalls unrelated work. It helps only when the collection is large, the per element work is CPU bound, and the source splits cheaply, which rules out LinkedList and most IO bound work outright.

In a Spring Boot application, where the container is already handling requests concurrently, the cores are usually busy. Measure before reaching for it, and be specific about what improved.

When a loop is better

Streams are worse than loops in three situations, and pretending otherwise leads to bad code.

When you need the index. IntStream.range(0, list.size()) followed by list.get(i) is a loop wearing a costume.

When you need to break out with state. A loop that accumulates and then breaks on a condition is direct. The stream version needs takeWhile plus a collector and is harder to read.

When the body throws checked exceptions. Lambdas cannot propagate them, so you end up wrapping in RuntimeException inside the pipeline, which is worse than the loop you were avoiding.

The test I use: if the pipeline reads as a sentence about what the data becomes, keep it. If it reads as instructions for a machine, write the loop.