On This Page
return batch().stream().map(Lab::checkSize).toList();
That's the whole line. One .map(), one method reference, one toList(). And this is what it costs you when checkSize throws:
Exception in thread "main" java.lang.IllegalStateException: too big: b.zip
at Lab.checkSize(Lab.java:9)
at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
at java.base/java.util.AbstractList$RandomAccessSpliterator.forEachRemaining(AbstractList.java:722)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:575)
at java.base/java.util.stream.AbstractPipeline.evaluateToArrayNode(AbstractPipeline.java:260)
at java.base/java.util.stream.ReferencePipeline.toArray(ReferencePipeline.java:616)
at java.base/java.util.stream.ReferencePipeline.toArray(ReferencePipeline.java:622)
at java.base/java.util.stream.ReferencePipeline.toList(ReferencePipeline.java:627)
at Lab.mref(Lab.java:28)
at Lab.main(Lab.java:54)
Thirteen lines, and three of them are mine. If you went looking for a stream stack trace java guide because you're staring at something like this, you're in the right place. Everything here ran on OpenJDK 21.0.12.1 (Ubuntu build, Linux, plain java from a terminal), and the JDK frames are version-specific, so your line numbers inside ReferencePipeline.java will differ on other builds. The shape won't. If frame anatomy itself is new to you, read how to read a Java stack trace first; this post assumes you know what at java.base/... means.
Nine frames of plumbing, three of yours
My first reaction was to open the third line. ReferencePipeline$3$1.accept is the first JDK frame after my code, and when you've got an exception passing through a library, the library is the obvious suspect. I opened ReferencePipeline.java at line 197 and read it for a while. Nothing was wrong with it, because nothing ever is. It's the stream machinery calling your function and not catching what comes back.
Read it from both ends
Top frame: Lab.checkSize(Lab.java:9). That's where the exception was created, and it's yours. Bottom frames: Lab.mref(Lab.java:28) and Lab.main(...). That's the line that built and ran the pipeline. Everything in between is the stream library doing its job, and you can skip all of it on the first read.
The middle isn't random though. It tells you which terminal operation ran the pipeline (toList here), and that matters more than people expect, because a stream does nothing until a terminal operation pulls on it. More on that below.
Break a pipeline on purpose
Pinned: OpenJDK 21.0.12.1, no libraries. Keep the blank lines; the line numbers in every trace depend on them. Compile with -g so you get line numbers (the Unknown Source post covers what happens otherwise, though plain javac defaults already include them).
Lab.java, an upload validator that rejects anything over 1,000 bytes:
import java.util.*;
import java.util.stream.*;
public class Lab {
record Upload(String name, long bytes) {}
static long checkSize(Upload u) {
if (u.bytes() > 1000) {
throw new IllegalStateException("too big: " + u.name());
}
return u.bytes();
}
static List<Upload> batch() {
return List.of(new Upload("a.png", 10), new Upload("b.zip", 5000), new Upload("c.txt", 7));
}
static List<Long> inline() {
return batch().stream()
.map(u -> {
if (u.bytes() > 1000) throw new IllegalStateException("too big: " + u.name());
return u.bytes();
})
.collect(Collectors.toList());
}
static List<Long> mref() {
return batch().stream().map(Lab::checkSize).toList();
}
static Stream<Long> lazy() {
return batch().stream().map(Lab::checkSize); // nothing runs here
}
static long consume(Stream<Long> s) {
return s.mapToLong(Long::longValue).sum(); // fails here
}
static long parallel() {
return batch().parallelStream().mapToLong(Lab::checkSize).sum();
}
static List<Long> chain() {
return batch().stream()
.filter(u -> u.bytes() > 0)
.sorted(Comparator.comparing(Upload::name))
.map(Lab::checkSize)
.collect(Collectors.toList());
}
public static void main(String[] args) {
switch (args[0]) {
case "inline" -> inline();
case "mref" -> mref();
case "lazy" -> consume(lazy());
case "parallel" -> parallel();
case "chain" -> chain();
case "foreach" -> batch().forEach(u -> checkSize(u));
}
}
}
Steps:
javac -g Lab.javajava Lab inlineprints the lambda version (below).- Try
mref,lazy,chain,foreachand compare.
The six scenarios differ in one thing each. inline puts the check in a lambda, mref points at a named method, lazy builds the stream in one method and consumes it in another, chain adds filter and sorted, and foreach skips streams entirely as a control.
If your frame says ReferencePipeline$3$1 and mine says $3$1 too but the line numbers differ, you're on a different JDK build. Fine. If it says something completely different at the top, you probably left out -g or changed the throw line.
Stream stack trace in Java: every frame, bottom to top
Here's the lambda version, java Lab inline:
Exception in thread "main" java.lang.IllegalStateException: too big: b.zip
at Lab.lambda$inline$0(Lab.java:21)
at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
at java.base/java.util.AbstractList$RandomAccessSpliterator.forEachRemaining(AbstractList.java:722)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:921)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.base/java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:682)
at Lab.inline(Lab.java:24)
at Lab.main(Lab.java:53)
Now the markers. I'm reading bottom to top because that's the order things happened:
Lab.main(Lab.java:53)calledinline(). Yours.Lab.inline(Lab.java:24): the.collect(...)line. Note it's line 24, the terminal call, not line 19 where the pipeline starts. That's the first surprise, and I'll come back to it.ReferencePipeline.collect,AbstractPipeline.evaluate,ReduceOps$ReduceOp.evaluateSequential: the terminal operation kicking off. These three names tell you what kind of terminal op it was. AtoList()goes throughtoArrayinstead (see themreftrace), andsum()goes throughreduce.AbstractPipeline.wrapAndCopyInto,copyInto: the pipeline gets fused into one chain of sinks and the source starts pushing elements through.AbstractList$RandomAccessSpliterator.forEachRemaining: the source, walking my list. This frame names the data source. For anArrayListit saysArrayList$ArrayListSpliterator.ReferencePipeline$3$1.accept: one stage of the pipeline, amapin this case. Across all my runs,mapwas always$3andmapToLongwas$5. I haven't readReferencePipeline.javaclosely enough to claim that numbering is stable across JDK builds; treat it as what I saw.Lab.lambda$inline$0(Lab.java:21): my lambda. The synthetic name islambda$+ enclosing method + a class-wide counter. The counter bit is in the post on odd frame names; theforeachrun shows it again aslambda$main$2, because two other lambdas in the file got 0 and 1 first.
So the one frame to open is the Lab.lambda$inline$0 line, and the one to open second is Lab.inline(Lab.java:24), to see who asked for the stream to run.
The named-method version
mref is the trace at the top of this post. The only difference that matters: line one is Lab.checkSize(Lab.java:9) instead of Lab.lambda$inline$0(Lab.java:21). A method reference doesn't produce a lambda$ frame at all, because it points straight at your method. That's the real argument for them in stack-trace terms: the frame has the name you gave it.
Why a stream trace is shaped like a sandwich
Two layers of your code at the edges, the stream implementation in the middle, and a third thing that surprised me: only frames that are still on the stack show up. That sounds obvious until you add a filter.
java Lab chain, with a filter before a sorted and a map after it:
Exception in thread "main" java.lang.IllegalStateException: too big: b.zip
at Lab.checkSize(Lab.java:9)
at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
at java.base/java.util.stream.SortedOps$RefSortingSink.end(SortedOps.java:395)
at java.base/java.util.stream.Sink$ChainedReference.end(Sink.java:261)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:510)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:921)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.base/java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:682)
at Lab.chain(Lab.java:48)
at Lab.main(Lab.java:57)
Where's the filter? Gone. Where's the source spliterator? Gone. filter had already finished with every element by the time sorted released them. sorted is a barrier: it holds everything until the upstream is exhausted and then, in RefSortingSink.end, replays the sorted elements into the downstream stages. By then the upstream frames have all returned. So the stack contains the downstream map, the sorter, and nothing about where the data came from. If you see SortedOps$RefSortingSink.end in a trace, your failing element has already been through the whole upstream pipeline and been sorted.
And now the laziness one. lazy() builds a pipeline with a map(Lab::checkSize) and returns it. consume() calls sum() on it. This is java Lab lazy:
Exception in thread "main" java.lang.IllegalStateException: too big: b.zip
at Lab.checkSize(Lab.java:9)
at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
at java.base/java.util.AbstractList$RandomAccessSpliterator.forEachRemaining(AbstractList.java:722)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:921)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.base/java.util.stream.LongPipeline.reduce(LongPipeline.java:498)
at java.base/java.util.stream.LongPipeline.sum(LongPipeline.java:456)
at Lab.consume(Lab.java:36)
at Lab.main(Lab.java:55)
The trace says Lab.consume(Lab.java:36) and Lab.main. It never mentions Lab.lazy, the method that wrote the .map(Lab::checkSize). That method returned long before anything ran. If lazy() lived in another class, another team's code, you'd be reading a trace that names a method that never touched the bad value and omits the one that configured it. That's the real cost of laziness for debugging: the frame that tells you what failed and the frame that tells you who set it up aren't the same frame.
A control for comparison: java Lab foreach skips streams and calls List.forEach with a lambda.
Exception in thread "main" java.lang.IllegalStateException: too big: b.zip
at Lab.checkSize(Lab.java:9)
at Lab.lambda$main$2(Lab.java:58)
at java.base/java.lang.Iterable.forEach(Iterable.java:75)
at Lab.main(Lab.java:58)
Five lines. Iterable.forEach is the only library frame. If a trace like this feels easy, it's because there's no pipeline between you and the call.
The parallel trace that has no main in it
This one took me an evening. On a 2-core box, parallel streams usually ran the failing element on the main thread, so the trace looked ordinary, and I almost wrote "parallel streams keep your caller frames" in a draft. Then I raised the common pool size (-Djava.util.concurrent.ForkJoinPool.common.parallelism=4), used a bigger source, and ran it a dozen times. The output split in two.
Par2.java, 1,000 sizes, element 900 too large, custom exception:
import java.util.*;
import java.util.stream.*;
public class Par2 {
static class UploadRejected extends RuntimeException {
UploadRejected(String name) { super("too big: " + name); }
}
static long checkSize(long bytes, int i) {
if (bytes > 1000) throw new UploadRejected("f" + i);
return bytes;
}
public static void main(String[] args) {
long[] sizes = new long[1000];
Arrays.fill(sizes, 10); sizes[900] = 5000;
try {
System.out.println(IntStream.range(0, 1000).parallel().mapToLong(i -> checkSize(sizes[i], i)).sum());
} catch (Exception e) {
e.printStackTrace();
System.out.println("thread=" + Thread.currentThread().getName());
}
}
}
Run: javac -g Par2.java then java -Djava.util.concurrent.ForkJoinPool.common.parallelism=4 Par2. I ran it twelve times; six came out like this (the main thread helped complete the task, and you can see helpComplete and ForkJoinTask.invoke and Par2.main):
Par2$UploadRejected: too big: f900
at Par2.checkSize(Par2.java:9)
at Par2.lambda$main$0(Par2.java:16)
at java.base/java.util.stream.IntPipeline$5$1.accept(IntPipeline.java:269)
at java.base/java.util.stream.Streams$RangeIntSpliterator.forEachRemaining(Streams.java:104)
at java.base/java.util.Spliterator$OfInt.forEachRemaining(Spliterator.java:712)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.ReduceOps$ReduceTask.doLeaf(ReduceOps.java:960)
at java.base/java.util.stream.ReduceOps$ReduceTask.doLeaf(ReduceOps.java:934)
at java.base/java.util.stream.AbstractTask.compute(AbstractTask.java:327)
at java.base/java.util.concurrent.CountedCompleter.exec(CountedCompleter.java:754)
at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:387)
at java.base/java.util.concurrent.ForkJoinPool$WorkQueue.helpComplete(ForkJoinPool.java:1395)
at java.base/java.util.concurrent.ForkJoinPool.helpComplete(ForkJoinPool.java:2104)
at java.base/java.util.concurrent.ForkJoinTask.awaitDone(ForkJoinTask.java:420)
at java.base/java.util.concurrent.ForkJoinTask.invoke(ForkJoinTask.java:668)
at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateParallel(ReduceOps.java:927)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:233)
at java.base/java.util.stream.LongPipeline.reduce(LongPipeline.java:498)
at java.base/java.util.stream.LongPipeline.sum(LongPipeline.java:456)
at Par2.main(Par2.java:16)
thread=main
And six came out like this, same code, same input:
Par2$UploadRejected: too big: f900
at Par2.checkSize(Par2.java:9)
at Par2.lambda$main$0(Par2.java:16)
at java.base/java.util.stream.IntPipeline$5$1.accept(IntPipeline.java:269)
at java.base/java.util.stream.Streams$RangeIntSpliterator.forEachRemaining(Streams.java:104)
at java.base/java.util.Spliterator$OfInt.forEachRemaining(Spliterator.java:712)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.ReduceOps$ReduceTask.doLeaf(ReduceOps.java:960)
at java.base/java.util.stream.ReduceOps$ReduceTask.doLeaf(ReduceOps.java:934)
at java.base/java.util.stream.AbstractTask.compute(AbstractTask.java:327)
at java.base/java.util.concurrent.CountedCompleter.exec(CountedCompleter.java:754)
at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:387)
at java.base/java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(ForkJoinPool.java:1312)
at java.base/java.util.concurrent.ForkJoinPool.scan(ForkJoinPool.java:1843)
at java.base/java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1808)
at java.base/java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:188)
thread=main
Look at the bottom. ForkJoinWorkerThread.run. No Par2.main. This was caught by catch (Exception e) in main, and my code printed thread=main straight after, but the trace itself doesn't contain a single main-thread frame. The exception was created on a pool worker, and it travelled back to the caller as-is. Every frame in it belongs to the worker.
That's the "missing context" problem from the async post (forward link, not written yet). If the log line only has this, you know where the exception happened and nothing about who started the stream.
Then it got stranger. Make a copy of Par2.java called Par3.java (rename the class), and replace throw new UploadRejected("f" + i) with throw new IllegalStateException("too big: f" + i) on the same line so the line numbers hold. In five of ten runs I got this:
java.lang.IllegalStateException: java.lang.IllegalStateException: too big: f900
at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:62)
at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:502)
at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:486)
at java.base/java.util.concurrent.ForkJoinTask.getThrowableException(ForkJoinTask.java:540)
at java.base/java.util.concurrent.ForkJoinTask.reportException(ForkJoinTask.java:567)
at java.base/java.util.concurrent.ForkJoinTask.invoke(ForkJoinTask.java:670)
at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateParallel(ReduceOps.java:927)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:233)
at java.base/java.util.stream.LongPipeline.reduce(LongPipeline.java:498)
at java.base/java.util.stream.LongPipeline.sum(LongPipeline.java:456)
at Par3.main(Par3.java:16)
Caused by: java.lang.IllegalStateException: too big: f900
at Par3.checkSize(Par3.java:9)
at Par3.lambda$main$0(Par3.java:16)
at java.base/java.util.stream.IntPipeline$5$1.accept(IntPipeline.java:269)
at java.base/java.util.stream.Streams$RangeIntSpliterator.forEachRemaining(Streams.java:104)
at java.base/java.util.Spliterator$OfInt.forEachRemaining(Spliterator.java:712)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.ReduceOps$ReduceTask.doLeaf(ReduceOps.java:960)
at java.base/java.util.stream.ReduceOps$ReduceTask.doLeaf(ReduceOps.java:934)
at java.base/java.util.stream.AbstractTask.compute(AbstractTask.java:327)
at java.base/java.util.concurrent.CountedCompleter.exec(CountedCompleter.java:754)
at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:387)
at java.base/java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(ForkJoinPool.java:1312)
at java.base/java.util.concurrent.ForkJoinPool.scan(ForkJoinPool.java:1843)
at java.base/java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1808)
at java.base/java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:188)
thread=main
Two exceptions. The outer one is a new IllegalStateException, created in main's thread by ForkJoinTask.getThrowableException via reflection (Constructor.newInstance), with a message that's the inner exception's toString(): java.lang.IllegalStateException: too big: f900. The original, with the real frames, is under Caused by. The other five runs gave the first shape, no wrapper.
My reading of why UploadRejected never got wrapped: it only has a String constructor, so the framework presumably couldn't build a copy and handed back the original. I didn't read ForkJoinTask.java to confirm (the src.zip on my box is a dangling symlink), so that's inference from three observed outputs, not a quote from the source. The wrapped version is what I'd expect any exception type with a (Throwable) constructor to do.
Practical upshot: with parallel streams, the "Caused by" you want might be the second block, or there might not be one, depending on a race and on what constructors your exception type has. Read both ends before deciding which trace is the real one.
Make your pipeline fail in a readable way
Three changes that actually help, and what each costs.
Extract the body into a named method. map(Lab::checkSize) beats a ten-line lambda. The frame has a real name, the line number is a real line in a real method, and you can set a breakpoint on it. Cost: a method for each step. In my experience that's less of a cost than it sounds.
Add the element to the message. The trace tells you where; it can't tell you which upload. My lab put u.name() in the message and that's the only reason b.zip is visible. For a lambda you can't touch (someone else's), wrap it. Ctx.java:
import java.util.*;
import java.util.function.*;
import java.util.stream.*;
public class Ctx {
record Upload(String name, long bytes) {}
static long checkSize(Upload u) {
if (u.bytes() > 1000) {
throw new IllegalStateException("too big");
}
return u.bytes();
}
static <T, R> Function<T, R> named(Function<T, String> label, Function<T, R> f) {
return t -> {
try {
return f.apply(t);
} catch (RuntimeException e) {
throw new IllegalStateException("failed on " + label.apply(t), e);
}
};
}
public static void main(String[] args) {
List<Upload> b = List.of(new Upload("a.png", 10), new Upload("b.zip", 5000));
b.stream().map(named(Upload::name, Ctx::checkSize)).toList();
}
}
java Ctx (after javac -g Ctx.java):
Exception in thread "main" java.lang.IllegalStateException: failed on b.zip
at Ctx.lambda$named$0(Ctx.java:20)
at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
at java.base/java.util.AbstractList$RandomAccessSpliterator.forEachRemaining(AbstractList.java:722)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:575)
at java.base/java.util.stream.AbstractPipeline.evaluateToArrayNode(AbstractPipeline.java:260)
at java.base/java.util.stream.ReferencePipeline.toArray(ReferencePipeline.java:616)
at java.base/java.util.stream.ReferencePipeline.toArray(ReferencePipeline.java:622)
at java.base/java.util.stream.ReferencePipeline.toList(ReferencePipeline.java:627)
at Ctx.main(Ctx.java:27)
Caused by: java.lang.IllegalStateException: too big
at Ctx.checkSize(Ctx.java:10)
at Ctx.lambda$named$0(Ctx.java:18)
... 10 more
The outer exception carries failed on b.zip and the real cause hangs under Caused by. Note ... 10 more: ten frames are shared with the outer trace, same as always (see Caused by in Java stack traces for counting them). lambda$named$0 appears in both traces because it's one method: line 18 is the f.apply(t) call that the cause passed through, line 20 is the throw new that made the wrapper. Cost: an extra helper, an extra exception layer in every trace, and you must be sure you don't swallow checked exceptions by accident.
Don't hide the terminal operation. If a method returns a Stream, the trace points at whoever consumes it. Returning a List (call .toList() inside the method) moves the failure to where the pipeline was built. Cost: you lose laziness and pay memory for the intermediate list. Often fine. Not for a stream over a million rows.
What doesn't help: wrapping every stage in try/catch and logging e.getMessage(). You'll get a log line with no frames and a message that was never designed to be read alone. The missing trace post covers what you lose.
What survives in your logs, and on which JDK
What I ran: OpenJDK 21.0.12.1 only. I did not run JDK 8, 17 or 25, so I can't tell you what ReferencePipeline frame numbers look like there, and I can't tell you whether parallel-stream exception wrapping behaves the same. Stream.toList() exists from JDK 16, so on older JDKs you'd have written collect(Collectors.toList()) and seen the collect terminal frames instead. The java.base/ prefix on JDK frames is JDK 9+ format. I didn't run Logback or Log4j2 for this post, so I can't say how they render the thirteen-line trace above; the usual caution applies: configure your logger to keep the full trace and print the cause chain, because the wrapper-plus-Caused by shape from the parallel run is exactly what a "short trace" setting can cut off.
For alerting: group by exception class and the first frame in your package, not by the whole trace. The JDK frames in between change with the JDK build and with parallel timing, so a fingerprint that includes them will treat one bug as two.
Keep these
- A stream trace is your frame at the top, library plumbing in the middle, and your frame calling the terminal operation at the bottom. Open the top and the bottom; skip the middle.
- The bottom frame points at the terminal operation line, not the line where the pipeline begins.
- A lazy stream returned from a method fails in the consumer. The method that built it won't be in the trace.
sortedand friends hide the upstream stages: finished stages aren't on the stack.- With parallel streams the trace may end at
ForkJoinWorkerThread.runwith no caller frames, or arrive wrapped with the real trace underCaused by. Both happened with identical code. - Method references keep your frame name. Lambdas show up as
lambda$method$N.
People keep asking me these
How do I find my lambda in a Java stream stack trace? Look for the lambda$ frame with your class name (Lab.lambda$inline$0 here), or the named method if you used a method reference. The ReferencePipeline, Spliterator and AbstractPipeline frames around it are the stream library and almost never the bug. Verified on JDK 21.0.12.1.
Why does the stack trace point at the last line of my stream? Streams are lazy. Nothing runs until a terminal operation (collect, toList, sum, forEach), so the frame that called it is the line number you see, not the line where .map(...) was written. A multi-line pipeline reports the terminal call's line in my runs.
Why is there no main in my parallel stream stack trace? The exception was created on a ForkJoinPool worker thread. If it reaches the caller unwrapped, the trace only contains worker frames. In my runs the same code also sometimes arrived wrapped in a second exception with the worker trace under Caused by. See the parallel section above.
Do method references give better stream stack traces than lambdas? In my runs, yes: map(Lab::checkSize) put Lab.checkSize at the top, while the inline version put Lab.lambda$inline$0. Both pointed at the right line. The method reference just has a name you chose.