Blog

Lambdas and Streams in Java: Laziness, Collectors and When to Use a Loop

Java lambdas turn a small piece of behaviour into a value, and streams chain those values into lazy pipelines. See how a pipeline really runs, which collectors to reach for, and when a plain loop reads better.

A lambda is a short piece of code you can pass around like a value. A stream is a pipeline that feeds a sequence of values through steps made of those lambdas. Together they replace many of the loops older Java code was full of.

This post covers lambdas and the functional interfaces behind them, method references, what a lambda can capture, stream pipelines, laziness, collectors, primitive streams, reduce, gatherers, and the cases where a loop is still the better tool. Every program below was run on Java 25, and its output is pasted from the run. To run one yourself, save it as Main.java and run java Main.java.

From anonymous class to lambda

A lambda is a shorter way to write an object that implements an interface with one abstract method. Sorting words by length shows the path. Here’s the same Comparator written three ways:

List<String> sortedBy(List<String> words, Comparator<String> order) {
    var copy = new ArrayList<>(words);
    copy.sort(order);
    return copy;
}

void main() {
    var words = List.of("banana", "fig", "cherry", "kiwi");

    var anonymous = new Comparator<String>() {
        @Override
        public int compare(String a, String b) {
            return Integer.compare(a.length(), b.length());
        }
    };
    Comparator<String> lambda = (a, b) -> Integer.compare(a.length(), b.length());
    Comparator<String> byKey = Comparator.comparing(String::length);

    IO.println(sortedBy(words, anonymous));
    IO.println(sortedBy(words, lambda));
    IO.println(sortedBy(words, byKey));
}

It prints:

[fig, kiwi, banana, cherry]
[fig, kiwi, banana, cherry]
[fig, kiwi, banana, cherry]

The anonymous class spells out the interface, the method name and the parameter types. The lambda keeps only the parameters and the body, because the compiler already knows the target is a Comparator<String>, and a Comparator has one method to fill in. The third line doesn’t write a comparison at all: Comparator.comparing builds one from a key, the length.

banana stays ahead of cherry in every line, although both have six letters. List.sort is stable, so equal elements keep their original order.

Functional interfaces

A functional interface is an interface with exactly one abstract method, and a lambda can stand in for any of them. The JDK ships a set of general ones in java.util.function, so you rarely need your own:

@FunctionalInterface
interface PriceRule {
    int apply(int cents);
}

void main() {
    Function<String, Integer> length = s -> s.length();
    Predicate<String> isLong = s -> s.length() > 4;
    Supplier<List<String>> fresh = () -> new ArrayList<>();
    Consumer<String> shout = s -> IO.println(s.toUpperCase() + "!");
    BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
    UnaryOperator<String> tidy = s -> s.strip();
    PriceRule discount = cents -> cents * 90 / 100;

    IO.println(length.apply("coffee"));
    IO.println(isLong.test("tea"));
    var list = fresh.get();
    list.add("new");
    IO.println(list);
    shout.accept("hello");
    IO.println(add.apply(2, 3));
    IO.println("[" + tidy.apply("  milk ") + "]");
    IO.println(discount.apply(500));
}

It prints:

6
false
[new]
HELLO!
5
[milk]
450

Each one is named for its shape:

  • Function<T, R> takes a T and returns an R. You call it with apply.
  • Predicate<T> takes a T and returns a boolean, with test.
  • Supplier<T> takes nothing and returns a T, with get.
  • Consumer<T> takes a T and returns nothing, with accept.
  • BiFunction<T, U, R> takes two arguments and returns an R.
  • UnaryOperator<T> is a Function<T, T>: same type in and out.

PriceRule is our own. @FunctionalInterface is optional, but it makes the compiler check the “exactly one” rule. We added a second abstract method, int undo(int cents), and the build failed with Unexpected @FunctionalInterface annotation, followed by multiple non-overriding abstract methods found in interface PriceRule.

Method references, four kinds

A method reference, written with ::, is a lambda whose whole body is one method call. There are four kinds, and the difference is where the object comes from:

record Point(int x, int y) {}

void main() {
    // static method: s -> Integer.parseInt(s)
    Function<String, Integer> parse = Integer::parseInt;
    // method on one object you already have: s -> prefix.concat(s)
    String prefix = "id-";
    Function<String, String> tag = prefix::concat;
    // method on whatever object arrives: s -> s.toUpperCase()
    Function<String, String> upper = String::toUpperCase;
    // constructor: (x, y) -> new Point(x, y)
    BiFunction<Integer, Integer, Point> make = Point::new;

    IO.println(parse.apply("42") + 1);
    IO.println(tag.apply("7"));
    IO.println(upper.apply("tea"));
    IO.println(make.apply(3, 4));
}

It prints:

43
id-7
TEA
Point[x=3, y=4]

The comment above each line is the lambda it replaces. The one that trips people up is String::toUpperCase. It looks like a static call, but toUpperCase is an instance method, so the first argument becomes the object the method runs on.

What a lambda can capture

A lambda can read local variables from the method around it, but only if they’re final or effectively final, meaning nothing assigns to them after they’re set. Counting with a lambda breaks that rule:

void main() {
    int count = 0;
    List.of("a", "b", "c").forEach(s -> count++);
    IO.println(count);
}

The build fails with:

Main.java:3: error: local variables referenced from a lambda expression must be final or effectively final
    List.of("a", "b", "c").forEach(s -> count++);
                                        ^

A lambda can outlive the method that created it, for example when it’s stored in a field or run on another thread. So Java copies the value into the lambda instead of sharing the variable. If the variable could change afterwards, the copy and the original would disagree, and Java rules that out at compile time. Fields aren’t limited this way, because the lambda reaches them through an object.

One more difference from anonymous classes: inside a lambda, this means the same as in the code around it.

void main() {
    Runnable lambda = () -> IO.println("lambda this: " + this.getClass().getName());
    Runnable anonymous = new Runnable() {
        @Override
        public void run() {
            IO.println("anonymous this: " + this.getClass().getName());
        }
    };
    lambda.run();
    anonymous.run();
}

It prints:

lambda this: Main
anonymous this: Main$1

The lambda’s this is the Main object that main runs on. The anonymous class is a new class, Main$1, so its this is itself.

A stream pipeline: source, steps, result

A stream pipeline has three parts: a source, any number of intermediate operations, and one terminal operation that produces a result. Here’s a list of orders cleaned up in one pass:

void main() {
    var orders = List.of("tea", "coffee", "tea", "juice", "water", "coffee", "milk");

    var result = orders.stream()
        .filter(o -> !o.equals("water"))
        .distinct()
        .map(String::toUpperCase)
        .sorted()
        .limit(3)
        .toList();

    IO.println(result);
    IO.println(orders);
}

It prints:

[COFFEE, JUICE, MILK]
[tea, coffee, tea, juice, water, coffee, milk]
  • Source: orders.stream(). Collections, arrays, Stream.of(...) and files can all be sources.
  • Intermediate operations: filter keeps the elements that pass a Predicate, distinct drops repeats, map turns each element into something else, sorted orders them, and limit keeps the first few. Each returns a new stream.
  • Terminal operation: toList() collects what’s left.

The last line shows the source list is untouched. A stream reads its source. It doesn’t change it.

toList() or collect(Collectors.toList())

Stream.toList() arrived in Java 16. We checked: with javac --release 15, the call fails with cannot find symbol. Before that, you wrote collect(Collectors.toList()), and a lot of code still does. The two aren’t quite the same:

void main() {
    var viaCollect = Stream.of("b", "a").collect(Collectors.toList());
    viaCollect.add("c");
    IO.println(viaCollect);

    var viaToList = Stream.of("b", "a").toList();
    IO.println(viaToList);
    viaToList.add("c");
}

It prints, then stops:

[b, a, c]
[b, a]
Exception in thread "main" java.lang.UnsupportedOperationException

toList() returns an unmodifiable list, so add throws. Collectors.toList() makes no promise either way, and today it happens to hand back an ArrayList. Use toList() unless you really need to change the list afterwards. If you do, say so with Collectors.toCollection(ArrayList::new).

Laziness: nothing runs until you ask

Intermediate operations don’t do any work when you call them. They only describe the work. The work starts when a terminal operation asks for a result:

void main() {
    var pipeline = Stream.of(1, 2, 3).map(n -> {
        IO.println("map saw " + n);
        return n * 10;
    });
    IO.println("pipeline built");

    var result = pipeline.toList();
    IO.println(result);
}

It prints:

pipeline built
map saw 1
map saw 2
map saw 3
[10, 20, 30]

pipeline built comes first. When the map line ran, it didn’t call the lambda even once. It returned a stream that remembers “multiply by ten”. The lambda ran only when toList() pulled elements through.

One element at a time

A stream doesn’t run filter over the whole list and then map over the result. Each element goes down the whole pipeline before the next one starts:

void main() {
    var result = Stream.of("apple", "fig", "cherry", "kiwi")
        .filter(w -> {
            IO.println("filter " + w);
            return w.length() > 3;
        })
        .map(w -> {
            IO.println("map    " + w);
            return w.toUpperCase();
        })
        .toList();
    IO.println(result);
}

It prints:

filter apple
map    apple
filter fig
filter cherry
map    cherry
filter kiwi
map    kiwi
[APPLE, CHERRY, KIWI]

Read it downwards. apple passes the filter and goes straight to map. fig fails the filter, so map never sees it. Then cherry goes all the way through, then kiwi. A loop with an if inside does exactly the same thing, and that’s the point: a stream is a loop you describe rather than write.

sorted is the exception. It can’t pass anything on until it has seen every element, so it collects them all first:

void main() {
    var result = Stream.of("kiwi", "apple", "fig")
        .peek(w -> IO.println("before sorted " + w))
        .sorted()
        .peek(w -> IO.println("after sorted  " + w))
        .toList();
    IO.println(result);
}

It prints:

before sorted kiwi
before sorted apple
before sorted fig
after sorted  apple
after sorted  fig
after sorted  kiwi
[apple, fig, kiwi]

peek runs a Consumer on each element as it passes, which makes it handy for seeing the flow. sorted and distinct are called stateful operations, because they need to remember elements. filter and map are stateless.

Short-circuiting: stopping early, even on an infinite stream

Some operations stop the pipeline as soon as they have their answer. findFirst stops at the first element that reaches it, and limit(n) stops after n. That’s what makes an infinite source safe:

void main() {
    var firstBig = Stream.iterate(1, n -> n * 2)
        .peek(n -> IO.println("looked at " + n))
        .filter(n -> n > 20)
        .findFirst();
    IO.println(firstBig);

    var firstFive = Stream.iterate(1, n -> n * 2).limit(5).toList();
    IO.println(firstFive);
}

It prints:

looked at 1
looked at 2
looked at 4
looked at 8
looked at 16
looked at 32
Optional[32]
[1, 2, 4, 8, 16]

Stream.iterate(1, n -> n * 2) describes 1, 2, 4, 8 and so on forever. The pipeline looked at six values, found 32, and stopped. findFirst returns an Optional, because a stream might have no first element. The part on Optional covers it. Swap findFirst() for toList() and the program never ends.

Laziness can skip your lambdas

Laziness has a surprise we didn’t expect. A terminal operation can decide it doesn’t need to run the pipeline at all:

void main() {
    long quick = Stream.of(1, 2, 3)
        .peek(n -> IO.println("peek " + n))
        .count();
    IO.println("count " + quick);

    long slow = Stream.of(1, 2, 3)
        .filter(n -> n > 1)
        .peek(n -> IO.println("peek " + n))
        .count();
    IO.println("count " + slow);
}

It prints:

count 3
peek 2
peek 3
count 2

The first peek never ran. Stream.of(1, 2, 3) knows it has three elements, peek can’t change that, so count answers 3 without pulling anything through. Once a filter is in the way, the size isn’t known, and every element has to flow. The JDK has done this since Java 9, and it’s allowed by the Stream documentation. So don’t put work you rely on inside peek or map. A lambda in a stream should compute a value, not cause an effect.

Explain it like I’m ten

A stream is an assembly line in a toy factory. Along the line stand workers: one throws away broken toys, one paints the good ones, one puts them in boxes. At the very end stands a customer.

Setting up the line doesn’t make any toys. Nothing moves until the customer at the end says “I want a finished toy”.

Then the first toy goes the whole way down the line: checked, painted, boxed, handed over. Only then does the second toy start. If the customer only wanted one toy, the line stops, and the other toys never leave the pile.

The precise version

A stream pipeline is a chain of stage objects. Each intermediate operation, like filter or map, adds a stage and returns at once. The terminal operation starts the evaluation. It asks the source for elements one by one, and each element is pushed through every stage’s lambda in turn before the source is asked for the next.

Stateless stages like filter and map handle an element and pass it on straight away. Stateful stages like sorted hold elements back until the source is empty. Short-circuiting operations, findFirst, anyMatch, limit and friends, signal that they’re done, and the source stops producing. Because the terminal operation sees the whole pipeline before it starts, it can also skip stages whose result it doesn’t need, as count did.

Where the analogy breaks: a real assembly line with a slow painter would have toys queued up between workers. A sequential stream never does. There’s no queue and no second toy in flight, except where a stateful stage like sorted stops the line to gather everything. And with parallel(), several lines run at once and elements don’t arrive in order.

Watching findFirst stop a stream

The animation follows Stream.of(1, 2, 3, 4, 5).filter(n -> n % 2 == 1).map(n -> n * 10).findFirst(), which returns Optional[10]:

Stream.of(1, 2, 3, 4, 5) 1 2 3 4 5 2 to 5: never looked at filter(n % 2 == 1) map(n * 10) findFirst() 1 10 1 is odd: passes 1 becomes 10 got one: stop result: Optional[10] the pipeline is built, but nothing moves yet findFirst() asks for one element 1 goes into filter: it's odd, so it passes 1 goes into map and comes out as 10 10 reaches findFirst(), which has its answer the stream stops: 2 to 5 are never looked at

A five-element stream pulled through filter, map and findFirst. Nothing moves until findFirst asks. Element 1 passes the filter, becomes 10, and reaches findFirst, which returns Optional[10]. The stream stops there, and 2 to 5 are never read.

Here are those steps in words, in case the animation doesn’t play for you:

  1. Stream.of, filter and map each return straight away. They’ve built a pipeline, and no element has moved.
  2. findFirst() is the terminal operation. It asks the pipeline for one element, and the request reaches the source.
  3. The source hands over 1. The filter tests 1 % 2 == 1, which is true, so 1 passes.
  4. 1 goes into map and comes out as 10.
  5. 10 reaches findFirst(). That’s all it needed, so it wraps the value as Optional[10].
  6. findFirst() tells the pipeline it’s done. The source never hands over 2, 3, 4 or 5, and no lambda runs for them.

A stream can be used once

A stream is a one-way trip over its source, so a second terminal operation on the same stream object fails:

void main() {
    var words = Stream.of("tea", "coffee", "milk");
    IO.println(words.count());
    IO.println(words.count());
}

It prints, then stops:

3
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed

The fix is to go back to the source, list.stream(), each time you need a new pipeline. To let other code make fresh streams, pass it the collection, not a stream.

Collectors: grouping, partitioning, joining

collect is the terminal operation that builds something from the elements, and Collectors holds the ready-made builders. Here are sales grouped, split and joined:

record Sale(String city, String product, int amount) {}

void main() {
    var sales = List.of(
        new Sale("Lisbon", "tea", 12),
        new Sale("Porto", "coffee", 30),
        new Sale("Lisbon", "coffee", 25),
        new Sale("Braga", "tea", 8),
        new Sale("Porto", "tea", 15));

    Map<String, Long> perCity = sales.stream()
        .collect(Collectors.groupingBy(Sale::city, TreeMap::new, Collectors.counting()));
    IO.println(perCity);

    Map<Boolean, List<Integer>> bigOrSmall = sales.stream()
        .map(Sale::amount)
        .collect(Collectors.partitioningBy(a -> a >= 20));
    IO.println(bigOrSmall);

    String cities = sales.stream()
        .map(Sale::city)
        .distinct()
        .sorted()
        .collect(Collectors.joining(", ", "<", ">"));
    IO.println(cities);
}

It prints:

{Braga=1, Lisbon=2, Porto=2}
{false=[12, 8, 15], true=[30, 25]}
<Braga, Lisbon, Porto>
  • groupingBy(key, mapFactory, downstream) puts elements in buckets by key, then runs a second collector on each bucket. counting() turns each bucket into its size. Leave out TreeMap::new and you get a HashMap, which has no useful order when you print it. The TreeMap keeps the cities sorted.
  • partitioningBy(predicate) always makes exactly two buckets, false and true, even when one is empty.
  • joining(separator, prefix, suffix) glues strings together. It only works on a stream of strings, which is why the map comes first.

toMap and duplicate keys

Collectors.toMap builds a map from a key function and a value function. When two elements produce the same key, you have to say what happens, or the stream throws:

record Sale(String city, int amount) {}

void main() {
    var sales = List.of(new Sale("Lisbon", 12), new Sale("Porto", 30), new Sale("Lisbon", 25));

    Map<String, Integer> totals = sales.stream()
        .collect(Collectors.toMap(Sale::city, Sale::amount, Integer::sum, TreeMap::new));
    IO.println(totals);

    Map<String, Integer> broken = sales.stream()
        .collect(Collectors.toMap(Sale::city, Sale::amount));
    IO.println(broken);
}

It prints, then stops:

{Lisbon=37, Porto=30}
Exception in thread "main" java.lang.IllegalStateException: Duplicate key Lisbon (attempted merging values 12 and 25)

The third argument, Integer::sum, is the merge function. It gets the old value and the new one and returns what to keep, so the Lisbon sales added up to 37. Without it, the second Lisbon throws IllegalStateException. The message names the key and both values. For a map from key to count or sum, groupingBy with counting() or summingInt says the same thing more plainly.

Primitive streams

IntStream, LongStream and DoubleStream hold plain int, long and double values, and they have the number methods that Stream<Integer> lacks:

void main() {
    IO.println(IntStream.range(0, 5).sum());
    IO.println(IntStream.rangeClosed(1, 5).boxed().toList());

    var words = List.of("tea", "coffee", "milk");
    int letters = words.stream().mapToInt(String::length).sum();
    OptionalDouble average = words.stream().mapToInt(String::length).average();
    IO.println(letters);
    IO.println(average);
    IO.println(average.orElse(0));
    IO.println(IntStream.empty().average());
}

It prints:

10
[1, 2, 3, 4, 5]
13
OptionalDouble[4.333333333333333]
4.333333333333333
OptionalDouble.empty

range(0, 5) is 0 to 4, and rangeClosed(1, 5) includes the 5. boxed() goes back to a Stream<Integer> when you need a List.

average() returns an OptionalDouble, not a double, because an empty stream has no average. The last line shows that case.

mapToInt is the important one. words.stream().map(String::length) would give you a Stream<Integer>: every length wrapped in an Integer object, and no sum() method to call. mapToInt gives you an IntStream of plain ints. No wrapper objects get created, and sum, average, min and max come with it.

reduce, in brief

reduce combines all the elements into one value by applying a function pair by pair:

void main() {
    int total = Stream.of(3, 4, 5).reduce(0, Integer::sum);
    Optional<Integer> product = Stream.of(3, 4, 5).reduce((a, b) -> a * b);
    Optional<Integer> nothing = Stream.<Integer>empty().reduce((a, b) -> a * b);

    IO.println(total);
    IO.println(product);
    IO.println(nothing);
    IO.println(IntStream.of(3, 4, 5).sum());
}

It prints:

12
Optional[60]
Optional.empty
12

With a starting value, 0 here, you get a plain result. Without one, you get an Optional, since there may be nothing to combine. reduce is general, so a reader has to work out what it computes. For sums, counts, minimums, maximums and joins, the named version (sum(), count(), max(...), Collectors.joining) says it directly, as the last line does.

Gatherers: custom intermediate steps

A gatherer is a custom intermediate operation, used with Stream.gather. Gatherers became final in Java 24. We checked: with javac --release 23, the build fails with Gatherers is a preview API and is disabled by default, and on Java 25 no flag is needed. Gatherers ships a few ready-made ones, such as fixed and sliding windows:

void main() {
    var readings = List.of(3, 5, 4, 8, 9, 2, 7);

    IO.println(readings.stream().gather(Gatherers.windowFixed(3)).toList());
    IO.println(readings.stream().gather(Gatherers.windowSliding(3)).toList());

    var movingAverage = readings.stream()
        .gather(Gatherers.windowSliding(3))
        .map(w -> w.stream().mapToInt(Integer::intValue).average().orElseThrow())
        .toList();
    IO.println(movingAverage);
}

It prints:

[[3, 5, 4], [8, 9, 2], [7]]
[[3, 5, 4], [5, 4, 8], [4, 8, 9], [8, 9, 2], [9, 2, 7]]
[4.0, 5.666666666666667, 7.0, 6.333333333333333, 6.0]

windowFixed(3) cuts the stream into groups of three, and the last group gets whatever is left. windowSliding(3) moves one element at a time, which is what a moving average needs. Before gatherers, grouping neighbouring elements meant a loop with indexes. You can also write your own with Gatherer.of.

When a loop is clearer

Streams are good at “take these, keep some, change them, collect them”. Outside that shape, four cases come up again and again.

Checked exceptions. None of the functional interfaces in java.util.function declares a checked exception, so a lambda can’t throw one:

int parsePort(String s) throws IOException {
    if (s.isBlank()) {
        throw new IOException("blank port");
    }
    return Integer.parseInt(s);
}

void main() {
    var ports = Stream.of("80", "443").map(s -> parsePort(s)).toList();
    IO.println(ports);
}

The build fails with:

Main.java:9: error: unreported exception IOException; must be caught or declared to be thrown
    var ports = Stream.of("80", "443").map(s -> parsePort(s)).toList();
                                                     ^

The workarounds are to catch the exception inside the lambda and wrap it in an unchecked one, or to write a helper that does that for you. Both hide the exception from the method signature. A for loop can simply let it propagate.

Mutating local state, and early exit. Here’s “buy items in order until the next one would go over budget”, once as a stream and once as a loop:

void main() {
    var prices = List.of(40, 25, 30, 50, 10);
    int budget = 100;

    int[] spentBox = {0};
    long boughtByStream = prices.stream()
        .takeWhile(p -> {
            if (spentBox[0] + p > budget) {
                return false;
            }
            spentBox[0] += p;
            return true;
        })
        .count();
    IO.println("stream: bought " + boughtByStream + ", spent " + spentBox[0]);

    int spent = 0;
    int bought = 0;
    for (int price : prices) {
        if (spent + price > budget) {
            break;
        }
        spent += price;
        bought++;
    }
    IO.println("loop:   bought " + bought + ", spent " + spent);
}

It prints:

stream: bought 3, spent 95
loop:   bought 3, spent 95

Both give the same answer. The stream version needs a one-element array, because the lambda can’t assign to a local variable, so it changes the inside of an array instead. Its takeWhile predicate has a side effect, which is exactly what the count surprise earlier warned against. The loop says what happens in the order it happens, and break is the early exit, with no trick.

Debugging. In a loop, you can put a breakpoint on a line and look at every variable. In a pipeline, the code you wrote runs inside the stream library. Stack traces fill up with frames from java.util.stream and generated names like lambda$main$0, and a breakpoint lands inside a lambda with no loop variable around it. If you need several peek calls to understand a pipeline, it probably wants to be a loop.

A short rule: use a stream when the pipeline reads like a sentence, filter this, map that, collect. Use a loop when you need break, a checked exception, several variables that change together, or a debugger.

What to remember

  • A lambda implements an interface with one abstract method. Function, Predicate, Supplier, Consumer, BiFunction and UnaryOperator cover most needs.
  • A method reference, like String::length or Point::new, is a lambda that just calls one method.
  • A lambda can only capture local variables that are effectively final.
  • A stream runs nothing until a terminal operation asks, then moves one element through every step before the next. findFirst and limit stop it early, even on an infinite source.
  • Don’t rely on side effects in peek or map. The terminal operation may skip them, as count did.
  • A stream can be used once. toList() is unmodifiable. Give groupingBy a TreeMap when order matters, and give toMap a merge function when keys can repeat.
  • Use mapToInt for numbers, and a plain loop for checked exceptions, early exits and changing state.

A stream describes the work, and the terminal operation decides how much of it actually runs.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.