tech, developers, and the code underneath

issue 113· review·

Virtual threads, two years on

Java's concurrency change looked incremental and turned out to reshape how services get written. A field report.

Virtual threads went final in Java 21 and are now running in production across a large number of services. Enough time has passed to say something more useful than "it is fast."

what they are#

A virtual thread is a thread managed by the JVM rather than the operating system. Creating one costs on the order of a few hundred bytes rather than a megabyte of stack. Blocking one parks it and frees the underlying carrier thread rather than blocking an OS thread.

The consequence: you can have millions of them, and blocking is no longer expensive.

java
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (var request : requests) {
        executor.submit(() -> handle(request));   // one thread per request, fine
    }
}

why this mattered more than it looked#

The Java ecosystem spent a decade building around the assumption that threads are expensive. That assumption produced:

  • Thread pools everywhere, with sizing that nobody understood.
  • Reactive programming frameworks, with their entirely separate ecosystem of types, operators, and debugging misery.
  • CompletableFuture chains that are unreadable after three composed steps.
  • Stack traces that told you nothing because the actual work happened on a different thread than the code you wrote.

All of that complexity existed to avoid blocking. Virtual threads make blocking cheap, which removes the reason for all of it.

The single biggest practical win is debugging. A virtual thread's stack trace is the actual call stack of the actual logical operation. Compare that to a reactive pipeline, where the stack trace is the scheduler and the real causal chain is reconstructed by squinting at operator names.

Teams that migrated report this as the thing they did not expect and would not give up.

the pitfalls that are real#

Pinning. A virtual thread inside a synchronized block cannot unmount from its carrier thread — it pins it. If that code then blocks, you have consumed an OS thread for the duration, and with a small carrier pool you can deadlock.

The fix is ReentrantLock instead of synchronized. Later JDK releases reduced the pinning cases substantially; library code you do not control can still do it.

Detect it:

-Djdk.tracePinnedThreads=full

Run that in a load test before you go to production. Every migration finds something.

ThreadLocal at scale. A ThreadLocal with a million threads is a million copies. Libraries that cache expensive objects per-thread — some serializers, some date formatters, some connection helpers — become a memory problem.

Scoped values are the intended replacement and are the right answer for request-scoped context.

Unbounded concurrency. Thread pools were an accidental rate limiter. Remove them and you can now issue ten thousand concurrent requests to a downstream service that handles two hundred. You have moved the failure from your service to theirs, which is worse.

You still need bounded concurrency. It just belongs at the resource — a semaphore around the downstream call — rather than as a global thread pool.

Connection pools. Your database connection pool is sized for a thread-pool world. It is now the bottleneck, and the right size is a different calculation. This surprises people.

the migration advice#

Do not rewrite anything. Virtual threads work with the code you have. Switch the executor, run your load tests, look for pinning, size your connection pools, done.

Do not migrate reactive code that works. A well-functioning reactive service should be left alone. The migration cost is real and the benefit is maintainability, which is a slow-compounding return.

Do migrate new services. Writing a new service with virtual threads and plain blocking code is dramatically simpler than the reactive equivalent, and simpler code is the whole point.

the broader lesson#

An enormous amount of accumulated architectural complexity existed to work around one runtime limitation. Removing the limitation made the complexity obsolete overnight — but only for code written after, because nobody rewrites working systems.

That is worth remembering the next time you build elaborate machinery around a platform constraint. Ask how likely the constraint is to persist, and whether the machinery will outlive it.

Usually it will, sitting in your codebase, load-bearing and pointless.

Dom, January 29, 2026

get README in your inbox

One dispatch, no noise. Tech and developer news, plus the occasional long piece on the craft.

subscribe →