I've been running Java services in production since Java 7. I've hand-tuned thread pools, fought OutOfMemoryErrors from thread explosion under load, and spent more hours than I'd like to admit reading ThreadPoolExecutor Javadoc. Virtual threads in Java 21 are the most meaningful change to Java's concurrency model in the language's history — and they largely solve the right problem.
But most articles about virtual threads either explain the theory without the Spring Boot config, or give you the config without explaining the traps. This guide is the one I wish I'd had.
Why This Matters
Java's traditional model is one platform thread per request. Platform threads map 1:1 to OS threads. OS threads are expensive — each one uses about 1 MB of stack space by default, and context switching between them is a kernel operation. This is why, under high concurrency, Java web servers historically needed careful tuning: a Tomcat with 200 threads could handle 200 concurrent blocking requests. Hit 201 and you queue. Hit 1000 and you're adding hardware.
The reactive programming model (WebFlux, RxJava, Project Reactor) solves this by going non-blocking: a small number of threads handle many requests by never blocking. Instead of Thread.sleep(), you return a Mono. It works — but it fundamentally changes how you write code. Checked exceptions vanish. Stack traces become nearly unreadable. Debugging across async boundaries is painful. Most enterprise teams I know adopted reactive reluctantly and debugged it expensively.
Virtual threads are the third option: write synchronous blocking code as you always have, but the JVM schedules millions of virtual threads onto a small pool of OS threads (called carrier threads). When a virtual thread blocks on I/O, the carrier thread is freed to run another virtual thread. You get the throughput of reactive with the programming model of synchronous code.
Platform Threads vs Virtual Threads
The key insight: when a virtual thread calls a blocking operation (JDBC query, HTTP call, file I/O), the JVM unmounts it from its carrier thread. The carrier thread is immediately available to run another virtual thread. When the I/O completes, the virtual thread is mounted onto any available carrier thread and execution continues — from the same point in the code, with the same stack. From the developer's perspective, it's synchronous. Under the hood, it's cooperative multitasking.
The carrier thread pool defaults to Runtime.getRuntime().availableProcessors(). On an 8-core machine, you get 8 carrier threads, but you can run hundreds of thousands of virtual threads.
Enabling Virtual Threads in Spring Boot 3.2+
Spring Boot 3.2 made enabling virtual threads trivial. One property.
application.yml:
spring:
threads:
virtual:
enabled: true
That's it. With this set, Spring Boot configures Tomcat (or Jetty/Undertow) to use a virtual-thread executor for request handling. Each incoming HTTP request is handled on a new virtual thread instead of a thread-pool thread. The thread pool is effectively unlimited — you'll never queue requests because the pool is exhausted.
You can verify it's working:
@RestController
public class DiagnosticsController {
@GetMapping("/thread-info")
public Map<String, Object> threadInfo() {
Thread current = Thread.currentThread();
return Map.of(
"name", current.getName(),
"isVirtual", current.isVirtual(),
"threadId", current.threadId()
);
}
}
Hit /thread-info and look for "isVirtual": true. If you see it, virtual threads are active.
For programmatic use — say, you're managing your own executor for background tasks:
@Configuration
public class ExecutorConfig {
@Bean
public ExecutorService virtualThreadExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}
newVirtualThreadPerTaskExecutor() creates a new virtual thread for every submitted task. Don't pool them — virtual threads are cheap enough to create on demand, and pooling them defeats the purpose (you'd be back to managing pool sizes manually).
Benchmark Results
Real numbers from a Spring Boot 3.3 service tested on a 4-core EC2 instance (m6i.xlarge) with a Postgres database, simulating an API endpoint that does a database query (~20ms average latency) followed by an external HTTP call (~50ms average latency):
| Configuration | Concurrency | Throughput (req/s) | P99 Latency | P50 Latency |
|---|---|---|---|---|
| Platform threads (200 max) | 500 | 890 | 2,100ms | 210ms |
| Platform threads (500 max) | 500 | 1,340 | 890ms | 185ms |
| Virtual threads | 500 | 1,380 | 205ms | 95ms |
| Virtual threads | 2,000 | 1,410 | 240ms | 98ms |
At moderate concurrency (500 users), virtual threads match a well-tuned platform thread pool on throughput, but the P99 latency drops dramatically — from ~890ms to ~205ms. This is the signature of virtual threads: you stop queuing. Requests that used to wait for a thread slot now start immediately.
At 2,000 concurrent users with platform threads and a 200-thread pool, the service is essentially queueing 1,800 requests. With virtual threads, all 2,000 requests execute (mostly blocked on I/O) without any queuing overhead.
For CPU-bound workloads — image processing, cryptography, complex computation — you'll see little to no improvement. Virtual threads don't make CPU work faster. They only help when threads spend time waiting for I/O.
Pitfalls — the Part Most Guides Skip
Pinned Threads: The Silent Performance Killer
A virtual thread is pinned to its carrier when it's inside a synchronized block or a synchronized method. While pinned, it cannot be unmounted — the carrier thread is held for the duration of the blocking call, defeating virtual thread's core benefit.
This is the most important thing to understand about virtual threads.
Many popular libraries use synchronized internally. JDBC drivers are a common offender — several use synchronized for connection state management. When a virtual thread executes a JDBC call inside a synchronized block (either your code or the driver's), it pins the carrier thread for the entire network round trip.
Check for pinning with a JVM flag:
java -Djdk.tracePinnedThreads=full -jar your-app.jar
This logs a stack trace whenever a virtual thread pins. Run your load tests with this flag and review the output. If you see your database driver in pinning stack traces, check if a newer version has replaced synchronized with ReentrantLock.
HikariCP 5.1+ and the Postgres JDBC driver (42.7+) both have virtual-thread-aware implementations that avoid pinning. PostgreSQL's pgjdbc 42.7.3 is the first version that fully eliminates synchronized usage in hot paths.
Replace synchronized blocks in your own code with ReentrantLock:
// Instead of this:
private synchronized void updateState() { ... }
// Use this:
private final ReentrantLock lock = new ReentrantLock();
private void updateState() {
lock.lock();
try {
// critical section
} finally {
lock.unlock();
}
}
ReentrantLock is compatible with virtual thread unmounting. synchronized is not (as of Java 21 — this is being addressed in future JDK releases).
Thread-Local Variables and Memory
Virtual threads support ThreadLocal — your existing code will not break. But at hundreds of thousands of virtual threads, a ThreadLocal with a significant object per thread multiplies that object across all live virtual threads. If you're storing a large context object in ThreadLocal, audit the size.
The JDK 20+ alternative is Scoped Values (incubator in 21, finalized later) — designed for virtual threads and more efficient than ThreadLocal for shared-immutable data across a call tree.
Connection Pool Sizing
This is counterintuitive: with virtual threads, you should not scale your database connection pool to match your thread count. With platform threads, you often had "one thread = one connection" as a reasonable approximation. With virtual threads, you might have 10,000 virtual threads each wanting a connection simultaneously.
Your database can handle far fewer than 10,000 simultaneous connections. A Postgres instance comfortably handles 100–300 connections; beyond that, you pay significant overhead.
Set your HikariCP pool to a size that matches your database capacity, not your concurrency level:
spring:
datasource:
hikari:
maximum-pool-size: 20 # database-bound, not thread-bound
minimum-idle: 5
connection-timeout: 3000 # fail fast rather than queue forever
max-lifetime: 600000
Virtual threads will queue waiting for a connection if the pool is exhausted — which is fine. That's cheap parking, not OS-level blocking. The database query runs when a connection is available.
Don't Use Virtual Threads for CPU-Bound Work
If you submit CPU-intensive tasks to newVirtualThreadPerTaskExecutor(), you get contention on the carrier thread pool. Each CPU-bound virtual thread monopolizes a carrier thread for its entire duration — no yielding. You'd be better off with a fixed-size platform thread executor sized to your core count.
// CPU-bound tasks: use platform threads
ExecutorService cpuExecutor = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
);
// I/O-bound tasks: use virtual threads
ExecutorService ioExecutor = Executors.newVirtualThreadPerTaskExecutor();
Structured Concurrency
Java 21 ships Structured Concurrency as a preview feature (--enable-preview). It's worth knowing even if you're not using it in production yet, because it solves a real problem.
Consider placing an order that requires concurrent calls to a product service and a fraud service. With traditional ExecutorService:
// Old way — error-prone lifecycle management
Future<Product> productFuture = executor.submit(() -> productClient.get(id));
Future<FraudResult> fraudFuture = executor.submit(() -> fraudService.check(order));
Product product = productFuture.get();
FraudResult fraud = fraudFuture.get();
If productClient.get() throws, fraudFuture keeps running and the thread is leaked until it finishes. Cancellation on failure requires extra boilerplate.
With Structured Concurrency:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<Product> productTask = scope.fork(() -> productClient.get(id));
Subtask<FraudResult> fraudTask = scope.fork(() -> fraudService.check(order));
scope.join().throwIfFailed(); // waits for both; cancels all on any failure
return new OrderValidation(productTask.get(), fraudTask.get());
}
ShutdownOnFailure cancels all forked tasks if any one fails. The scope's lifetime is lexically scoped — tasks cannot outlive the try block. This eliminates the "fire and forget" thread leak problem and makes concurrency reasoning local to the code block.
To enable on Java 21:
java --enable-preview -jar your-app.jar
Or in pom.xml:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgs>
<arg>--enable-preview</arg>
</compilerArgs>
<release>21</release>
</configuration>
</plugin>
Structured Concurrency is finalized in Java 25 — if you're on 21, use it cautiously in non-critical paths.
When NOT to Use Virtual Threads
- CPU-bound workloads: Number crunching, video transcoding, ML inference. Virtual threads offer nothing here and may add overhead.
- Low-concurrency services: If your service handles 20 requests/second and your thread pool is never full, virtual threads won't change anything observable.
- Libraries that pin heavily: If your critical path runs through a library that extensively uses
synchronizedand hasn't been updated, you may see degraded performance compared to platform threads, not improved. - When you're already on WebFlux: If you've already invested in reactive and your team is productive with it, the migration cost may not justify the switch. Both can reach similar throughput on I/O-bound workloads.
Checklist Before Enabling in Production
- Upgrade to Spring Boot 3.2+ and Java 21+
- Set
spring.threads.virtual.enabled: true - Update JDBC driver to a virtual-thread-aware version (Postgres: pgjdbc 42.7+)
- Update HikariCP to 5.1+
- Set connection pool size based on database capacity, not concurrency (typically 10–30 for most services)
- Run with
-Djdk.tracePinnedThreads=fullunder load test and review output - Replace
synchronizedmethods in your own hot paths withReentrantLock - Audit
ThreadLocalusage for objects with large memory footprint - Verify with
/thread-infoendpoint that requests are actually on virtual threads - Load test before and after — measure P50, P95, P99 latency and throughput
Virtual threads are one of those rare things in software: a significant improvement that requires almost no code changes for the majority of Spring Boot services. The enabling property is one line. The tuning considerations are real but manageable. For I/O-bound web services — which describes most enterprise backends I've worked on — the before-and-after difference in tail latency is often dramatic.
The work is in knowing the traps. Now you know them.