Spring Boot 4 + Java 21 Virtual Threads: How I/O Throughput Changes Dramatically Without Touching Your Code
Have you ever introduced WebFlux to solve concurrency issues, only to spend months getting your entire team up to speed on the reactive paradigm? I have. Callbacks calling callbacks, stack traces impossible to read, and that creeping feeling of "is this actually better?" — I remember it vividly. A practical alternative that spares you that pain has finally arrived.
In November 2025, Spring Boot 4.0 was officially released, bringing the easiest possible environment for using Virtual Threads, which were formally introduced in Java 21. Add a single line to application.properties and I/O throughput improves dramatically with almost no changes to your existing MVC code. By the end of this article, you'll be able to apply virtual threads to an existing Spring MVC project with just a config file change and optional lock pattern adjustments.
This article covers why virtual threads work, how to use them in Spring Boot 4, and the pitfalls that will quietly bite you if you miss them. I'll also call out commonly misunderstood code patterns, so I encourage you to read all the way through.
Core Concepts
The Limits of Platform Threads, and the JVM's Solution
Let's drop down to the JVM level for a moment to understand why this is even possible.
Traditional Java threads (platform threads) map 1:1 to OS threads. A single OS thread consumes several MB of memory, and context switching carries a non-trivial cost. As a result, the thread pool size (typically around 200) was the effective concurrency ceiling for traditional Spring MVC applications.
Consider a database query that takes 100ms — that thread just sits there waiting for the full 100ms. From the OS's perspective, that resource is tied up, and any new incoming request has to wait in a queue if no free thread is available. This is the classic bottleneck in I/O-bound environments.
Platform Thread: Java's traditional thread, directly backed by an OS thread. Expensive to create; creating thousands of them causes memory and scheduling overhead to spike dramatically.
Virtual threads solve this problem at the JVM level. The core idea is simple:
- The moment a blocking I/O call occurs, the JVM automatically unmounts the virtual thread from its carrier thread.
- The carrier thread immediately goes off to handle another virtual thread.
- Once the I/O completes, the virtual thread is remounted onto an available carrier thread and resumes execution.
t=0ms Request A → Virtual Thread A ─── DB query starts → unmount
↓ carrier thread released
t=5ms Request B → Virtual Thread B ─── carrier thread reused → DB query starts → unmount
t=10ms Request C → Virtual Thread C ─── carrier thread reused → ...
t=100ms DB response for A arrives → Virtual Thread A remount → resumes processing → response sent
t=105ms DB response for B arrives → Virtual Thread B remount → ...From the developer's perspective, there's no async/await, no Mono/Flux. Your existing imperative try { ... } catch style code runs asynchronously as-is.
Carrier Thread: The OS thread that actually executes virtual threads. Only a small number are maintained (scaled to CPU core count), and they schedule millions of virtual threads.
Integration in Spring Boot 4 — What "Officially Fully Supported" Means
Spring Boot 4.0 is the first major Spring Boot release to officially give virtual threads first-class citizen status at both the language and framework level. This single line is all it takes:
# application.properties
spring.threads.virtual.enabled=trueEnabling this setting causes Spring MVC's Tomcat request handling, @Async tasks, and @Scheduled jobs to all run on virtual threads. Spring Security's SecurityContextHolder, JPA transactions, and Spring's various per-thread state mechanisms all continue to work without any code changes — the Spring team has already done the internal infrastructure work to account for virtual threads.
The Java baseline is 17, but Java 21 or higher is recommended for optimal virtual thread performance. Java 24 is even better, and the reason why is explained in the pitfalls section below.
Practical Application
Example 1: Basic Configuration — Switching MVC with a Single Config Line
This is the fastest way to get started. Just bump the dependency version in pom.xml (or build.gradle) for your existing Spring MVC project and add one line to your config.
<!-- pom.xml -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.0</version>
</parent>
<properties>
<java.version>21</java.version>
</properties># application.properties
spring.threads.virtual.enabled=true
# HikariCP — no need to inflate the pool size in a virtual thread environment
spring.datasource.hikari.maximum-pool-size=50The maximum-pool-size=50 figure isn't arbitrary. The DB connection pool should be sized to match the number of concurrent connections your DB server can handle, independently of the virtual thread count. A common starting point is (DB server core count × 2) + headroom, then tune based on actual measurements.
| Setting | Role |
|---|---|
spring.threads.virtual.enabled=true |
Switches MVC request threads, @Async, and @Scheduled to virtual threads |
hikari.maximum-pool-size |
Limits DB connections to a separate pool regardless of virtual thread count, preventing excessive connections |
Example 2: @Async Tasks — Explicit Executor Configuration + Exception Handling
With spring.threads.virtual.enabled=true in Spring Boot 4, the default @Async executor is automatically switched over. If you're on Spring Boot 3.x or want explicit control over the executor, you can configure it as follows:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
// Creates a new virtual thread for each task
return Executors.newVirtualThreadPerTaskExecutor();
}
}@Service
public class NotificationService {
@Async
public CompletableFuture<Void> sendEmail(String recipient) {
try {
// Existing blocking code unchanged — carrier thread is automatically released on I/O wait
emailClient.send(recipient);
return CompletableFuture.completedFuture(null);
} catch (Exception e) {
// Exceptions in @Async propagate through the returned Future
// The caller can handle them with .exceptionally() or .handle()
return CompletableFuture.failedFuture(e);
}
}
}Exceptions from an @Async method are wrapped inside the returned CompletableFuture. If you skip exception handling, errors can be silently swallowed until the caller invokes Future.get() or .join(). In production, always make sure exception paths are handled.
Example 3: Parallel DB Queries — Leveraging Concurrent I/O with Virtual Threads
This is the quintessential I/O-intensive scenario. There's a common misconception here — I'll be honest, it tripped me up at first too. When fetching details for multiple products concurrently, there's a temptation to reach for parallelStream(), but this has nothing to do with virtual threads.
⚠️
parallelStream()usesForkJoinPool.commonPool()(a thread pool designed for CPU-parallel operations, separate from virtual threads). Even with virtual threads enabled, work insideparallelStream()does NOT run on virtual threads.
To execute DB queries concurrently on virtual threads, the correct approach is to combine Executors.newVirtualThreadPerTaskExecutor() with CompletableFuture, as shown below:
@Service
public class ProductService {
private final ProductRepository productRepository;
public List<ProductDetail> fetchAllDetails(List<Long> ids) {
// newVirtualThreadPerTaskExecutor: runs each task on a new virtual thread
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = ids.stream()
.map(id -> CompletableFuture.supplyAsync(
() -> productRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("id: " + id)),
executor))
.toList();
// Wait until all Futures have completed
return futures.stream()
.map(CompletableFuture::join)
.toList();
}
}
}While each findById waits for a DB response, it releases the carrier thread, allowing other queries to fill the gap. N queries proceed almost simultaneously.
| Approach | 10 queries × 100ms each | Notes |
|---|---|---|
| Sequential platform threads | ~1,000ms | One at a time |
| Parallel platform threads | ~100ms (requires 10 threads) | Thread creation cost incurred |
| Virtual threads (code above) | ~100ms | Near-zero thread creation cost |
Example 4: @Scheduled Tasks — Replacing the Scheduler
@Configuration
public class SchedulingConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
// setScheduler() requires a ScheduledExecutorService
// Since newVirtualThreadPerTaskExecutor() returns an ExecutorService,
// use a ScheduledThreadPool with a virtual thread factory instead
taskRegistrar.setScheduler(
Executors.newScheduledThreadPool(1, Thread.ofVirtual().factory())
);
}
}For batch scheduled jobs that make many external API calls or perform heavy DB I/O, this single configuration change can significantly reduce execution time. Note that passing newVirtualThreadPerTaskExecutor() directly will cause a compile error.
Pros and Cons
Advantages
These numbers might look abstract at first, but according to TechEmpower benchmark plaintext scenario results (2025), a virtual thread-based server on a 16-core machine achieved approximately 1.2 million req/s. WebFlux on the same hardware came in at around 900k req/s — meaning virtual threads actually edge ahead in simple I/O-intensive scenarios. Keep in mind that results vary significantly for DB query or JSON serialization scenarios within the same benchmark, so direct measurement against your own application's characteristics is strongly recommended.
| Item | Details |
|---|---|
| Code simplicity | Retains existing imperative style; no reactive paradigm migration required |
| High throughput | Handles millions of concurrent requests without wasting OS threads on I/O waits |
| Debugging ease | Intuitive stack traces; no need to trace reactive callback chains |
| Backward compatibility | Spring Security, JPA, transactions, etc. all work without code changes |
| Low memory overhead | Minimal memory footprint compared to platform threads (KB vs MB) |
Drawbacks and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Pinning | Carrier thread blocks if I/O occurs inside a synchronized block (Java 21–23) |
Replace with ReentrantLock, or use Java 24+ |
| Ineffective for CPU-bound work | No benefit for CPU-intensive tasks like encryption or image processing | Keep such tasks on ForkJoinPool |
| ThreadLocal memory | ThreadLocal memory usage can spike as virtual thread count grows |
Consider replacing with ScopedValue (finalized in Java 23) |
| Connection pool tuning | DB connection pool can become a bottleneck under request bursts | Tune HikariCP pool size to match I/O patterns |
Pinning: The phenomenon where a virtual thread, upon encountering blocking I/O inside a
synchronizedblock or JNI code, cannot unmount from its carrier thread and remains stuck. JEP (Java Enhancement Proposal) 491 redesigned thesynchronizedmonitor implementation in Java 24, resolving this at the root.
To check whether pinning is occurring, use the following JVM flag for diagnostics:
-Djdk.tracePinnedThreads=fullIf issues are found, replacing synchronized with ReentrantLock is the most reliable fix:
// ❌ Causes pinning — carrier thread gets stuck when I/O occurs inside synchronized block
synchronized (lock) {
result = dbRepository.findAll(); // blocking I/O
}
// ✅ Virtual thread-friendly — ReentrantLock allows unmount/remount
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
result = dbRepository.findAll();
} finally {
lock.unlock();
}ScopedValue was introduced as a preview in Java 21 and finalized in Java 23. Using it on Java 21 requires the --enable-preview compile option, so Java 23 or higher is recommended for stable usage.
ScopedValue: The modern alternative to
ThreadLocal(finalized in Java 23). It is immutable and propagates down the call stack, making it far more memory-efficient than having tens of thousands of virtual threads each carrying their ownThreadLocalvalues. Think of it as posting information on a shared bulletin board rather than keeping it in a personal drawer.
The Most Common Mistakes in Practice
-
"CPU-bound tasks will also get faster with virtual threads" — The benefit of virtual threads lies in making carrier threads available to other work during I/O wait time. There's no effect on computations that continuously occupy the CPU (hash calculation, image resizing, etc.), and you may actually incur additional scheduling overhead.
-
Inflating HikariCP pool size without limit — If thousands of virtual threads simultaneously attempt to connect to the DB, the DB server won't be able to keep up. Because virtual threads release carrier threads even while waiting for a DB connection, a small pool can handle a large number of requests. Keep the pool conservatively sized to match your DB server's capacity.
-
Confusing
parallelStream()with virtual threads — As explained in Example 3,parallelStream()usesForkJoinPool.commonPool(). It operates independently of virtual thread configuration. For parallel I/O, useExecutors.newVirtualThreadPerTaskExecutor()+CompletableFuture.supplyAsync().
Closing Thoughts
The core promise of Spring Boot 4 + virtual threads is "same code, dramatically better concurrency." Without requiring your entire team to learn the reactive paradigm, there is now a practical path to multiplying I/O throughput on an existing imperative codebase. There are real-world cases of request throughput increasing several times over in I/O-intensive environments — and the numbers you measure in your own application will be more convincing to your team than any TechEmpower figure (which shows roughly a 33% advantage over WebFlux on identical hardware).
Three steps you can take right now:
- Upgrade to Java 21 or higher and migrate to Spring Boot 4.0. Bump
spring-boot-starter-parentto4.0.0and addspring.threads.virtual.enabled=truetoapplication.properties. Most existing code will work without modification. - Audit your codebase for pinning with the
-Djdk.tracePinnedThreads=fullflag. Ifsynchronizedblocks contain DB queries or external HTTP calls, consider replacing them withReentrantLock. On Java 24 or higher, this concern is largely resolved. - Run before-and-after load tests with k6 or Gatling. Gradually increase concurrent user count and observe how response time and throughput change — this is the most compelling way to demonstrate the value of virtual threads to your team.
References
- Spring Boot 4 and Virtual Threads: A Practical, High-Impact Upgrade | Medium
- Virtual Threads in Spring Boot 4: What Actually Changes for Your Code | Javarevisited
- Virtual Threads and Reactive Evolution in Spring Boot 4 | CodeToDeploy
- Unlocking Peak Performance: Java Virtual Threads with Spring Boot 4.0 | Code Mill Hub
- Spring Boot Performance with Java Virtual Threads | Java Code Geeks
- Java Virtual Threads: The Pinning Problem, the Deadlock, and the Fix in Java 24
- Virtual Threads vs WebFlux: Java Concurrency in 2026 Guide
- Virtual Threads + HikariCP: The 2025 Formula | Medium
- Spring Boot 4 Migration Guide | OpenLogic
- Embracing Virtual Threads | Spring.io Official Blog
- Virtual Threads Performance in Spring Boot | FastThread Blog