The non-Async variant runs the callback on whatever thread completed the previous stage (or the caller's thread if it is already done); the *Async variant submits the callback to an executor — your own if you pass one, otherwise the ForkJoinPool.commonPool.
The three forms
Every chaining method comes in three flavours:
cf.thenApply(f); // runs on the completing thread (or caller)
cf.thenApplyAsync(f); // runs on ForkJoinPool.commonPool
cf.thenApplyAsync(f, myPool); // runs on the executor you supply
Who actually runs thenApply (no Async)
This is the trick. With thenApply, the callback executes on the thread that completed the upstream stage — typically the worker that ran supplyAsync. But if the upstream future is already complete when you attach the callback, it runs synchronously on the calling thread.
CompletableFuture<String> done = CompletableFuture.completedFuture("x");
done.thenApply(s -> {
// already complete -> runs on THIS (main) thread, inline
return s.toUpperCase();
});
So thenApply's execution thread is non-deterministic from the caller's point of view — it depends on timing. That is fine for cheap transforms, dangerous for blocking work.
Why you would force Async
Two reasons:
- Don't hijack the worker. A long or blocking callback on
thenApplyties up the upstream pool's thread (often the common pool), starving other tasks.thenApplyAsynchands the work to a dedicated pool. - Control the pool. Always pass your own executor for I/O. The default common pool is sized to
cores - 1and is shared JVM-wide with parallel streams and fork/join.
Executor io = Executors.newFixedThreadPool(32);
CompletableFuture.supplyAsync(this::callServiceA, io) // on io
.thenApply(this::cheapParse) // on io's worker (cheap, ok)
.thenApplyAsync(this::callServiceB, io) // explicitly back on io
.thenAcceptAsync(this::write, io);