On This Page
Exception in thread "pool-1-thread-1" java.lang.IllegalArgumentException: empty body for evt_1
at Lab.sign(Lab.java:8)
at Lab.handle(Lab.java:13)
at Lab.lambda$bare$0(Lab.java:18)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Nobody in this trace knows who asked for the work. That's the whole problem with async traces, and the rest of this post is about living with it.
I got this on OpenJDK 21.0.12.1 (Ubuntu build, Linux, plain java from a terminal). It's a webhook handler that signs incoming events on a thread pool, and the event had an empty body. If you landed here searching for a CompletableFuture stack trace explanation, the executor version above is the simplest shape of the same story, so we start there and work up to CompletionException. Frame anatomy itself is in how to read a Java stack trace; I'm assuming you know what at java.base/... means.
Whose thread is this, and who called it?
My first read was wrong in a boring way. I looked at Lab.handle on line 13, then Lab.lambda$bare$0 on line 18, and went hunting for the caller of handle in the trace. There isn't one. The bottom of this trace isn't main. It's Thread.run, because the thread that threw is pool-1-thread-1, and its entire life is Thread.run → Worker.run → runWorker → your task. The thread that called execute() is main, it's somewhere else entirely, and nothing in this stack refers to it.
So the short read is: the top three frames are yours, the bottom three are the pool, and the link between "who submitted this" and "who runs this" doesn't exist in the data. A stack is per thread. It was never going to have the submitter in it. (If you're on virtual threads, the shape shifts again; that's its own post.)
Throw it from a pool on purpose
Pin the JDK. I used OpenJDK 21.0.12.1; a pinned eclipse-temurin:21 image gives you the same shape. Save this as Lab.java and keep the blank lines, because every line number in this post depends on them.
import java.util.concurrent.*;
import java.util.function.*;
public class Lab {
record Webhook(String id, String body) {}
static String sign(Webhook w) {
if (w.body().isEmpty()) throw new IllegalArgumentException("empty body for " + w.id());
return w.id() + ":" + w.body().length();
}
static void handle(Webhook w) {
System.out.println("signed " + sign(w));
}
static void bare() throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(1);
pool.execute(() -> handle(new Webhook("evt_1", "")));
pool.shutdown();
pool.awaitTermination(2, TimeUnit.SECONDS);
}
static void submitSwallowed() throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(1);
pool.submit(() -> handle(new Webhook("evt_2", "")));
pool.shutdown();
pool.awaitTermination(2, TimeUnit.SECONDS);
System.out.println("done, nothing printed above");
}
static void submitGet() throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(1);
Future<?> f = pool.submit(() -> handle(new Webhook("evt_3", "")));
try {
f.get();
} finally {
pool.shutdown();
}
}
static void cfJoin() {
CompletableFuture.supplyAsync(() -> sign(new Webhook("evt_4", ""))).join();
}
static void cfChain() {
CompletableFuture<String> f = CompletableFuture
.supplyAsync(() -> sign(new Webhook("evt_5", "")))
.thenApply(String::toUpperCase)
.exceptionally(ex -> {
System.out.println("handler got: " + ex.getClass().getName());
throw new IllegalStateException("retry exhausted", ex);
});
f.join();
}
static void cfFailingStage() {
CompletableFuture.supplyAsync(() -> sign(new Webhook("evt_6", "")))
.exceptionally(ex -> { System.out.println("same stage got: " + ex.getClass().getName()); return "x"; })
.join();
CompletableFuture.supplyAsync(() -> sign(new Webhook("evt_6", "")))
.thenApply(s -> s)
.exceptionally(ex -> { System.out.println("next stage got: " + ex.getClass().getName()); return "x"; })
.join();
}
static Runnable tagged(Runnable r) {
Exception site = new Exception("submitted from " + Thread.currentThread().getName());
return () -> {
try { r.run(); }
catch (RuntimeException e) { e.addSuppressed(site); throw e; }
};
}
static void tag() throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(1);
Future<?> f = pool.submit(tagged(() -> handle(new Webhook("evt_7", ""))));
try { f.get(); } catch (ExecutionException e) { e.getCause().printStackTrace(); }
finally { pool.shutdown(); }
}
static void handler() throws Exception {
ThreadFactory tf = r -> {
Thread t = new Thread(r, "webhook-worker-1");
t.setUncaughtExceptionHandler((th, ex) ->
System.out.println("handler on " + th.getName() + ": " + ex));
return t;
};
ExecutorService pool = Executors.newFixedThreadPool(1, tf);
pool.execute(() -> handle(new Webhook("evt_8", "")));
Future<?> f = pool.submit(() -> handle(new Webhook("evt_8", "")));
pool.shutdown();
pool.awaitTermination(2, TimeUnit.SECONDS);
System.out.println("submit() reached handler? no; future isDone=" + f.isDone());
}
public static void main(String[] args) throws Exception {
switch (args[0]) {
case "bare" -> bare();
case "swallow" -> submitSwallowed();
case "get" -> submitGet();
case "join" -> cfJoin();
case "chain" -> cfChain();
case "stage" -> cfFailingStage();
case "tag" -> tag();
case "handler" -> handler();
}
}
}
Compile with debug info and run one scenario at a time. -g is the default for line numbers anyway, but I like being explicit when I'm about to quote line numbers at you.
javac -g Lab.java
java Lab bare # execute(): worker prints it, main never sees it
java Lab swallow # submit(): nothing printed at all
java Lab get # submit() + Future.get(): ExecutionException
java Lab join # supplyAsync + join(): CompletionException
java Lab chain # thenApply + exceptionally that rethrows
java Lab stage # what exceptionally() actually receives
java Lab tag # submit-site captured as Suppressed
java Lab handler # UncaughtExceptionHandler on the thread factory
Variations worth running: java -Djava.util.concurrent.ForkJoinPool.common.parallelism=3 Lab join, and the same lab on a bigger machine. More on why in a minute. If your join trace ends at ForkJoinWorkerThread.run and mine ends at Thread.run, you aren't wrong, we have different core counts.
Four ways to get a CompletableFuture stack trace (and its executor cousins)
execute(), which printed the trace at the top of this post. The pool thread has no handler of its own, so the default one prints Exception in thread "pool-1-thread-1" to stderr. The task is dead and so is that worker thread. I didn't test what the pool does next, but ThreadPoolExecutor is built to replace a worker that dies. You see one trace and your app keeps running, which is exactly why people miss it.
submit() with nobody calling get(). Same code, one word different, and the output was:
done, nothing printed above
That's the whole output. The exception is sitting inside the FutureTask, the pool thread is fine, no log line exists. I'd call this the most expensive async bug in Java, because it looks like success.
submit() plus Future.get(). Now you get something to read:
Exception in thread "main" java.util.concurrent.ExecutionException: java.lang.IllegalArgumentException: empty body for evt_3
at java.base/java.util.concurrent.FutureTask.report(FutureTask.java:122)
at java.base/java.util.concurrent.FutureTask.get(FutureTask.java:191)
at Lab.submitGet(Lab.java:35)
at Lab.main(Lab.java:100)
Caused by: java.lang.IllegalArgumentException: empty body for evt_3
at Lab.sign(Lab.java:8)
at Lab.handle(Lab.java:13)
at Lab.lambda$submitGet$2(Lab.java:33)
at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:572)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Two stacks in one trace, and they belong to two different threads. The outer block (submitGet line 35, main line 100) is the caller who asked for the result. The Caused by block is the worker who produced the failure. Line 33, the lambda, is where the task was submitted from; line 35 is where the caller blocked on it. Different lines, same method, easy to blur together.
supplyAsync(...).join(). This is the one that surprised me:
Exception in thread "main" java.util.concurrent.CompletionException: java.lang.IllegalArgumentException: empty body for evt_4
at java.base/java.util.concurrent.CompletableFuture.encodeThrowable(CompletableFuture.java:315)
at java.base/java.util.concurrent.CompletableFuture.completeThrowable(CompletableFuture.java:320)
at java.base/java.util.concurrent.CompletableFuture$AsyncSupply.run(CompletableFuture.java:1770)
at java.base/java.lang.Thread.run(Thread.java:1583)
Caused by: java.lang.IllegalArgumentException: empty body for evt_4
at Lab.sign(Lab.java:8)
at Lab.lambda$cfJoin$3(Lab.java:42)
at java.base/java.util.concurrent.CompletableFuture$AsyncSupply.run(CompletableFuture.java:1768)
... 1 more
The header says Exception in thread "main". The frames under it say Thread.run. There's no Lab.cfJoin and no Lab.main anywhere. The CompletionException was created on the worker thread, inside encodeThrowable, so its stack is the worker's stack, and join() rethrew that same object on main. Remember that frames are captured when a Throwable is constructed, not when it's thrown (the hub post has the demo). It's the same rule, showing up in a place where it looks like a bug.
The ... 1 more is the usual elision from the Caused by post, and the frame it hides is Thread.run, not AsyncSupply.run. The printer compares the two stacks from the bottom up and stops at the first frame that differs. AsyncSupply.run shows up in both blocks, but at line 1768 ("called your supplier") in one and line 1770 ("caught what it threw") in the other, so the comparison stops there and only Thread.run is shared. I first read that as AsyncSupply.run being the elided frame, and I was wrong. Nothing is lost either way. (With a bigger common pool the same trace ends ... 6 more, because six frames line up before they differ.)
Every frame, annotated
Taking the get trace, because it has both halves:
Exception in thread "main" ...ExecutionException: ...IllegalArgumentException: empty body for evt_3 (1)
at ...FutureTask.report(FutureTask.java:122) (2)
at ...FutureTask.get(FutureTask.java:191) (3)
at Lab.submitGet(Lab.java:35) (4)
at Lab.main(Lab.java:100) (5)
Caused by: ...IllegalArgumentException: empty body for evt_3 (6)
at Lab.sign(Lab.java:8) (7)
at Lab.lambda$submitGet$2(Lab.java:33) (8)
at ...Executors$RunnableAdapter.call(Executors.java:572) (9)
at ...FutureTask.run(FutureTask.java:317) (10)
at ...ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642) (11)
- The thread named here is the caller's.
ExecutionExceptionis a checked wrapper; its message is the cause'stoString(), which is why the same text appears twice. reportis where the stored exception is wrapped. This frame is JDK plumbing and you can skip it.getis the blocking call. Interesting only because it tells you the caller waited.- Your code, the consumer side. This is not where the bug is.
- Bottom of the caller's stack,
main. - Switch of threads. Everything below this line ran on
pool-1-thread-1, and the trace doesn't say so. The thread name isn't printed in aCaused byblock. You have to infer it from the bottom frames. - The real culprit:
sign, line 8, an empty-body check. - The task body.
lambda$submitGet$2is a synthetic name; the2is a class-wide counter, not a count of anything in this method. RunnableAdapterissubmit(Runnable)turning your lambda into aCallable. Skip.FutureTask.runcatches your exception and stores it. This frame is whysubmit()swallows things.- The worker loop. Everything under it is the pool.
Open frame 7 first, frame 8 second, and don't open anything between 1 and 5 until you care why someone was waiting.
Why the submitter's stack is gone
The first half of this section was a dead end for me. I assumed the JVM or the executor must be stashing the submitting thread's stack somewhere I could ask for, a "task origin" the way a debugger shows async stack traces. As far as I can tell it doesn't: nothing in ThreadPoolExecutor or FutureTask that I looked at in the Javadoc records who called submit. IDE debuggers can show an "async stack trace", and as I understand it that's the debugger instrumenting submit/execute to capture one on purpose. Tooling, not the runtime, and it won't be in your production logs.
What the runtime does have is the rule from the hub post: Throwable.fillInStackTrace() runs in the constructor, and records the current thread's frames at that moment. So the async story is a story about which constructor ran on which thread:
main thread pool worker thread
----------- ------------------
submit(task) ------ hands off ----> run task
get()/join() task throws X (X's frames: worker's)
^ CF wraps in CompletionException (frames: worker's)
| FutureTask stores X
ExecutionException created HERE <-- report(): new ExecutionException(X) (frames: caller's)
Two details from CompletableFuture that I checked against the JDK 21 source (read through a summarising fetch of CompletableFuture.java at jdk-21+35, so treat the second one as paraphrase, not quote). First, encodeThrowable wraps anything that isn't already a CompletionException, which is why stages downstream see the wrapper. Second, the async default executor is ForkJoinPool.commonPool() only when the common pool's parallelism is above 1; otherwise CompletableFuture falls back to a thread-per-task executor.
That second one explains the trace I got. My box has two cores, so common-pool parallelism is 1 and supplyAsync started a fresh Thread per task, hence Thread.run at the bottom. With -Djava.util.concurrent.ForkJoinPool.common.parallelism=3 the same lab ends like this:
at java.base/java.util.concurrent.CompletableFuture$AsyncSupply.run(CompletableFuture.java:1770)
at java.base/java.util.concurrent.CompletableFuture$AsyncSupply.exec(CompletableFuture.java:1760)
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)
(That's the CompletionException block only; the Caused by block below it ends ... 6 more.) Your local laptop with eight cores and a one-vCPU container in Kubernetes will print different bottoms for the same code. Worth knowing before you diff traces between environments.
The claims I keep hearing, checked
"The failing stage sees the raw exception; downstream stages see CompletionException." Half true. Lab stage ran supplyAsync(...).exceptionally(ex -> ...) and the handler on the same stage as the supplier printed java.util.concurrent.CompletionException, not IllegalArgumentException. A future completed by hand (a separate throwaway probe, not in Lab.java) with completeExceptionally(new IllegalArgumentException(...)) does hand exceptionally the raw exception, and a stage after it gets the wrapper. So it depends on whether the exception came out of a supplier (wrapped by AsyncSupply) or was injected directly. Check ex.getCause() if you need the real type, or unwrap with a loop.
"join() throws the same exception as get()." No. get() throws checked ExecutionException, join() throws unchecked CompletionException. In both, the interesting trace is under Caused by.
"Rethrowing in exceptionally gives me a clean trace." My chain lab rethrew new IllegalStateException("retry exhausted", ex) from exceptionally and got a three-deep chain: CompletionException → IllegalStateException → CompletionException → IllegalArgumentException. The root cause is at the bottom, three Caused by blocks down, and the outer CompletionException carries frames from exceptionally(Lab.java:49), the wiring site, not the failure site. Here the callback ran on main at wiring time; with different timing it can run on the worker, so don't build anything on which one you get. If your alerting groups on the top frame, every error in that chain will look identical.
"submit() and execute() are interchangeable." Not for failures. execute() hands the exception to the thread's uncaught handler (default: stderr). submit() hands it to the Future. If nobody reads the Future, nobody ever sees it. The handler lab confirmed this; the handler fired for the execute() task only, and the submit() future only reported isDone=true.
Getting the submit site back into the trace
You can't recover a submitter's stack after the fact, but you can capture it at the moment of submission, which costs one new Exception(). My tagged() wrapper constructs an exception on the caller's thread and attaches it as a suppressed exception when the task fails:
java.lang.IllegalArgumentException: empty body for evt_7
at Lab.sign(Lab.java:8)
at Lab.handle(Lab.java:13)
at Lab.lambda$tag$12(Lab.java:76)
at Lab.lambda$tagged$11(Lab.java:69)
at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:572)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Suppressed: java.lang.Exception: submitted from main
at Lab.tagged(Lab.java:67)
at Lab.tag(Lab.java:76)
at Lab.main(Lab.java:104)
Now the trace says who submitted: Lab.tag line 76, called from main line 104. I chose addSuppressed over initCause because the original exception may already have a cause, and I didn't want to overwrite or reorder the chain (how Suppressed prints covers why it lands after the frames). Costs, all real: one extra Throwable allocation and a stack walk on every submit, which is not free on a hot path (measure before you wrap all of it); a larger log line on every failure; and you must wrap every submission point, because the one you forget is the one that fails at 3 a.m. If submit rate is very high, capture the exception only when a flag or sampling says so.
Other things that help, none of them clever: name your threads (ThreadFactory or Thread.ofPlatform().name(...)) so the first line of Exception in thread "..." tells you which pool; put the event id in the exception message, as I did with evt_N here, because the stack won't tell you which input broke; and use a custom exception with real context rather than relying on the trace to carry it.
For the CompletableFuture error-handling API itself (handle, whenComplete, timeouts), I'm leaving that to the dedicated CompletableFuture exceptions post; this one is only about reading what comes out.
What your logs see, and on which JDK
Three production habits that follow from the labs.
Install a default handler. Thread.setDefaultUncaughtExceptionHandler or a per-factory handler (my handler lab) gets execute() failures into your logger rather than raw stderr. It will never see submit() failures; for those, check the Future, or use a wrapper that logs and rethrows. Log once, at the boundary, so you don't get the same chain three times.
Log the whole throwable, never getMessage(). For CompletionException and ExecutionException the message is the cause's toString(), so message-only logging at least keeps the type name, but it loses every frame. I'll cover SLF4J specifics in the logging post.
When you group errors by top frame, unwrap first. CompletionException's top frames are encodeThrowable and completeThrowable on every failure in every pipeline, so a fingerprint taken from them will lump unrelated bugs together. Unwrap to the root cause and fingerprint that.
Version safety: everything above was run on OpenJDK 21.0.12.1 only. I did not run JDK 8, 17 or 25, so line numbers inside CompletableFuture.java, FutureTask.java and ThreadPoolExecutor.java are 21's, and the exact helper frames (encodeThrowable, AsyncSupply.run) are the kind of thing that moves between releases. The structure (separate stacks for caller and worker, wrapper created on the worker, submit() swallowing) is old and I'd expect it on 8 and 17, but I haven't seen it there. Logback and Log4j2 were not part of this lab. Getting to the bottom of this took me most of two evenings, a good part of it chasing an "origin stack" the JDK doesn't keep.
The short list
- A stack belongs to one thread. The worker's trace will never contain the submitter.
get()andjoin()give you two stacks in one trace: the waiter's on top, the failing worker underCaused by.- A
CompletionExceptioncan printException in thread "main"over frames from a worker, because frames are captured at construction. submit()with noget()loses the exception.execute()prints it from the worker.- To get the submit site back, capture a
new Exception()at submission and attach it asSuppressed. It costs an allocation per submit.
Questions that come up
Why does my CompletableFuture stack trace show Thread.run or ForkJoinWorkerThread.run and not my method? Because the exception was built on the worker thread. The caller's frames aren't in it. On JDK 21 the bottom frame depends on common-pool parallelism: above 1 you get ForkJoinWorkerThread.run, at 1 (a one- or two-core container) Thread.run.
What's the difference between CompletionException and ExecutionException? ExecutionException is checked and comes from Future.get() / CompletableFuture.get(). CompletionException is unchecked and comes from join() and from dependent stages. In both, your real exception is getCause().
How do I find the caller of a task that failed in a thread pool? The runtime doesn't keep it. Capture a new Exception("submitted from ...") when you submit and attach it with addSuppressed when the task fails, or put a correlation id in the message and the MDC.
Why did my submit() task fail silently? The exception is held in the Future until someone calls get(). If nobody does, nothing prints. Use execute() with an uncaught handler, or always check the Future.