Have you heard stories about “switching to virtual threads dramatically improving throughput” and wanted to try it in your own project? The configuration itself is just one line, but there are a few pitfalls to watch out for. This article covers everything from enabling virtual threads to benchmark results and important caveats.
Check the Prerequisites
First, verify that your environment is compatible.
java --version
# openjdk 21.0.x ...
Spring Boot 3.2 or later is required. Check the version in your build.gradle or pom.xml.
// build.gradle
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
Versions below Spring Boot 3.2 do not have the spring.threads.virtual.enabled property at all, so upgrading your version is the first step.
What Are Virtual Threads?
Traditional platform threads have a one-to-one mapping with OS threads. Trying to handle thousands of threads simultaneously drives up memory and context-switching costs, which is why Tomcat’s thread pool is typically capped at around 200.
Virtual Threads (Project Loom) are lightweight threads managed by the JVM. When an I/O wait occurs, the JVM automatically unmounts the virtual thread from its platform thread and assigns the platform thread to another virtual thread. As a result, a small number of platform threads can handle a large volume of requests.
However, CPU-bound processing sees almost no benefit. Virtual threads truly shine in services with heavy I/O waits, such as database access or external API calls.
Enabling Virtual Threads with spring.threads.virtual.enabled=true
All it takes is adding one line to application.properties.
spring.threads.virtual.enabled=true
With this setting, Tomcat’s thread pool switches to a virtual-thread-capable Executor, and a new virtual thread is created for each request.
If you are using a custom Executor with @Async or have defined separate Executors for specific tasks, those will need to be updated as well.
@Bean
public ExecutorService virtualThreadExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
Verifying the Effect with a Simple Benchmark
I measured performance using wrk on an endpoint with I/O waits (an API with approximately 100ms of DB access time). The test environment was: 4-core CPU, JVM -Xmx512m, PostgreSQL connection pool max 20, Java 21.0.2, Spring Boot 3.2.2.
# Measure for 30 seconds with 100 concurrent connections
wrk -t4 -c100 -d30s http://localhost:8080/api/users
| Configuration | Throughput | Latency (avg) |
|---|---|---|
| Traditional thread pool (200 fixed threads) | ~450 req/s | 220ms |
| Virtual threads enabled | ~1,200 req/s | 82ms |
Because platform threads are released during I/O waits, other requests can be processed even while waiting for a connection pool slot. That said, under these conditions — connection pool limit of 20 and 100 concurrent connections — pool exhaustion can also be a contributing factor to the performance difference, so treat these numbers as a reference only. Always measure under the actual conditions of your own service.
For CPU-bound endpoints, the difference was nearly zero.
Watch Out for Changes in ThreadLocal Behavior
Because virtual threads are disposable, the number of live threads varies with the number of concurrent requests. Code that uses ThreadLocal requires attention.
There are two main concerns. First, ThreadLocal map memory overhead: as the number of concurrent connections increases, heap usage grows. Second, value propagation via InheritableThreadLocal: virtual threads may inherit values from their parent thread unexpectedly.
With MDC in particular, forgetting to clear values after processing leaves stale data behind. Make it a habit to call MDC.clear() in a finally block as a defensive practice.
MDC.put("requestId", requestId);
try {
// processing
} finally {
MDC.clear();
}
Java 21 introduces ScopedValue as a safer alternative. Java 21–23 had it in preview (requiring --enable-preview); it became a finalized feature in Java 24 (JEP 487). Since it remained in preview through Java 22 and 23 as well, be sure to check the status for your target JDK version before adopting it.
Conditions for Pinning and How to Diagnose It
This is the most common pitfall. During the execution of a synchronized block or a native method, a virtual thread becomes pinned to its platform thread. While pinned, the thread cannot be unmounted, so you lose the benefits of virtual threads.
You can check for pinning events using JFR.
# With duration=30s specified, vt.jfr is written out automatically after 30 seconds
jcmd <PID> JFR.start duration=30s filename=vt.jfr
jfr print --events jdk.VirtualThreadPinned vt.jfr
# To stop and dump manually (for longer recordings)
jcmd <PID> JFR.start name=vtrecord filename=vt.jfr
jcmd <PID> JFR.stop name=vtrecord
If jdk.VirtualThreadPinned events are occurring frequently, action is needed.
Replacing synchronized with ReentrantLock
The fix is straightforward. Replacing synchronized with ReentrantLock eliminates pinning.
// Before
synchronized (this) {
doSomething();
}
// After
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
doSomething();
} finally {
lock.unlock();
}
You can fix your own code directly, but if a library you depend on relies on synchronized, you will need to upgrade that library. Be sure to check libraries such as Jedis or older MySQL JDBC drivers.
Differences and Use Cases Compared to @Async
Asynchronous processing with @Async and virtual threads operate at different layers. They are easy to confuse, so here is a clear breakdown.
When spring.threads.virtual.enabled=true is set, Spring Boot 3.2 auto-configures SimpleAsyncTaskExecutor (with setVirtualThreads(true)) as the default Executor for @Async. Without any additional configuration, @Async methods will also run on virtual threads.
However, if you have explicitly specified a fixed-size thread pool for @Async, concurrency will be limited to that pool size. When using a custom Executor, replace it with Executors.newVirtualThreadPerTaskExecutor().
Using Virtual Threads with @Scheduled
For schedulers using @Scheduled, if spring.threads.virtual.enabled=true is already set, Spring Boot 3.2’s auto-configuration applies the equivalent settings automatically — no manual Bean definition is needed.
If you need a custom scheduler, using SimpleAsyncTaskScheduler is the recommended pattern.
@Bean
public TaskScheduler taskScheduler() {
SimpleAsyncTaskScheduler scheduler = new SimpleAsyncTaskScheduler();
scheduler.setVirtualThreads(true);
return scheduler;
}
If your scheduler runs long-running tasks, it is worth using JFR to confirm that no pinning issues are occurring.
Summary
Before deploying to production, it is worth checking these four points:
- Measure for
jdk.VirtualThreadPinnedevents using JFR - Check whether any libraries you use depend on
synchronized - Review
ThreadLocalusage and assess the impact ofInheritableThreadLocalbehavior and memory overhead - Roll out gradually with a canary release first
Services with heavy external API calls via RestTemplate or WebClient tend to have a higher ratio of I/O waits, making them better candidates for virtual thread benefits. Give it a try with real measurements.