Concurrency, virtual threads and structured concurrency
Threads and executors, CompletableFuture, virtual threads for I/O, structured concurrency scopes, and the races that still bite.
Threads and executors
Thread platform = Thread.ofPlatform().name("worker-1").start(() -> doWork());
try (var pool = Executors.newFixedThreadPool(8)) {
Future<Integer> future = pool.submit(() -> compute());
int result = future.get(2, TimeUnit.SECONDS); // blocks, wraps failures
pool.shutdown();
}
try (var virtual = Executors.newVirtualThreadPerTaskExecutor()) {
for (Order order : orders) virtual.submit(() -> enrich(order));
}| Executor | Thread count | Use it when |
|---|---|---|
newFixedThreadPool(n) | Exactly n | Steady CPU-bound work |
newCachedThreadPool() | Unbounded | Almost never in a server |
newVirtualThreadPerTaskExecutor() | One virtual thread per task | Blocking I/O at high concurrency |
newScheduledThreadPool(n) | Exactly n | Periodic and delayed tasks |
ForkJoinPool.commonPool() | CPU count minus one | Parallel streams and recursive splits |
- An unbounded pool with an unbounded queue is a memory leak that only shows up under load; bound the queue and choose a rejection policy deliberately.
- Executors are
AutoCloseable, so try-with-resources shuts them down. Forgetting this keeps the JVM alive and hides shutdown bugs. Future.getblocks the calling thread and wraps the real failure inExecutionException, which is why the cause must always be unwrapped and logged.- The common ForkJoinPool is shared with parallel streams and other subsystems — never submit blocking work to it.
- Shared mutable state needs synchronization, an atomic type or immutability. Anything else is a race waiting for a busy day.
CompletableFuture
var io = Executors.newVirtualThreadPerTaskExecutor();
CompletableFuture<Quote> quoted = CompletableFuture
.supplyAsync(() -> fetchQuote(sku), io)
.thenApply(Quote::withTax)
.exceptionally(err -> Quote.failed(sku));
CompletableFuture<Void> all = CompletableFuture.allOf(a, b, c);
CompletableFuture<Object> fastest = CompletableFuture.anyOf(a, b);- Pass an explicit executor. Without one, the stage chain runs on the common pool and a blocking call there stalls unrelated work.
exceptionallyhandles a failed stage;handlesees both the value and the error in one place.thenCombinejoins two independent results, which is clearer than nestingthenApplycalls.jointhrows an uncheckedCompletionExceptionandgeta checked one — both wrap the original cause, so unwrap before logging.- A stage that never completes because an upstream future was never finished is the classic CompletableFuture leak; keep the graph small and finite.
Virtual threads and structured concurrency
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Supplier<User> user = scope.fork(() -> loadUser(id));
Supplier<Orders> orders = scope.fork(() -> loadOrders(id));
scope.join(); // wait for every forked task
scope.throwIfFailed(); // propagate the first failure
return new Profile(user.get(), orders.get());
}- A virtual thread is cheap to create but not free to run: millions of them consume memory, and they still execute on a limited set of carrier threads.
- Never pool virtual threads. Create one per task; the JDK handles the scheduling.
- They help I/O-bound work, where threads mostly wait. For CPU-bound work the number of cores is the real limit and platform threads are just as good.
- A blocking call inside a
synchronizedblock or a native frame can pin a carrier thread, which quietly removes the scalability the virtual threads were meant to provide. - Structured concurrency makes cancellation and error propagation part of the code shape: if one forked task fails, the scope cancels the rest instead of leaving them running.
- Scoped values carry immutable request context down the call chain where a
ThreadLocalwould leak as soon as the work moves to another thread.
⚠️
Do not pool virtual threads, and do not bound them with a fixed pool to "protect" a downstream service. If a dependency needs protection, use a semaphore or a rate limiter around it — that limit is about the dependency, not about threads.
FAQ
Is virtual threads a replacement for reactive programming?
For most request-per-task services, yes: blocking code on virtual threads is far easier to read and debug than a reactive chain. Reactive still earns its place for streaming, backpressure across many events, and pipelines where nothing blocks.
How do I find a race condition?
Reproduce under load, then reason about which fields are shared and which writes are unsynchronized. Immutability and message passing remove whole classes of races, and stress tests with several threads are the practical way to confirm a fix.
Related
Testing, logging and JVM tooling Generics, lambdas and the streams API
Last refreshed 2026-09-18.