How virtual threads work in Java 21, what they change for I/O-bound server applications, how to enable them in Spring Boot, and where the remaining limitations are.
Java 21 finalised virtual threads as a production feature after years of preview. The promise is simple: thread-per-request concurrency without the scalability ceiling imposed by OS threads. For server-side Java applications dominated by I/O — database queries, HTTP calls, stream reads — virtual threads make the thread-per-request model viable at the scale previously only achievable with reactive programming.
Platform (OS) threads are expensive. A typical server can have a few thousand before scheduling overhead and memory pressure hurt throughput. The default stack size is 512KB–1MB per thread. Under the blocking I/O model used by most Spring Boot applications, each thread blocks while waiting for a database response or HTTP reply — using a full OS thread to sit idle.
This is why reactive programming (WebFlux, Reactor) exists: use a small fixed thread pool, never block, and multiply throughput. The cost is mandatory asynchronous code — callbacks, Mono/Flux chains, and a completely different programming model.
Virtual threads change the calculus: they are lightweight JVM-managed threads that mount and unmount from carrier (OS) threads when they block. A million virtual threads can be active simultaneously with minimal memory (hundreds of bytes per virtual thread). Blocking calls park the virtual thread — not the carrier thread. The carrier is free to run other virtual threads.
// Named factory
Thread vt = Thread.ofVirtual().name("market-reader").start(() -> {
processMarketStream();
});
// Executor
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> betfairClient.getMarketBook(marketId));
// StructuredTaskScope (Java 21+)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var book = scope.fork(() -> fetchMarketBook(id));
var funds = scope.fork(() -> fetchAccountFunds());
scope.join();
scope.throwIfFailed();
processOrder(book.get(), funds.get());
}
Spring Boot 3.2 supports virtual threads with a single property:
spring:
threads:
virtual:
enabled: true
This switches Tomcat, Spring MVC, @Async, and @Scheduled to use virtual thread executors. Every incoming HTTP request gets its own virtual thread. Blocking I/O in request handlers no longer consumes a platform thread.
A traditional Spring Boot application with a Tomcat thread pool of 200 can handle 200 concurrent blocking requests. With virtual threads, the ceiling is effectively the number of available file descriptors, database connections, or downstream API rate limits — not thread count.
For a service making a blocking database query on each request (100ms each), the 200-thread pool saturates at ~2000 requests/second. With virtual threads, throughput scales to available CPU and I/O capacity.
Virtual threads park during blocking I/O, but there are operations that pin the virtual thread to its carrier — holding the carrier thread hostage:
synchronized blocks and methods: If a virtual thread is blocked inside synchronized, it pins the carrier. Replace with ReentrantLock for virtual-thread-friendly locking.Check for pinning with the JVM flag:
-Djdk.tracePinnedThreads=full
This logs stack traces when pinning occurs. Common culprits: JDBC drivers with synchronized internals (many older drivers), some Hibernate internals, Object.wait().
Connection pools (HikariCP, Redis clients) are still necessary — the number of database connections is limited by the database, not by Java threads. With virtual threads, the pool blocks the virtual thread while waiting for a connection, which is fine — the carrier thread is free. But pool sizing still matters: if you have 1000 virtual threads waiting for one of 10 database connections, throughput is still constrained by 10 connections.
Virtual threads restore the thread-per-request model to competitiveness with reactive. The choice is no longer forced:
| WebFlux (Reactor) | Virtual Threads (MVC) | |
|---|---|---|
| Programming model | Reactive (Mono/Flux) | Blocking, familiar |
| Learning curve | High | Low |
| Throughput (I/O bound) | High | Comparable |
| Throughput (CPU bound) | No advantage | No advantage |
| Library compatibility | Must use reactive libs | All blocking libs work |
For new services, virtual threads with Spring MVC is the pragmatic default. The codebase is simpler, debugging is easier, and throughput is competitive for I/O-bound workloads. WebFlux remains the right choice for streaming, backpressure, and truly reactive pipelines.
CPU-bound tasks — image processing, cryptography, numerical computation — are not improved by virtual threads. The limiting factor is CPU cores, not threads. For CPU-bound parallelism, use ForkJoinPool with RecursiveTask or parallel streams.
Virtual threads are the most significant performance-relevant feature added to Java in a decade. For most Spring Boot services, enabling them is a one-line configuration change that removes an entire category of scalability concern.
If you’re modernising a Spring Boot service to use Java 21 features and want a review, get in touch.