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));
}
ExecutorThread countUse it when
newFixedThreadPool(n)Exactly nSteady CPU-bound work
newCachedThreadPool()UnboundedAlmost never in a server
newVirtualThreadPerTaskExecutor()One virtual thread per taskBlocking I/O at high concurrency
newScheduledThreadPool(n)Exactly nPeriodic and delayed tasks
ForkJoinPool.commonPool()CPU count minus oneParallel 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.get blocks the calling thread and wraps the real failure in ExecutionException, 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.
  • exceptionally handles a failed stage; handle sees both the value and the error in one place.
  • thenCombine joins two independent results, which is clearer than nesting thenApply calls.
  • join throws an unchecked CompletionException and get a 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 synchronized block 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 ThreadLocal would 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.

Testing, logging and JVM tooling Generics, lambdas and the streams API

Last refreshed 2026-09-18.