Virtual Threads in Java — What Changes, What Doesn't, and How to Migrate
by Eric Hanson, Backend Developer at Clean Systems Consulting
The execution model — carrier threads and mounting
A virtual thread is a Java thread managed by the JVM, not the OS. The JVM schedules virtual threads onto a small pool of OS threads called carrier threads — by default, one carrier thread per CPU core.
When a virtual thread blocks on I/O — a database query, an HTTP call, a file read — it is unmounted from its carrier thread. The carrier thread is immediately available to run another virtual thread. When the I/O completes, the virtual thread is rescheduled and mounted onto a carrier thread to continue.
This is the fundamental difference from platform threads: a platform thread that blocks on I/O holds an OS thread for the entire duration of the blocking operation. A virtual thread releases the carrier thread the moment it blocks.
The implications:
// Platform thread model — 200 threads, 200 OS threads, most blocked on DB
ExecutorService pool = Executors.newFixedThreadPool(200);
// Virtual thread model — millions of threads possible, only N (= CPU count) carrier threads
ExecutorService vtp = Executors.newVirtualThreadPerTaskExecutor();
For a service handling 10,000 concurrent requests where each request makes 3 database calls averaging 10ms each, the platform thread model requires 10,000 OS threads most of which are blocked. The virtual thread model requires roughly as many carrier threads as CPU cores, with 10,000 virtual threads cycling through them.
What virtual threads don't change
CPU-bound work is unaffected. A virtual thread that computes — sorting, encryption, JSON serialization, image processing — occupies a carrier thread for the full computation. Virtual threads add no CPU capacity. For CPU-bound work, the appropriate parallelism is bounded by CPU core count, which is exactly what ForkJoinPool.commonPool() and Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()) provide.
Correctness requirements don't change. Shared mutable state still requires synchronization. Race conditions, deadlocks, and visibility failures are identical problems on virtual threads. The concurrency bugs covered in earlier articles apply equally.
External resource limits still apply. A database connection pool with 20 connections can serve at most 20 concurrent queries regardless of how many virtual threads are waiting. Virtual threads don't multiply connections — they make waiting cheaper. You may find you need a larger connection pool with virtual threads because more virtual threads can reach the pool simultaneously.
The pinning problem — synchronized and native code
A virtual thread pins to its carrier thread when it enters a synchronized block or method, or when it calls native code. While pinned, the carrier thread cannot be used by other virtual threads — the virtual thread behaves like a platform thread for the duration of the pinned section.
// This pins the carrier thread — bad if I/O happens inside
public synchronized void updateCache() {
String value = database.query(sql); // I/O inside synchronized — carrier pinned
cache.put(key, value);
}
If database.query() blocks for 50ms and this method is called by 1,000 virtual threads simultaneously, up to 1,000 carrier threads (or all available, whichever is smaller) are pinned — defeating the purpose of virtual threads.
The fix: replace synchronized with ReentrantLock in code that performs I/O:
private final ReentrantLock lock = new ReentrantLock();
public void updateCache() {
lock.lock();
try {
String value = database.query(sql); // virtual thread can unmount here
cache.put(key, value);
} finally {
lock.unlock();
}
}
ReentrantLock does not pin — a virtual thread waiting to acquire a ReentrantLock unmounts from its carrier thread while waiting.
Detecting pinning. The JVM can log pinning events:
-Djdk.tracePinnedThreads=full
This logs a stack trace whenever a virtual thread pins its carrier thread. Run this during load testing to find pinning in your code and in the libraries you use. Many popular libraries (JDBC drivers, cryptography, compression) use synchronized internally and will pin until they're updated.
The library pinning problem. You don't control library code. If a JDBC driver uses synchronized internally during query execution, your virtual threads pin while executing queries. This is the most significant adoption friction for virtual threads in 2024. Check whether your critical-path libraries are virtual-thread-friendly — many have released updates to remove synchronized after Java 21's release.
HikariCP addressed this in version 5.1.0. PostgreSQL JDBC addressed it in pgJDBC 42.7.0. Spring Framework addressed internal synchronization for virtual threads. Check the release notes of your dependencies.
Spring Boot migration
Spring Boot 3.2+ enables virtual threads for all request handling with a single property:
spring.threads.virtual.enabled=true
This replaces the Tomcat/Jetty thread pool with a virtual thread executor — each incoming HTTP request gets its own virtual thread. The thread-per-request programming model is preserved; the scalability profile changes.
For the Tomcat connector explicitly:
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
For @Async methods:
@Bean
public Executor taskExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
For scheduled tasks (@Scheduled), Spring Boot 3.2+ with spring.threads.virtual.enabled=true automatically uses virtual threads.
What to verify after enabling:
- Run
jdk.tracePinnedThreads=fullunder load and examine the output for unexpected pinning - Verify connection pool behavior — you may see higher peak connection usage
- Check thread-local usage — virtual threads use thread-locals correctly, but code that assumed thread-locals correlated with requests needs review (each virtual thread is a new thread with new thread-locals)
Connection pool sizing with virtual threads
The counterintuitive consequence: you may need more connections, not fewer, after enabling virtual threads.
With 200 platform threads, the connection pool can never have more than 200 simultaneous holders — threads are the bottleneck. With virtual threads, thousands of tasks can reach the pool simultaneously. If the pool has 20 connections and 1,000 virtual threads want queries, 980 virtual threads wait. They wait efficiently (unmounted from carrier threads), but they still wait.
The practical effect: virtual threads surface connection pool as the bottleneck that platform thread limits previously masked. Monitor connection pool wait times after migration. Increase pool size if wait time is significant, up to the database server's connection limit.
HikariCP configuration for virtual thread workloads:
HikariConfig config = new HikariConfig();
config.setMaximumPoolSize(50); // increase from typical 10-20
config.setConnectionTimeout(30_000);
config.setKeepaliveTime(60_000);
The right maximumPoolSize depends on your database server's capacity. PostgreSQL handles hundreds of connections without trouble on reasonable hardware; the practical limit is usually database CPU, not connection count.
Thread-local and scoped values
Virtual threads support ThreadLocal — each virtual thread has its own thread-local storage. Code that uses ThreadLocal for request context (user ID, trace ID, MDC logging context) works correctly with virtual threads.
The performance consideration: with platform threads, thread-local storage is reused across requests (the thread serves many requests over its lifetime). With virtual threads, each request gets a new virtual thread — thread-local storage is initialized per virtual thread. Expensive ThreadLocal initializers run per request rather than per thread.
Java 20+ introduces ScopedValue as a cleaner alternative to ThreadLocal for immutable per-request context. ScopedValue is designed for virtual threads — it passes values down call chains without mutation:
private static final ScopedValue<RequestContext> REQUEST_CTX = ScopedValue.newInstance();
// At request entry
ScopedValue.where(REQUEST_CTX, new RequestContext(userId, traceId))
.run(() -> handleRequest(request));
// Anywhere in the call chain
RequestContext ctx = REQUEST_CTX.get();
ScopedValue is a preview feature in Java 21 (finalized in Java 24). For Java 21 production use, ThreadLocal works correctly — consider ScopedValue when it's stable in your target version.
The migration checklist
For a typical Spring Boot service migrating to virtual threads:
-
Verify Java 21+. Virtual threads are production-ready only from Java 21.
-
Enable virtual threads.
spring.threads.virtual.enabled=truein Spring Boot 3.2+. -
Detect pinning. Run load tests with
-Djdk.tracePinnedThreads=full. Examine stack traces for pinning in your code and critical libraries. -
Update libraries. Check for updated versions of JDBC drivers, HTTP clients, and other I/O libraries that removed
synchronizedfor virtual thread compatibility. -
Replace synchronized with ReentrantLock in any of your code that performs I/O inside
synchronizedblocks. -
Monitor connection pools. Check pool wait time and peak concurrent holders. Increase pool size if wait time is significant.
-
Review thread-local usage. Code that caches expensive objects in
ThreadLocalexpecting reuse across requests will re-initialize per virtual thread. Move expensive shared objects to singletons or application-scoped beans. -
Load test. Virtual threads change the concurrency profile of the service — load test to verify throughput improvement and the absence of unexpected behavior before production.
The migration is lower-risk than a rewrite to reactive programming. The programming model stays synchronous and blocking. The scalability improvement is real for I/O-bound services. The traps — pinning, connection pool sizing, thread-local initialization — are findable and fixable before rollout.