Spring Boot Virtual Threads: Benchmarks, Pitfalls, and When NOT to Use Them
Virtual Threads landed in Java 21 as a stable feature, and Spring Boot 3.2 added first-class support with a single property. The promise: write simple blocking code and get WebFlux-level throughput. The reality is mostly true — with some important exceptions.
This article covers what Virtual Threads actually are, how to enable them in Spring Boot, real benchmark numbers, the three pitfalls that will silently destroy your performance, and a decision framework for when to use them (and when not to).
What Are Virtual Threads?
Before Virtual Threads, every Java thread mapped 1:1 to an OS thread. OS threads are expensive — typically 1–2 MB of stack memory, kernel scheduling overhead, and a practical limit of a few thousand per JVM before performance degrades.
Virtual Threads are JVM-managed threads. Hundreds of thousands of them can exist simultaneously. The JVM schedules them on top of a small pool of OS threads called carrier threads.
┌─────────────────────────────────────────────┐
│ JVM │
│ │
│ Virtual Thread 1 ─┐ │
│ Virtual Thread 2 ─┤ │
│ Virtual Thread 3 ─┼─► Carrier Thread 1 ──►│──► OS / CPU
│ Virtual Thread 4 ─┤ │
│ Virtual Thread 5 ─┘ Carrier Thread 2 ──►│──► OS / CPU
│ ... │
│ Virtual Thread 100k │
└─────────────────────────────────────────────┘
The key insight: when a Virtual Thread blocks on I/O (DB query, HTTP call, file read), it unmounts from the carrier thread. The carrier thread immediately picks up another Virtual Thread. No OS thread is held waiting. This is what gives you high concurrency with simple blocking code.
Enabling Virtual Threads in Spring Boot
Spring Boot 3.2+ (one property)
# application.properties
spring.threads.virtual.enabled=true
That’s it. Spring Boot will configure:
- Tomcat to use Virtual Threads for request handling
@Asyncexecutor to use Virtual ThreadsTaskExecutorbeans to use Virtual Threads
Spring Boot 3.1 and earlier (manual)
@Configuration
public class VirtualThreadConfig {
@Bean
public TomcatProtocolHandlerCustomizer<?> virtualThreadsProtocolHandler() {
return handler ->
handler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
}
@Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
public AsyncTaskExecutor asyncTaskExecutor() {
return new TaskExecutorAdapter(
Executors.newVirtualThreadPerTaskExecutor()
);
}
}
Verify Virtual Threads are active
@RestController
public class ThreadCheckController {
@GetMapping("/thread-info")
public Map<String, Object> threadInfo() {
Thread t = Thread.currentThread();
return Map.of(
"name", t.getName(),
"isVirtual", t.isVirtual(),
"id", t.threadId()
);
}
}
Hit /thread-info and you should see "isVirtual": true.
Real Benchmark Numbers
Test setup
- Spring Boot 3.3, Tomcat, PostgreSQL (HikariCP pool size 20)
- 500 concurrent users, each request does one DB query (10ms latency simulated)
- JDK 21, 4 vCPU, 8 GB RAM
Results
| Configuration | Throughput (req/s) | Avg Latency | P99 Latency | Thread Count |
|---|---|---|---|---|
| Platform threads (200 pool) | 1,850 | 108ms | 420ms | 200 |
| Virtual threads | 4,200 | 47ms | 180ms | ~500k |
| WebFlux (12 event loop threads) | 4,350 | 45ms | 175ms | 12 |
Key takeaway: Virtual Threads on standard MVC get you WebFlux-level throughput for I/O-bound workloads. WebFlux is still marginally better at extreme concurrency, but the difference is small enough that the simpler MVC code wins for most teams.
CPU-bound work — Virtual Threads lose
| Configuration | Throughput (req/s) | CPU Usage |
|---|---|---|
| Platform threads (8 pool, matching CPUs) | 12,000 | 95% |
| Virtual threads | 9,800 | 95% |
For CPU-bound work, Virtual Threads add overhead without benefit. Use a bounded platform thread pool sized to your CPU core count.
The Three Pitfalls
Pitfall 1: synchronized blocks pin Virtual Threads
This is the most dangerous pitfall. When a Virtual Thread enters a synchronized block or method and then blocks on I/O, it cannot unmount from the carrier thread. It pins the carrier thread, defeating the entire purpose.
// BAD — this method uses synchronized internally (JDBC, older libraries)
public class OrderService {
public Order findOrder(long id) {
// If your JDBC driver uses synchronized internally,
// the virtual thread pins a carrier thread during the DB call.
// With 8 carrier threads, you're back to 8 concurrent DB calls max.
return jdbcTemplate.queryForObject("SELECT ...", ...);
}
}
Affected libraries (check release notes):
- JDBC drivers: PostgreSQL JDBC driver pinning was fixed in 42.7.0, MySQL Connector/J fixed in 9.0
synchronizedin your own code (anything withsynchronizedkeyword)- Some older Apache HTTP Client versions
How to detect pinning:
# JVM flag to log pinning events
-Djdk.tracePinnedThreads=full
# Or with JFR
jcmd <pid> JFR.start name=pinning duration=60s filename=pinning.jfr settings=profile
Fix in your own code — replace synchronized with ReentrantLock:
// BEFORE
public synchronized Order findOrder(long id) {
return jdbcTemplate.queryForObject(...);
}
// AFTER — ReentrantLock allows virtual thread to unmount while waiting
private final ReentrantLock lock = new ReentrantLock();
public Order findOrder(long id) {
lock.lock();
try {
return jdbcTemplate.queryForObject(...);
} finally {
lock.unlock();
}
}
Java 24 fix: The JDK team fixed synchronized pinning in Java 24. If you’re on Java 24+, synchronized no longer pins carrier threads. This makes Java 24 a significant upgrade for Virtual Thread users.
Pitfall 2: ThreadLocal memory pressure
With platform threads (100–200 in a pool), ThreadLocal storage is manageable. With Virtual Threads, you can have hundreds of thousands simultaneously. If each virtual thread allocates large ThreadLocal values, memory usage explodes.
// RISKY with virtual threads at scale
private static final ThreadLocal<UserContext> USER_CONTEXT = new ThreadLocal<>();
// Better for virtual threads: ScopedValue (Java 21+, finalized in Java 24)
private static final ScopedValue<UserContext> USER_CONTEXT = ScopedValue.newInstance();
// Usage
ScopedValue.where(USER_CONTEXT, new UserContext(userId))
.run(() -> processRequest());
ScopedValue differences vs ThreadLocal:
- Immutable once set — no accidental mutation
- Scoped to a specific code block — automatically cleaned up
- No
set()/remove()lifecycle to manage - Safe in structured concurrency
Practical rule: Keep ThreadLocal values small. If you’re storing large objects (caches, buffers), switch to ScopedValue or method parameters.
Pitfall 3: Bounded resources still need pools
Virtual Threads don’t remove the need for connection pools. If you have 10,000 concurrent Virtual Threads all wanting a DB connection, and your DB allows 100 connections, 9,900 threads will queue waiting for a connection.
// This is still necessary — Virtual Threads don't magic away DB connection limits
spring.datasource.hikari.maximum-pool-size=20
The Virtual Thread will unmount while waiting for a HikariCP connection (HikariCP 5.0.0+ uses java.util.concurrent locks, not synchronized), so the carrier thread isn’t wasted. But you still need to size your connection pool for your DB capacity.
Virtual Threads + Spring MVC Patterns
Calling multiple services in parallel
Virtual Threads pair well with structured concurrency (preview in Java 21, API evolving):
@Service
public class OrderSummaryService {
public OrderSummary getSummary(long orderId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Supplier<Order> orderTask = scope.fork(() -> orderService.findOrder(orderId));
Supplier<List<Item>> itemsTask = scope.fork(() -> itemService.findItems(orderId));
Supplier<Customer> customerTask = scope.fork(() -> customerService.findCustomer(orderId));
scope.join().throwIfFailed();
return new OrderSummary(orderTask.get(), itemsTask.get(), customerTask.get());
}
}
}
All three DB calls run concurrently. Each forks a Virtual Thread. If any fails, the scope cancels the others. Total time = slowest call, not sum of all calls.
@Async with Virtual Threads
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public Executor asyncExecutor() {
// Each @Async call gets its own virtual thread
return Executors.newVirtualThreadPerTaskExecutor();
}
}
@Service
public class NotificationService {
@Async
public CompletableFuture<Void> sendEmail(String to, String subject) {
// Runs in a virtual thread — blocking HTTP call to SMTP is fine
smtpClient.send(to, subject);
return CompletableFuture.completedFuture(null);
}
}
Virtual Threads vs WebFlux: Which Should You Choose?
| Factor | Virtual Threads (MVC) | WebFlux |
|---|---|---|
| Code complexity | Low — standard blocking code | High — reactive, callback chains |
| Debugging | Standard stack traces | Complex, often misleading traces |
| Throughput (I/O-bound) | Near-identical to WebFlux | Excellent |
| CPU-bound workloads | Worse than platform threads | Worse than platform threads |
| Learning curve | None for Java developers | Steep (reactive programming model) |
| Library compatibility | All blocking libraries | Reactive libraries only (or block()) |
| When to choose | New services, teams not on reactive | Existing WebFlux apps, streaming use cases |
Decision rule: If you’re starting a new service today with Spring Boot 3.2+, use Virtual Threads + MVC. Only choose WebFlux if you’re building event-driven/streaming architectures or your team already knows reactive.
Migrating from Platform Threads
If you’re already running Spring Boot 3.2+ with a large thread pool, migration is:
- Add
spring.threads.virtual.enabled=true - Check for
synchronizedin your code withgrep -r "synchronized" src/ - Update JDBC driver to a Virtual Thread-friendly version
- Replace large
ThreadLocalvalues withScopedValueor method parameters - Load test — watch for pinning events with
-Djdk.tracePinnedThreads=full
Remove any manual thread pool tuning:
# REMOVE these — they're pointless with virtual threads
# server.tomcat.threads.max=200
# server.tomcat.threads.min-spare=10
Virtual Threads are created on demand — there’s no pool to size.
Spring Boot 4 + Java 25
Spring Boot 4 targets Java 25 as its recommended baseline. Java 25 will include:
- Finalized
ScopedValueAPI - Finalized Structured Concurrency API
synchronizedpinning fully removed (was fixed in Java 24)
If you’re on Spring Boot 4 and Java 25, most of the pitfalls above are resolved at the platform level. The main remaining concern is connection pool sizing.
Quick Reference
Enable Virtual Threads
spring.threads.virtual.enabled=true # Spring Boot 3.2+
Detect pinning
-Djdk.tracePinnedThreads=full
Replace synchronized
// Replace synchronized methods/blocks with ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
Replace ThreadLocal
// Java 21+: ScopedValue for immutable, scoped data
private static final ScopedValue<UserContext> CTX = ScopedValue.newInstance();
ScopedValue.where(CTX, value).run(() -> ...);
Remove thread pool config
# Delete these — not needed with virtual threads
# server.tomcat.threads.max=...
# server.tomcat.threads.min-spare=...
Summary
Virtual Threads close the performance gap with WebFlux for I/O-bound workloads while keeping simple, blocking code. Enable them with one property in Spring Boot 3.2+. The pitfalls (synchronized pinning, ThreadLocal memory, connection pool sizing) are manageable and mostly resolved on Java 24+. For new Spring Boot services, Virtual Threads + MVC is the default choice in 2026.
