Virtual threads were the most anticipated Java feature in years. Project Loom’s promise was simple: write blocking code that scales like non-blocking code. Java 21 delivered them as a finalized, production-ready feature. They work. The caveats matter.
The Problem Virtual Threads Solve
Traditional Java concurrency has a fundamental constraint: platform threads are expensive. Each platform thread maps 1:1 to an OS thread. OS threads consume roughly 1MB of stack space by default and are expensive to create and context-switch.
The consequence is straightforward: a server that handles each request with one platform thread can handle roughly as many concurrent requests as it has threads in its pool. A pool of 200 threads handles 200 concurrent requests. The 201st request waits.
This is only a problem when threads spend a lot of time waiting — which describes most web applications. A request that makes 3 database calls and 2 HTTP calls to downstream services spends the vast majority of its lifetime blocked on I/O. The thread is not executing — it’s just sitting there waiting for bytes to arrive.
Non-blocking / reactive frameworks solved this by using a small number of threads and callbacks. Instead of blocking, you register a callback that fires when data arrives. The thread is never idle — it always has useful work to do.
The cost is the programming model. Reactive code is harder to read, harder to debug, harder to test, and harder to integrate with libraries that assume blocking semantics. It also doesn’t integrate cleanly with thread-local state, security context propagation, or most ORMs.
Virtual threads offer a third option: keep the blocking programming model, but make blocking cheap.
How Virtual Threads Work
Virtual threads are JVM-managed threads, not OS threads. They run on top of a pool of carrier threads (platform threads that actually run on OS threads). When a virtual thread blocks — waiting for I/O, waiting for a lock, sleeping — the JVM unmounts it from its carrier thread and parks it. The carrier thread is free to pick up another virtual thread.
Carrier thread 1: [VT-A executing] → [VT-A blocks on I/O] → [VT-B executing]
Carrier thread 2: [VT-C executing] → [VT-C blocks on sleep] → [VT-D executing]
Virtual threads are cheap: they consume roughly 1KB of stack space initially (vs 1MB for platform threads) and are cheap to create. You can create millions of them.
The programming model is identical to platform threads. Code that works with platform threads works with virtual threads without modification:
// Creating virtual threads — Java 21
Thread vt = Thread.ofVirtual().start(() -> {
// This blocking call is fine — the carrier thread is not blocked
String response = httpClient.get("https://api.example.com/data");
process(response);
});
// Virtual thread per task executor
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> {
// Each task gets its own virtual thread
return processRequest(request);
});
In Spring Boot 3.2+, you enable virtual threads for the embedded web server with a single property:
spring.threads.virtual.enabled=true
That’s it. Your existing blocking Spring MVC code now runs on virtual threads. Every request gets its own virtual thread. Blocking I/O doesn’t consume a carrier thread. You go from handling 200 concurrent requests (with a 200-thread pool) to handling thousands.
Measuring the Difference
Virtual threads help specifically with I/O-bound, blocking workloads. A service that makes database calls, calls downstream HTTP APIs, or reads from files sees dramatic throughput improvements.
A typical Spring Boot REST endpoint making two database calls and one HTTP call:
| Approach | Concurrency | Throughput | p99 Latency |
|---|---|---|---|
| Platform threads (200 pool) | 200 requests | Baseline | Baseline |
| Virtual threads | ~10,000+ requests | 5–15× higher | Similar per-request |
The improvement is in concurrency capacity, not per-request speed. Each individual request takes roughly the same time. What changes is how many requests you can handle simultaneously.
What Virtual Threads Don’t Solve
This is where the nuance matters.
CPU-Bound Work
If your threads are not blocking — if they’re doing computation — virtual threads provide no benefit. The bottleneck is CPU, not thread availability. Virtual threads don’t add CPU cores.
A workload that does complex JSON transformation, image processing, or heavy business logic will see no improvement from switching to virtual threads.
Pinning
Pinning is the critical caveat. A virtual thread is pinned to its carrier thread — cannot unmount — when:
- It holds a
synchronizedlock while blocking - It executes a native method that blocks
When pinned, the virtual thread occupies a carrier thread even while blocked. If enough threads are pinned simultaneously, all carrier threads are occupied and the application stalls.
// This causes pinning — the virtual thread cannot unmount while synchronized
synchronized (this) {
String data = blockingNetworkCall(); // VT is pinned here
}
// This is fine — ReentrantLock allows unmounting
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
String data = blockingNetworkCall(); // VT can unmount here
} finally {
lock.unlock();
}
The practical impact depends on your codebase. Many libraries use synchronized internally. In Java 21, JDBC drivers and some I/O operations caused pinning. In Java 24, many of these were fixed — file I/O and socket I/O no longer cause pinning.
Detect pinning with JFR:
java -XX:+FlightRecorder \
-Xlog:jvmci=info \
-Djdk.tracePinnedThreads=full \
-jar application.jar
Or JFR event jdk.VirtualThreadPinned.
Thread-Local State
Virtual threads support ThreadLocal — the API works. The problem is that a common pattern is storing per-request state in ThreadLocal and assuming it’s private to one request for its lifetime. With platform threads and a thread pool, one thread handles one request at a time. With millions of virtual threads, ThreadLocal values still work correctly — each virtual thread has its own.
The issue is thread-local pollution from pooled libraries. Some libraries cache resources in ThreadLocal assuming thread reuse (thread pooling). Virtual threads are not reused. A library that stores a database connection in ThreadLocal expecting to reuse it won’t get the behavior it expects.
For this reason, Java 21 also introduced ScopedValues — a cleaner alternative to ThreadLocal for per-request immutable state:
static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();
// Set at request entry point
ScopedValue.where(CONTEXT, new RequestContext(userId, traceId))
.run(() -> handleRequest(request));
// Access anywhere in the call stack
RequestContext ctx = CONTEXT.get();
ScopedValues are immutable, have well-defined scope, and work correctly with virtual threads and structured concurrency.
Database Connection Pools
This is the most commonly hit practical issue.
A connection pool with 20 connections handles 20 concurrent database operations. With platform threads and a 200-thread pool, this is fine — most threads are doing other things. With virtual threads and 10,000 concurrent requests, you might have 10,000 threads all trying to acquire a database connection from a pool of 20. This works — they wait — but connection pool contention becomes the bottleneck.
Virtual threads don’t eliminate the need to size your connection pool correctly. In fact, with virtual threads, you may need to think more carefully about how many concurrent operations your database can actually handle.
The solution is not “make the connection pool bigger.” Databases handle a finite number of concurrent connections efficiently. More than that and performance degrades. The solution is to size the pool to what the database can handle, and accept that virtual threads will queue at the pool boundary.
For JDBC and Hikari:
spring:
datasource:
hikari:
maximum-pool-size: 20 # Sized to what PostgreSQL can handle
# With virtual threads, threads waiting for a connection
# are virtual threads — they unmount rather than blocking carrier threads
Structured Concurrency
Java 24 finalized Structured Concurrency (JEP 505), which builds on virtual threads to address unstructured concurrent code.
The problem with the traditional ExecutorService pattern:
// These tasks run concurrently, but errors and cancellation are messy
Future<User> user = executor.submit(() -> fetchUser(userId));
Future<Orders> orders = executor.submit(() -> fetchOrders(userId));
// If fetchUser throws, fetchOrders keeps running
// If we cancel, cleanup is unclear
User u = user.get(); // May throw
Orders o = orders.get(); // May also throw
With StructuredTaskScope:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> user = scope.fork(() -> fetchUser(userId));
Subtask<Orders> orders = scope.fork(() -> fetchOrders(userId));
scope.join(); // Wait for all
scope.throwIfFailed(); // Propagate any error
return new UserProfile(user.get(), orders.get());
}
// When the try block exits, all forked tasks are done or cancelled
Task lifetimes are scoped to the enclosing block. If any task fails, others are cancelled. If the parent is cancelled, children are cancelled. The mental model is clean: the structure of the code mirrors the structure of the concurrent operations.
When to Use Virtual Threads
Use virtual threads when:
- Your application is I/O-bound (databases, HTTP, file I/O)
- You’re using a blocking programming model (Spring MVC, JDBC)
- You want simpler code than reactive alternatives
Don’t expect benefits when:
- Your threads are CPU-bound
- Your bottleneck is an external resource (database, downstream API)
- You’re already using reactive/non-blocking (WebFlux, R2DBC)
For new Spring Boot applications targeting Java 21+, enabling virtual threads is low-risk and often beneficial. For existing applications, test under load and measure — specifically checking for pinning issues and connection pool behavior.
Virtual threads are not a silver bullet, but they are a genuine improvement to Java’s concurrency model. The blocking/synchronous programming style that Java developers know is now a credible choice for highly concurrent applications.