Java Streams, Lambdas and Optional on JDK 26

Lazy evaluation shown with peek, the toMap duplicate-key crash, for vs stream vs parallelStream timed on JDK 26, Optional misuse and Java 24 gatherers.

Series: Java lecture 4/6 (intermediate). For developers who can read a lambda but are not sure when a stream actually runs, why a collector crashes, or whether parallelStream() will help. After this lecture you will be able to predict the order a pipeline executes in, fix the four stream exceptions beginners hit, and decide between a loop, a stream and a parallel stream from measurements.

Streams show up in every Java code review now, and most review comments about them fall into a few groups: "this runs twice", "this crashes on duplicate keys", "why is this parallel", "don't call get() on an Optional". This post goes through each one with code we ran and the exact output. Nothing here is a summary of the Javadoc; where the docs and our run differ in emphasis, we show the run.

Environment: OpenJDK 26 (Temurin build 26+35), Apple Silicon Mac (M1 Pro, 8 cores; the common fork-join pool reported parallelism 7). Short demos were run with java File.java, the benchmark with javac then java -Xms2g -Xmx2g. Random data uses new Random(7).

Lambdas, method references and functional interfaces

A lambda is an instance of a functional interface: an interface with exactly one abstract method. The JDK ships the common shapes in java.util.function, and a method reference is a shorter way to write a lambda that only calls one method.

Function<String, Integer> len = String::length;            // T -> R
Predicate<String> empty = String::isEmpty;                  // T -> boolean
Supplier<List<String>> mk = ArrayList::new;                 // () -> T
BiFunction<String, String, Boolean> eq = String::equalsIgnoreCase;
UnaryOperator<String> up = s -> s.toUpperCase();            // T -> T
System.out.println(len.apply("tokyo") + " " + empty.negate().test("") + " "
    + mk.get() + " " + eq.apply("Java", "JAVA") + " " + up.andThen(s -> s + "!").apply("hi"));
5 false [] true HI!

String::length takes the receiver as the argument; String::equalsIgnoreCase takes the receiver as the first of two; ArrayList::new is a constructor. Default methods like negate() and andThen() let you combine them without writing new lambdas.

The first compiler error people meet with lambdas is about local variables:

List<Integer> prices = List.of(980, 3200, 600);
int total = 0;
prices.forEach(p -> total += p);
Eff.java:7: error: local variables referenced from a lambda expression must be final or effectively final
        prices.forEach(p -> total += p);
                            ^
1 error

A lambda can read a local variable only if that variable never changes after it is assigned. The fix is not a one-element array or an AtomicInteger trick; it is to let the stream compute the value: int total = prices.stream().mapToInt(Integer::intValue).sum(); (prints 4780). If you came from C#, this is stricter than C# closures, which capture variables by reference; see our C# lambda post for the contrast.

Lazy evaluation, made visible with peek

A stream pipeline does nothing until a terminal operation (findFirst, collect, count, forEach…) asks for a result. peek lets you watch it happen:

Stream<String> s = Stream.of("fig", "kiwi", "apple", "banana")
    .peek(x -> System.out.println("  peek   " + x))
    .filter(x -> x.length() > 3)
    .map(x -> { System.out.println("  map    " + x); return x.toUpperCase(); });
System.out.println("pipeline built, nothing printed yet");
Optional<String> first = s.findFirst();
System.out.println("findFirst -> " + first.get());

System.out.println("-- sorted() is a barrier:");
Stream.of("kiwi", "apple", "fig")
    .peek(x -> System.out.println("  before sort " + x))
    .sorted()
    .peek(x -> System.out.println("  after sort  " + x))
    .findFirst();
pipeline built, nothing printed yet
  peek   fig
  peek   kiwi
  map    kiwi
findFirst -> KIWI
-- sorted() is a barrier:
  before sort kiwi
  before sort apple
  before sort fig
  after sort  apple

Three facts are in that output. Nothing printed while the pipeline was being built. Elements went through one at a time, top to bottom: fig was peeked and dropped, then kiwi went all the way through, not "peek everything, then filter everything". And findFirst short-circuited: apple and banana were never read. The second pipeline shows the exception: sorted() must see every element before it can emit the first, so it is a barrier.

Flow diagram. The source Stream.of fig, kiwi, apple, banana feeds peek, then filter length greater than 3, then map to upper case, then findFirst. fig passes peek but is dropped by the filter. kiwi passes peek, the filter and map, and findFirst returns KIWI. apple and banana are never read, so peek never prints them.
What the peek output proves: elements travel one at a time through the whole pipeline, and findFirst stops the source as soon as one element survives.

The practical consequence: never put side effects you rely on inside peek or map. Whether they run, and how many times, depends on the terminal operation. count() is allowed to skip the pipeline when it can get the size from the source, and on JDK 26 it does:

List<String> l = List.of("a", "b", "c");
long n = l.stream().peek(x -> System.out.println("peek " + x)).count();
System.out.println("count=" + n);
long m = l.stream().peek(x -> System.out.println("peek " + x)).filter(x -> !x.isEmpty()).count();
System.out.println("count after filter=" + m);
count=3
peek a
peek b
peek c
count after filter=3

The first peek never ran. Adding a filter made the size unknown, so the second pipeline did run.

Collectors: groupingBy, counting, joining, toMap

record Order(String city, String item, int yen) {}

List<Order> orders = List.of(
    new Order("Osaka", "ramen", 980), new Order("Tokyo", "sushi", 3200),
    new Order("Osaka", "takoyaki", 600), new Order("Fukuoka", "ramen", 850),
    new Order("Tokyo", "ramen", 1100));

System.out.println(orders.stream()
    .collect(Collectors.groupingBy(Order::city, Collectors.counting())));
System.out.println(orders.stream()
    .collect(Collectors.groupingBy(Order::city, TreeMap::new, Collectors.counting())));
System.out.println(orders.stream()
    .collect(Collectors.groupingBy(Order::city, TreeMap::new, Collectors.summingInt(Order::yen))));
System.out.println(orders.stream().map(Order::item).distinct().sorted()
    .collect(Collectors.joining(", ", "[", "]")));
System.out.println("max price per item " + orders.stream()
    .collect(Collectors.toMap(Order::item, Order::yen, Integer::max, TreeMap::new)));
{Tokyo=2, Fukuoka=1, Osaka=2}
{Fukuoka=1, Osaka=2, Tokyo=2}
{Fukuoka=850, Osaka=1580, Tokyo=4300}
[ramen, sushi, takoyaki]
max price per item {ramen=1100, sushi=3200, takoyaki=600}

Look at the first line: groupingBy returns a HashMap, so the cities come out in no particular order. If output order matters (reports, tests that compare strings), pass TreeMap::new as the map factory, as in the second line.

Common mistake: toMap with duplicate keys

Map<String, Integer> priceByItem = orders.stream()
    .collect(Collectors.toMap(Order::item, Order::yen));   // "ramen" appears 3 times

What actually happens:

Exception in thread "main" java.lang.IllegalStateException: Duplicate key ramen (attempted merging values 980 and 850)
    at java.base/java.util.stream.Collectors.duplicateKeyException(Collectors.java:137)
    at java.base/java.util.stream.Collectors.lambda$uniqKeysMapAccumulator$0(Collectors.java:184)
    at java.base/java.util.stream.ReduceOps$3ReducingSink.accept(ReduceOps.java:169)
    at java.base/java.util.stream.AbstractList$RandomAccessSpliterator.forEachRemaining(AbstractList.java:722)
    …
    at java.base/java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:723)
    at Collect.main(Collect.java:34)

The two-argument toMap assumes keys are unique. This is a typical "works in test, fails in production" bug: the test data had one ramen. At least the message names the key and both values. Fix: decide what a duplicate means. Keep the max or the latest with a merge function (Integer::max, (a, b) -> b), or keep them all with groupingBy.

Common mistake: using a stream twice

A Stream is a one-shot pipeline, not a collection.

Stream<String> names = Stream.of("ana", "bo", "chie");
System.out.println(names.count());
System.out.println(names.filter(n -> n.length() > 2).count());
3
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
    at java.base/java.util.stream.AbstractPipeline.<init>(AbstractPipeline.java:201)
    …
    at java.base/java.util.stream.ReferencePipeline.filter(ReferencePipeline.java:184)
    at Twice.main(Twice.java:7)

It fails on filter, the first call on the already-consumed stream. The usual cause is a method that returns Stream<T> and a caller that uses the result twice. Fix: keep the source (List<String> names) and call names.stream() each time, or pass a Supplier<Stream<String>> if the source is expensive to describe. Both versions print 3 and 2.

for loop vs stream vs parallelStream, measured

Same harness as lecture 3: warm-up runs untimed, then the median of 11 timed runs, results summed into a field so the JIT cannot discard the work. JMH would be the proper tool; it is not used because nothing may be installed in this series. The task: sum the squares of the even values.

cores=8 commonPool parallelism=7
== A: sum of squares of even values, int[10,000,000]
for loop                       median   27.1625 ms  (min 26.6018, max 27.8053)
IntStream                      median   33.0102 ms  (min 32.3225, max 34.3361)
IntStream.parallel()           median    8.9625 ms  (min 7.4494, max 10.0775)
== B: same work, List<Integer> of 1,000 elements, 1,000 calls per sample
for loop                       median    0.6654 ms  (min 0.6511, max 0.6747)
stream()                       median    0.7942 ms  (min 0.7686, max 2.4748)
parallelStream()               median    6.5678 ms  (min 4.3373, max 22.6658)
== C: LinkedList<Integer> of 1,000,000
for loop                       median    4.5086 ms  (min 4.4550, max 4.7740)
stream()                       median    7.5466 ms  (min 7.5023, max 7.8438)
parallelStream()               median    4.6535 ms  (min 3.8963, max 7.4222)
== D: Stream.iterate(...).limit(2,000,000)
for loop                       median    0.6605 ms  (min 0.6594, max 0.8373)
iterate+limit                  median   16.1651 ms  (min 13.1412, max 18.0171)
iterate+limit parallel         median   24.7791 ms  (min 14.7319, max 35.6399)
LongStream.range parallel      median    0.7683 ms  (min 0.3322, max 2.5199)
Bar chart. The for loop took 27.2 milliseconds, the sequential IntStream 33.0 milliseconds, and the parallel IntStream 9.0 milliseconds.
Sum of squares of the even values in an int[10,000,000], median of 11 runs after 20 warm-up runs, 8-core Apple Silicon Mac (M1 Pro). Here parallel pays off.

A, a big primitive array: the parallel stream was 3 times faster than the loop. An array splits into equal halves for free, there is no boxing, and 10 million elements is enough work to pay for 7 worker threads. The sequential IntStream was about 20% slower than the loop.

Bar chart. The for loop took 0.67 milliseconds, stream 0.79 milliseconds, and parallelStream 6.57 milliseconds.
The same work on a List<Integer> of 1,000 elements, repeated 1,000 times per sample. Splitting work across threads costs more than the work itself, so parallelStream is about 10 times slower.

B, the case where parallel is slower: on a 1,000-element list, parallelStream() was about 10 times slower than the loop. Each call splits the list, hands pieces to the fork-join pool, waits and merges; for 1,000 elements that overhead is bigger than the work. This is the shape of most request-handling code: many small collections, not one huge one.

C and D, the surprises: LinkedList is often said to parallelize badly, but here the parallel stream merely tied the loop; it did not lose. Stream.iterate(...).limit(n) is the real trap: each element depends on the previous one and limit must preserve order, so the parallel version was slower than the sequential one (24.8 ms vs 16.2 ms), and both were more than 20 times slower than a plain loop. LongStream.range expresses the same numbers in a splittable way and was fast. Across three runs the parallel numbers moved the most (A: 5.3–9.0 ms, B: 6.6–17.9 ms, D: 15.9–24.8 ms); the conclusions did not.

Optional: good and bad uses

Optional is a return type for "there may be no result". It is not a general null replacement.

record User(String id, String email) {}
static final Map<String, User> DB = Map.of("u1", new User("u1", "a@example.com"), "u2", new User("u2", null));
static Optional<User> find(String id) { return Optional.ofNullable(DB.get(id)); }
static String slowDefault() { System.out.println("  (slowDefault called)"); return "none"; }

System.out.println(find("u1").map(User::email).orElse("none"));
System.out.println(find("u2").map(User::email).orElse("none"));   // map turns null into empty
System.out.println(find("u9").map(User::email).orElse("none"));
System.out.println("orElse:");
System.out.println(find("u1").map(User::email).orElse(slowDefault()));
System.out.println("orElseGet:");
System.out.println(find("u1").map(User::email).orElseGet(Opt::slowDefault));
a@example.com
none
none
orElse:
  (slowDefault called)
a@example.com
orElseGet:
a@example.com

Good use: return Optional from a lookup, chain map, and decide once at the end. Note that map also absorbs a null returned by the mapper (u2 has no e-mail). The trap is orElse(x): x is an ordinary argument, so it is evaluated even when a value is present. If the default is a database call or creates an object, use orElseGet.

The bad uses, with what each one does:

try { find("u9").get().email(); }
catch (NoSuchElementException e) { System.out.println("get() on empty -> " + e.getMessage()); }
try { Optional.of(DB.get("u9")); }
catch (NullPointerException e) { System.out.println("Optional.of(null) -> NPE"); }
Optional<User> maybe = null;
try { maybe.isPresent(); }
catch (NullPointerException e) { System.out.println("null Optional -> " + e.getMessage()); }
get() on empty -> No value present
Optional.of(null) -> NPE
null Optional -> Cannot invoke "java.util.Optional.isPresent()" because "maybe" is null

get() without a check is just a NullPointerException with a different name; if you are sure a value exists, orElseThrow() says so explicitly. Optional.of is for values that cannot be null; use ofNullable otherwise. The last line is an Optional variable that is itself null, which defeats the point. Also avoid Optional as a field or method parameter (it is not Serializable and adds an allocation), and never wrap a collection: return an empty list instead. One detail: the helpful NPE message names the variable "maybe" only when the class was compiled with -g; with the source launcher (java Opt.java) we got "<local1>" instead.

Checked exceptions inside lambdas

Function.apply does not declare throws, so a lambda that calls a method throwing a checked exception does not compile:

List<String> texts = files.stream()
    .map(p -> Files.readString(p))
    .toList();
Checked.java:8: error: unreported exception IOException; must be caught or declared to be thrown
            .map(p -> Files.readString(p))
                                      ^
1 error

The standard fix is to wrap the checked exception in an unchecked one inside a small helper, then unwrap it where you can handle it:

static String read(Path p) {
    try { return Files.readString(p); }
    catch (IOException e) { throw new UncheckedIOException(e); }
}
…
try {
    List<String> texts = List.of(Path.of("a.txt"), Path.of("missing.txt")).stream()
        .map(Checked2::read)
        .toList();
} catch (UncheckedIOException e) {
    System.out.println("caught: " + e.getCause());
}
caught: java.nio.file.NoSuchFileException: missing.txt

UncheckedIOException exists for exactly this case. Be honest with yourself about the result, though: the first failure stops the whole pipeline. If you need "read what you can and report the rest", a plain for loop with try/catch per file is clearer than any stream trick.

Stream.toList() vs collect(Collectors.toList())

List<Integer> a = Stream.of(3, 1, 2).collect(Collectors.toList());
a.add(4);
System.out.println("collect(toList()) " + a + " " + a.getClass().getName());
List<Integer> u = Stream.of(3, 1, 2).collect(Collectors.toUnmodifiableList());
System.out.println("toUnmodifiableList " + u.getClass().getName());
System.out.println("toList with null " + Stream.of(1, null, 3).toList());
try { Stream.of(1, null, 3).collect(Collectors.toUnmodifiableList()); }
catch (NullPointerException e) { System.out.println("toUnmodifiableList with null -> " + e); }
List<Integer> b = Stream.of(3, 1, 2).toList();
System.out.println("Stream.toList()   " + b + " " + b.getClass().getName());
b.add(4);
collect(toList()) [3, 1, 2, 4] java.util.ArrayList
toUnmodifiableList java.util.ImmutableCollections$ListN
toList with null [1, null, 3]
toUnmodifiableList with null -> java.lang.NullPointerException
Stream.toList()   [3, 1, 2] java.util.ImmutableCollections$ListN
Exception in thread "main" java.lang.UnsupportedOperationException
    at java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java:145)
    at java.base/java.util.ImmutableCollections$AbstractImmutableCollection.add(ImmutableCollections.java:150)
    at ToList.main(ToList.java:17)

Replacing .collect(Collectors.toList()) with the shorter .toList() is a popular IDE suggestion, and it changes behavior: code that later calls add on the result now throws. Stream.toList() is unmodifiable but allows null; toUnmodifiableList() is unmodifiable and rejects null. Collectors.toList() gave an ArrayList on JDK 26, but the Javadoc makes no promise about mutability, so write Collectors.toCollection(ArrayList::new) when you need to modify the list.

Gatherers (Java 24): checked on 26

Before Java 24, custom intermediate operations (windows, running totals, "keep only rising values") required leaving the stream or writing a Spliterator. JEP 485 made Stream.gather and java.util.stream.Gatherers final in Java 24. It compiles and runs on 26:

List<Integer> temps = List.of(21, 23, 22, 26, 25, 27, 30);
System.out.println(temps.stream().gather(Gatherers.windowFixed(3)).toList());
System.out.println(temps.stream().gather(Gatherers.windowSliding(3))
    .map(w -> w.stream().mapToInt(Integer::intValue).average().orElse(0))
    .map(a -> String.format("%.1f", a)).toList());
System.out.println(temps.stream().gather(Gatherers.scan(() -> 0, Integer::sum)).toList());
// a custom gatherer: keep only values that rose compared with the previous one
Gatherer<Integer, int[], Integer> rising = Gatherer.ofSequential(
    () -> new int[]{Integer.MIN_VALUE},
    (state, x, down) -> { boolean up = x > state[0]; state[0] = x; return !up || down.push(x); });
System.out.println(temps.stream().gather(rising).toList());
[[21, 23, 22], [26, 25, 27], [30]]
[22.0, 23.7, 24.3, 26.0, 27.3]
[21, 44, 66, 92, 117, 144, 174]
[21, 23, 26, 27, 30]

Compiling the same file with javac --release 23 fails with error: Gatherers is a preview API and is disabled by default. (it was a preview in 22 and 23); --release 24 compiles. The custom gatherer's integrator returns false to stop the stream early; we return !up || down.push(x) so that skipping an element never stops it.

Decision table

Situation Use Why (from the runs above)
Simple filter/map/collect, readability first sequential stream() about 20% slower than the loop in A and B
Hot loop over a primitive array for loop or IntStream no boxing; loop 27 ms vs stream 33 ms
Large array, independent CPU work, 1M+ elements parallel() 9.0 ms vs 27.2 ms on 8 cores
Small collections, per-request code never parallelStream() 10x slower at 1,000 elements
Generated sequence IntStream.range, not Stream.iterate().limit() 0.77 ms vs 16–25 ms
Keys may repeat toMap(k, v, merge) or groupingBy two-arg toMap throws IllegalStateException
Need the result twice keep the source, call .stream() again a stream throws on second use
"No result" from a lookup return Optional, finish with orElseGet orElse evaluates its argument anyway
Result list will be modified collect(toCollection(ArrayList::new)) Stream.toList() is unmodifiable
Windows, running totals gather(...) (Java 24+) stays inside the pipeline
I/O with per-item error handling plain for + try/catch checked exceptions do not cross lambdas

Honest limits and version notes

  • Not JMH. Warm-up plus medians, three full runs. Parallel results varied the most (one B sample hit 22.7 ms). Treat the ratios, not the milliseconds, as the result. On a machine with 2 cores, A would gain much less.
  • The fork-join common pool is shared by every parallel stream in the JVM. A parallel stream in a web server competes with other requests; our single-program measurement does not show that.
  • Versions checked on JDK 26 with --release: Stream.toList() is Java 16 (--release 15 fails with cannot find symbol). Gatherers are final in Java 24 (preview error at 23). Lambdas and streams are Java 8, Files.readString is Java 11, records are Java 16; those we only ran on 26.
  • We did not cover Collectors.teeing (Java 12), primitive collectors in depth, or the parallel behavior of custom gatherers.
  • For the same ideas in another language: LINQ in C# has the same deferred execution, and Python generators are lazy in the same pull-one-at-a-time way.

Run it yourself

Save as StreamBench.java, then javac StreamBench.java && java -Xms2g -Xmx2g StreamBench:

import java.util.*;
import java.util.function.LongSupplier;
import java.util.stream.*;

public class StreamBench {
    static long sink;

    static double measure(String name, int warmup, int runs, LongSupplier task) {
        for (int i = 0; i < warmup; i++) sink += task.getAsLong();
        double[] t = new double[runs];
        for (int i = 0; i < runs; i++) {
            long s = System.nanoTime();
            sink += task.getAsLong();
            t[i] = (System.nanoTime() - s) / 1e6;
        }
        Arrays.sort(t);
        System.out.printf("%-30s median %9.4f ms  (min %.4f, max %.4f)%n", name, t[runs / 2], t[0], t[runs - 1]);
        return t[runs / 2];
    }

    public static void main(String[] args) {
        System.out.println("cores=" + Runtime.getRuntime().availableProcessors()
            + " commonPool parallelism=" + java.util.concurrent.ForkJoinPool.commonPool().getParallelism());
        Random rnd = new Random(7);
        int[] big = new int[10_000_000];
        for (int i = 0; i < big.length; i++) big[i] = rnd.nextInt(1000);

        System.out.println("== A: sum of squares of even values, int[10,000,000]");
        measure("for loop", 20, 11, () -> { long s = 0; for (int v : big) if ((v & 1) == 0) s += (long) v * v; return s; });
        measure("IntStream", 20, 11, () -> Arrays.stream(big).filter(v -> (v & 1) == 0).mapToLong(v -> (long) v * v).sum());
        measure("IntStream.parallel()", 20, 11, () -> Arrays.stream(big).parallel().filter(v -> (v & 1) == 0).mapToLong(v -> (long) v * v).sum());

        System.out.println("== B: same work, List<Integer> of 1,000 elements, 1,000 calls per sample");
        List<Integer> small = new ArrayList<>();
        for (int i = 0; i < 1_000; i++) small.add(rnd.nextInt(1000));
        measure("for loop", 20, 11, () -> { long t = 0; for (int r = 0; r < 1000; r++) { long s = 0; for (int v : small) if ((v & 1) == 0) s += (long) v * v; t += s; } return t; });
        measure("stream()", 20, 11, () -> { long t = 0; for (int r = 0; r < 1000; r++) t += small.stream().filter(v -> (v & 1) == 0).mapToLong(v -> (long) v * v).sum(); return t; });
        measure("parallelStream()", 20, 11, () -> { long t = 0; for (int r = 0; r < 1000; r++) t += small.parallelStream().filter(v -> (v & 1) == 0).mapToLong(v -> (long) v * v).sum(); return t; });

        System.out.println("== C: LinkedList<Integer> of 1,000,000");
        List<Integer> linked = new LinkedList<>();
        for (int i = 0; i < 1_000_000; i++) linked.add(rnd.nextInt(1000));
        measure("for loop", 20, 11, () -> { long s = 0; for (int v : linked) if ((v & 1) == 0) s += (long) v * v; return s; });
        measure("stream()", 20, 11, () -> linked.stream().filter(v -> (v & 1) == 0).mapToLong(v -> (long) v * v).sum());
        measure("parallelStream()", 20, 11, () -> linked.parallelStream().filter(v -> (v & 1) == 0).mapToLong(v -> (long) v * v).sum());

        System.out.println("== D: Stream.iterate(...).limit(2,000,000)");
        measure("for loop", 20, 11, () -> { long s = 0; for (int i = 0; i < 2_000_000; i++) s += (long) i * i; return s; });
        measure("iterate+limit", 20, 11, () -> Stream.iterate(0, i -> i + 1).limit(2_000_000).mapToLong(i -> (long) i * i).sum());
        measure("iterate+limit parallel", 20, 11, () -> Stream.iterate(0, i -> i + 1).limit(2_000_000).parallel().mapToLong(i -> (long) i * i).sum());
        measure("LongStream.range parallel", 20, 11, () -> LongStream.range(0, 2_000_000).parallel().map(i -> i * i).sum());
        System.out.println("sink=" + (sink != 0));
    }
}

Previous: Lecture 3: collections performance · Next: Lecture 5: virtual threads and concurrency

FAQ

Is parallelStream faster than a for loop in Java?

Only when there is enough work per call. Summing squares over an int[10,000,000] took 27.2 ms with a for loop and 9.0 ms with IntStream.parallel() on an 8-core M1 Pro. The same work on a 1,000-element list took 0.67 ms per 1,000 calls with a loop and 6.57 ms with parallelStream, about 10 times slower.

Why does Collectors.toMap throw IllegalStateException: Duplicate key?

toMap with two arguments assumes every key is unique. When a second element maps to an existing key it throws, and the message shows the key and both values, for example 'Duplicate key ramen (attempted merging values 980 and 850)'. Pass a third argument, a merge function such as Integer::max, or use groupingBy.

What is the difference between Stream.toList() and collect(Collectors.toList())?

Stream.toList() (Java 16) returns an unmodifiable list; calling add on it throws UnsupportedOperationException. collect(Collectors.toList()) returned a java.util.ArrayList on JDK 26, which you can modify, although the Javadoc does not promise that type. Stream.toList() accepts null elements; Collectors.toUnmodifiableList() does not.

  • #Java
  • #streams
  • #lambda
  • #Optional
  • #gatherers
  • #JDK 26
  • #intermediate

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑