Java 21 Virtual Threads in Spring Boot: Performance, Pinning, and Problems Nobody Mentions
Virtual threads can make a Java application handle far more concurrent requests. They can also expose bottlenecks that were quietly protected by your old thread pool. Here’s what actually changes when you enable them in production.
Java 21 made something possible that would have sounded ridiculous to many backend engineers a few years earlier: running hundreds of thousands of concurrent tasks without creating hundreds of thousands of operating-system threads.
Naturally, developers started treating virtual threads as a performance upgrade. Spring Boot added support, tutorials appeared everywhere, and suddenly a configuration change was being discussed as though it could replace years of concurrency tuning.
I understand the excitement. Anyone who has maintained a Java service with large thread pools, blocked HTTP calls, and complicated asynchronous code can appreciate a simpler programming model. Being able to write ordinary blocking code without dedicating an operating-system thread to every waiting operation is a substantial improvement.
But there’s a problem with how virtual threads are often explained.
They make certain kinds of concurrency cheaper. They don’t make PostgreSQL faster, increase Kafka broker capacity, eliminate connection limits, or guarantee that an application will complete more useful work.
In some systems, removing the old thread bottleneck simply allows requests to reach the next bottleneck much faster.
What Java Virtual Threads Actually Change
Traditional Java platform threads are closely associated with operating-system threads. Creating large numbers of them consumes resources, particularly memory for stacks and operating-system scheduling overhead.
Virtual threads work differently.
Java schedules virtual threads onto a smaller set of platform threads called carrier threads. When a virtual thread performs a supported blocking operation, the JVM can suspend it and release its carrier so another virtual thread can run.
That makes it practical to maintain many concurrent tasks that spend substantial time waiting for I/O.
Consider a service that calls an external API. Each request performs a few milliseconds of computation, waits 200 milliseconds for a response, and then performs another small amount of work.
With platform threads, enough concurrent requests can exhaust the request-handling thread pool even when CPU utilization remains relatively low. Most threads are simply waiting.
Virtual threads reduce that particular cost.
The application can keep a much larger number of requests in progress without requiring a corresponding number of operating-system threads.
Here’s a basic Java 21 example:
try (var executor =
Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
callRemoteService();
return null;
});
}
}Each submitted task gets its own virtual thread. Assuming the remote call uses blocking operations that cooperate with virtual-thread scheduling, thousands of tasks can spend time waiting without consuming thousands of carrier threads.
That is genuinely useful.
However, submitting 10,000 tasks doesn’t mean your downstream service can process 10,000 simultaneous requests safely. Java has made concurrency cheaper on the caller’s side. The receiving system still has its own capacity limits.
This distinction matters enormously in production.
How to Enable Virtual Threads in Spring Boot
For a compatible Spring Boot 3.2+ application running on Java 21 or later, virtual-thread support can be enabled with:
spring.threads.virtual.enabled=trueIn supported Spring Boot configurations, this changes how certain framework-managed executors and embedded web server request-handling components use threads.
You should still verify which executors and libraries your application actually uses. Custom executors, third-party clients, and background processing components don’t automatically adopt virtual threads merely because the property exists.
I’d also check the Spring Boot version before relying on examples from a random tutorial. Framework behavior and Java’s virtual-thread implementation have continued evolving, particularly around pinning.
One less obvious detail involves scheduled applications. Virtual threads are daemon threads, and a scheduled task alone may not keep the JVM alive. Spring Boot provides a keep-alive setting for applications that need that behavior:
spring.main.keep-alive=trueThis is especially relevant for services that primarily run scheduled jobs rather than handling requests through a web server.
Enabling virtual threads is easy enough. Knowing which execution paths changed, and what resource limits remain, takes more investigation.
Why Your HikariCP Pool Becomes More Important
Imagine a Spring Boot API running with 200 platform request threads and a HikariCP pool containing thirty connections.
Under heavy traffic, request threads provide an incidental concurrency limit. Once enough threads are busy, additional requests wait before they can execute application logic.
Now enable virtual threads.
The application may be able to start far more request handlers concurrently because waiting no longer requires a dedicated platform thread.
That sounds excellent until thousands of requests reach code that needs a PostgreSQL connection.
HikariCP still has thirty connections.
Virtual threads don’t change that.
You may now have thousands of virtual threads waiting to acquire one of those connections. The JVM can represent those waiting tasks efficiently, but PostgreSQL still processes work according to its available connections, CPU, I/O capacity, and locking behavior.
If the application admits more requests than the database can complete, latency can increase dramatically.
Some requests will eventually hit the configured connection acquisition timeout. Others may remain in upstream queues, depending on how the application and server are configured.
The important point is that a cheaper waiting mechanism doesn’t eliminate the cost of the work being waited for.
This is where I would introduce explicit concurrency limits.
For example, if a particular operation depends on an expensive external API, a semaphore can limit how many requests invoke that dependency simultaneously.
private final Semaphore permits = new Semaphore(50);public Result fetchData() throws InterruptedException {
permits.acquire(); try {
return externalClient.fetch();
} finally {
permits.release();
}
}
This is only a simplified example. A production implementation also needs to consider bounded waiting, deadlines, interruption, fairness requirements, and how to respond when capacity is unavailable.
Still, the principle is important: virtual threads are not a replacement for controlling concurrency.
In fact, they often make deliberate concurrency limits more important because the old thread pool may no longer be providing an accidental one.
Virtual Thread Pinning: Why Java Version Matters
Pinning is one of the most frequently discussed virtual-thread problems, and also one of the easiest to explain incorrectly.
A virtual thread is pinned when it cannot be unmounted from its carrier during a blocking operation. If enough virtual threads block while pinned, carrier availability can become constrained.
In Java 21, blocking inside a synchronized region was an important potential source of pinning.
Consider:
public synchronized String loadCustomer(UUID id) {
return remoteCustomerClient.fetch(id);
}If the remote call blocks while the virtual thread remains pinned, its carrier may remain occupied for the duration of the operation.
Under high concurrency, this can undermine the scalability benefit you expected from virtual threads.
But here’s a detail that older articles often miss.
JDK 24 changed virtual-thread behavior around synchronized blocks and methods through JEP 491. With that improvement, ordinary monitor-based synchronization no longer causes the same pinning problem that developers needed to watch for in Java 21.
This doesn’t mean pinning has disappeared under every circumstance. Native calls, foreign-function interactions, and other runtime constraints can still matter.
It means you should never diagnose pinning based solely on advice written for an older JDK.
If you’re running Java 21, monitor-related pinning remains relevant. If you’re running a newer JDK with the JEP 491 changes, your investigation needs to reflect the updated runtime behavior.
For Java 21, a useful diagnostic option is:
-Djdk.tracePinnedThreads=fullJava Flight Recorder can also help investigate virtual-thread behavior, including relevant pinning events, depending on the JDK version and enabled event configuration.
Get The Systems & Architecture’s stories in your inbox
Join Medium for free to get updates from this writer.
Before rewriting every synchronized method in a codebase, establish whether pinning is actually occurring and whether it affects throughput.
Replacing synchronization primitives without evidence can introduce concurrency defects far more serious than the original performance problem.
Virtual Threads Won’t Make CPU-Heavy Code Faster
Suppose your service spends most of its time performing JSON transformations, encryption, compression, image processing, or expensive calculations.
Virtual threads won’t create additional CPU cores.
A workload that keeps the CPU busy executing Java code generally doesn’t benefit from having thousands of additional runnable tasks competing for the same processors.
In some circumstances, scheduling overhead and increased concurrency can even reduce throughput.
Virtual threads are most useful when tasks spend significant time waiting for supported blocking operations.
That is why I would benchmark them against a realistic workload rather than comparing thread creation costs in isolation.
A benchmark that creates one million sleeping virtual threads demonstrates that virtual threads are lightweight. It doesn’t prove that your Spring Boot service can process one million useful business operations concurrently.
For a production benchmark, I’d measure completed requests per second, p95 and p99 latency, CPU utilization, allocation rate, garbage collection behavior, connection pool saturation, and downstream dependency load.
I’d also examine performance after the application reaches its capacity limit.
Some designs look impressive until traffic exceeds sustainable throughput. Then queueing, timeouts, and retries turn a throughput improvement into a reliability problem.
ThreadLocal Becomes an Interesting Memory Problem
Many Java applications use ThreadLocal for request context, logging metadata, security information, or other thread-associated state.
Virtual threads support thread-local variables, but their lightweight nature changes the economics.
With a traditional fixed pool of 200 platform threads, a thread-local value may be associated with a relatively small, stable number of threads.
With a virtual-thread-per-task model, an application can create enormous numbers of threads over time.
If each virtual thread holds substantial thread-local data while waiting for I/O, memory consumption can grow with the number of concurrent tasks.
That doesn’t mean ThreadLocal is inherently incompatible with virtual threads. It means developers need to understand the lifetime and size of the data they associate with each task.
Request-scoped context should be cleaned up appropriately. Large objects should not be retained unnecessarily, and context propagation should be tested rather than assumed.
The interaction with application frameworks deserves particular attention. Logging MDC, security context, tracing context, and transaction management may rely on thread-associated state, depending on the implementation.
Virtual threads preserve the familiar thread-per-request programming model, which can simplify some context-handling problems compared with callback-heavy asynchronous code. But that convenience doesn’t remove the need to inspect how libraries manage state.
An application that was designed around a small, reused platform thread pool may contain assumptions that deserve review before moving to a much larger number of short-lived virtual threads.
Don’t Confuse Virtual Threads With Reactive Programming
Spring WebFlux and Java virtual threads are often presented as competing solutions to the same problem.
There is some overlap, but they provide different programming models and operational characteristics.
Virtual threads allow developers to write straightforward blocking-style code while reducing the cost of waiting on many supported I/O operations.
Reactive programming represents asynchronous operations through a different composition model, often with explicit support for backpressure and nonblocking data flows.
Neither approach automatically makes a system faster.
A WebFlux application can still overload PostgreSQL if it generates more work than the database can handle. A virtual-thread-based MVC application can handle substantial concurrency efficiently when its dependencies and resource limits are designed appropriately.
If a team already has a stable reactive system, rewriting it solely to adopt virtual threads may offer little business value.
Likewise, a conventional Spring MVC service doesn’t necessarily need a full reactive rewrite to improve concurrency when blocking I/O is the primary limitation.
I’d compare implementation complexity, library compatibility, operational behavior, and measured throughput before recommending either approach.
Architecture decisions become expensive when engineers treat programming models as team identities.
What I’d Measure Before Enabling Virtual Threads in Production
I’d begin with a baseline from the existing application.
How many requests are in progress during peak traffic? What percentage of request time is spent waiting on external dependencies? Are platform threads actually limiting throughput, or is the application constrained by PostgreSQL, CPU, or another resource?
If the current thread pool isn’t the bottleneck, virtual threads may offer limited performance improvement.
Next, I’d identify custom executors, thread-local usage, synchronized code paths, native integrations, and libraries that may behave differently under high concurrency.
Then I’d test with realistic traffic and dependency latency.
A particularly useful experiment is to introduce additional latency into a downstream HTTP service while keeping the request arrival rate stable. Compare completed throughput, memory consumption, request latency, and downstream concurrency between platform-thread and virtual-thread configurations.
I’d repeat the test with a deliberately constrained database pool.
That reveals whether virtual threads improve useful throughput or merely allow more requests to accumulate while waiting for the same database connections.
Finally, I’d observe shutdown behavior, cancellation, timeouts, and scheduled jobs. A concurrency change that performs well under normal traffic can still create operational surprises during deployments and failures.
The objective is to understand which bottleneck moved and whether the new behavior is preferable.
Frequently Asked Questions About Java 21 Virtual Threads
Are Java 21 virtual threads faster than platform threads?
Not necessarily for individual tasks. Their primary benefit is supporting large numbers of concurrent tasks that spend time waiting for I/O without requiring a dedicated operating-system thread for each one. CPU-bound tasks generally don’t become faster simply because they run in virtual threads.
How do I enable virtual threads in Spring Boot 3?
For supported Spring Boot versions running on Java 21 or later, set spring.threads.virtual.enabled=true. Verify which framework-managed executors use virtual threads and test custom executors and third-party libraries separately.
What is virtual thread pinning in Java?
Pinning occurs when a virtual thread cannot release its carrier thread while blocked. In Java 21, blocking within synchronized regions could cause pinning. JDK 24 improved this behavior through JEP 491, so diagnosis should account for the Java version in use.
Do virtual threads solve HikariCP connection pool exhaustion?
No. Virtual threads reduce the cost of waiting on the application side, but they don’t increase the number of available database connections or PostgreSQL’s processing capacity. Applications may need explicit concurrency limits to prevent excessive demand.
Should I replace Spring WebFlux with virtual threads?
Only if a measured or maintainability-related benefit justifies the change. Both models can support highly concurrent applications, and their trade-offs depend on workload characteristics, library support, and team requirements. A rewrite isn’t automatically necessary.
Are virtual threads suitable for CPU-intensive applications?
They can execute CPU-intensive work, but they don’t increase available CPU capacity. Their main advantage appears in workloads with substantial blocking I/O. For CPU-heavy processing, controlling parallelism according to available processors remains important.
Are virtual threads recommended for microservices?
They can be a good fit for Java microservices that use blocking-style code and spend significant time waiting on external services or databases. Production adoption should include benchmarking, resource limits, library compatibility checks, and failure testing.
One Experiment I’d Run Before Switching
Take a Spring Boot endpoint that performs a database query and calls an external service. Measure its behavior under a fixed request arrival rate with platform threads, then repeat the same test with virtual threads enabled.
Increase downstream latency gradually while keeping CPU capacity and database connection limits unchanged. Watch how many requests complete, how long they wait, and what happens when the application exceeds sustainable throughput.
You may find that virtual threads dramatically improve concurrency without requiring a complicated asynchronous programming model. You may also discover that your existing thread pool was protecting a database that cannot tolerate much more parallel work.
Both results are useful.
Java 21 gives backend engineers a much better tool for managing blocking concurrency. Getting value from it requires understanding where concurrency helps and where it simply exposes another limit.
For engineers working on Java and Spring Boot services beyond development environments, I’ve assembled the Java & Spring Boot Production Engineering System. It includes 12 resources covering JVM behavior, Spring Boot production debugging, SQL and Redis performance, microservice boilerplates, release checks, and Senior Java interview preparation.
I also publish engineering notes on Java, distributed systems, production reliability, and architecture at Devrim’s Engineering Notes on Substack.










