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=full under 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:

  1. Verify Java 21+. Virtual threads are production-ready only from Java 21.

  2. Enable virtual threads. spring.threads.virtual.enabled=true in Spring Boot 3.2+.

  3. Detect pinning. Run load tests with -Djdk.tracePinnedThreads=full. Examine stack traces for pinning in your code and critical libraries.

  4. Update libraries. Check for updated versions of JDBC drivers, HTTP clients, and other I/O libraries that removed synchronized for virtual thread compatibility.

  5. Replace synchronized with ReentrantLock in any of your code that performs I/O inside synchronized blocks.

  6. Monitor connection pools. Check pool wait time and peak concurrent holders. Increase pool size if wait time is significant.

  7. Review thread-local usage. Code that caches expensive objects in ThreadLocal expecting reuse across requests will re-initialize per virtual thread. Move expensive shared objects to singletons or application-scoped beans.

  8. 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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

10 Warning Signs Your Software Project Will Fail

Some software projects fail quietly—long before they hit production. Knowing the warning signs early can save time, money, and headaches.

Read more

Java Thread Management — Why ExecutorService Exists and How to Use It Well

Creating threads directly is expensive, uncontrolled, and hard to shut down cleanly. ExecutorService solves all three problems — but its default configurations have tradeoffs that matter in production.

Read more

Centralized Configuration in Spring Boot Microservices Is Not Optional

Scattered environment variables and per-service property files work fine for one service. At ten services, they become an operational liability. Here is what a real configuration strategy looks like and why most teams implement it too late.

Read more

Java Streams Are Lazy — What That Means for Performance and Correctness

Stream intermediate operations do not execute until a terminal operation is called. This laziness enables short-circuiting, infinite streams, and fusion optimizations — and causes correctness bugs when side effects are assumed to have already fired.

Read more