Virtual threads in Java 21: patterns, pitfalls and fixes
If you've been running the same thread-per-request architecture for years and watching your thread pool exhaust under moderate load, Java 21 virtual threads offer a path forward that doesn't require reactive rewrites. Virtual threads are cheap JVM-managed threads that let you run millions of them on a handful of OS threads, turning blocking code into something that scales without the traditional OS thread overhead. In this guide, we'll walk through enabling them in Spring Boot, show you how to keep your existing blocking code without refactoring, and help you spot the patterns that can trap a virtual thread and pin it to a carrier thread.
What changed — Virtual threads are cheap JVM-managed threads
For decades, Java's threading model relied on a one-to-one mapping between virtual threads (what the JVM exposes to your code) and OS kernel threads. A typical server could only sustain a few hundred concurrent threads before context-switching overhead and memory pressure became a bottleneck. Virtual threads flip this completely.
Virtual threads are lightweight, stackful continuations managed entirely by the JVM. The JVM schedules them onto a small pool of carrier threads (typically equal to the number of CPU cores). When a virtual thread hits a blocking operation—like a database query or HTTP call—it parks itself, freeing up its carrier thread to run another virtual thread. To the developer, it still feels like blocking code. To the OS, it's just a handful of threads doing all the work.
The practical impact is immediate: you can create millions of virtual threads with no thread pool exhaustion. A request handler that takes 100 ms and waits on a database call? Spawn a million of them if your business logic requires it. The JVM handles the scheduling.
This matters for teams running e-commerce checkouts, CRM request handlers, ride-hailing APIs, or any service that sustains thousands of concurrent requests. You no longer need to tune Tomcat's thread pool size or argue about whether reactive is worth the rewrite.
Enable in Spring Boot — One property switches Tomcat and @Async to virtual threads
Spring Boot 3.2+ makes enabling virtual threads trivial. Add one property to your application.yml or application.properties:
spring:
threads:
virtual:
enabled: trueThat's it. Tomcat automatically switches to virtual threads for request handling. Your @Async tasks run on virtual threads. The rest of your code stays unchanged.
Before this property, you were manually configuring Tomcat's executor:
@Configuration
public class TomcatConfig implements WebServerFactoryCustomizer<TomcatServletWebServerFactory> {
@Override
public void customize(TomcatServletWebServerFactory factory) {
factory.setProtocol("org.apache.coyote.http11.Http11NioProtocol");
factory.addConnectorCustomizers(connector -> {
var executor = new VirtualThreadExecutor("tomcat-vt-");
connector.getProtocolHandler().setExecutor(executor);
});
}
}Now Spring handles it for you. If you need fine-grained control—thread names, queue strategy, rejection policy—you can still customize, but the defaults work well for most teams.
Verify it's enabled by watching your logs on startup. You'll see Tomcat using a VirtualThreadExecutor. To test at runtime, check the thread name in a request handler:
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping("/{id}")
public ResponseEntity<Order> getOrder(@PathVariable Long id) {
String threadName = Thread.currentThread().getName();
System.out.println("Running on: " + threadName);
// Output: Running on: tomcat-vt-1, tomcat-vt-2, etc.
return ResponseEntity.ok(orderService.findById(id));
}
}Virtual thread names typically include "vt" or "virtual." If you see "NioBlockingSelector" or "http-nio," virtual threads aren't active.
Keep blocking code — JDBC and HTTP calls block a virtual thread, not an OS thread
The biggest win for teams already using blocking APIs is that you don't have to change them. Spring Data JPA, standard JDBC, RestTemplate, WebClient—all of them work. The behavior changes only at the OS level.
Here's a typical e-commerce checkout flow that was problematic with OS threads but now thrives:
@Service
@Transactional
public class CheckoutService {
@Autowired
private OrderRepository orderRepo;
@Autowired
private PaymentClient paymentClient;
@Autowired
private InventoryService inventoryService;
public OrderConfirmation checkout(CartRequest cart) {
// All these operations block, but on virtual threads,
// the carrier thread is freed to handle other requests.
Order order = new Order();
order.setItems(cart.getItems());
order.setCustomerId(cart.getCustomerId());
orderRepo.save(order); // JDBC call blocks the virtual thread
PaymentResponse paymentResp = paymentClient.charge(order.getId(), cart.getTotal());
// HTTP call blocks the virtual thread
order.setPaymentId(paymentResp.getTransactionId());
orderRepo.save(order); // Another JDBC call
inventoryService.reserve(cart.getItems()); // Blocking call to inventory service
return new OrderConfirmation(order.getId(), paymentResp.getConfirmation());
}
}With OS threads, this 500 ms flow (100 ms DB, 200 ms payment API, 100 ms inventory) would tie up a thread pool slot. If you had only 200 threads and checkout took 500 ms, you'd exhaust the pool at ~400 concurrent users. With virtual threads, you handle 400,000 concurrent users with the same code.
The reason is transparent to you: when the JDBC driver blocks on network I/O, the virtual thread parks. The JVM unmounts it from its carrier thread. A different virtual thread resumes on that carrier thread. No context switch cost at the OS level. The carrier thread never blocks.
This means teams using CRM systems (where a request might hit multiple services), travel APIs (flight search + hotel search), or ride-hailing platforms (matching + pricing + notifications) can scale without a reactive rewrite.
Watch for pinning — Long synchronized blocks around I/O pin the carrier thread
Virtual threads are not a silver bullet. There's one critical pattern that breaks the scalability promise: holding a lock (via synchronized) while doing I/O.
When a virtual thread holds a monitor lock (a synchronized block) and then blocks on I/O, the JVM cannot park the virtual thread. The lock's internals prevent it. Instead, the entire carrier thread gets pinned. The JVM can't schedule other virtual threads on that carrier. The benefit collapses.
Here's an anti-pattern from a SaaS onboarding flow:
public class UserService {
private final Object lock = new Object();
private Map<String, UserProfile> cache = new HashMap<>();
public UserProfile getOrCreate(String email) {
synchronized (lock) { // Lock held here
if (cache.containsKey(email)) {
return cache.get(email);
}
// PINNING HAPPENS HERE: synchronization block + I/O
UserProfile profile = userRepository.findByEmail(email); // JDBC call
if (profile == null) {
profile = createDefaultProfile(email);
userRepository.save(profile); // Another JDBC call under lock
}
cache.put(email, profile);
return profile;
}
}
}If 1,000 requests hit this method concurrently, they all queue on the lock. Only one carrier thread can hold it at a time, and it blocks while doing I/O. The other carrier threads sit idle. You're back to single-threaded behavior.
The fix is to use ReentrantLock and release the lock before I/O:
public class UserService {
private final ReentrantLock lock = new ReentrantLock();
private Map<String, UserProfile> cache = new HashMap<>();
public UserProfile getOrCreate(String email) {
lock.lock();
try {
if (cache.containsKey(email)) {
return cache.get(email);
}
} finally {
lock.unlock();
}
// I/O happens OUTSIDE the lock
UserProfile profile = userRepository.findByEmail(email); // Virtual thread parks, carrier freed
lock.lock();
try {
// Double-check pattern: another thread might have inserted while we were loading
UserProfile cached = cache.get(email);
if (cached != null) {
return cached;
}
if (profile == null) {
profile = createDefaultProfile(email);
userRepository.save(profile);
}
cache.put(email, profile);
return profile;
} finally {
lock.unlock();
}
}
}Alternatively, use a concurrent cache like ConcurrentHashMap with computeIfAbsent, which avoids explicit locking altogether:
public class UserService {
private final Map<String, UserProfile> cache = new ConcurrentHashMap<>();
public UserProfile getOrCreate(String email) {
return cache.computeIfAbsent(email, key -> {
UserProfile profile = userRepository.findByEmail(email); // Safe under virtual threads
if (profile == null) {
profile = createDefaultProfile(email);
userRepository.save(profile);
}
return profile;
});
}
}The JVM reports pinning events at runtime. Enable warnings with -Djdk.tracePinnedThreads=full to audit your code. In production, run diagnostics on a high-load test and fix pinning hot spots before deploying.
Common mistakes
Teams typically stumble in a few areas:
- Forgetting to enable the property. Virtual threads are opt-in in Spring Boot. Leaving
spring.threads.virtual.enabledunset means Tomcat still uses OS threads. Check startup logs to confirm activation. - Mixing virtual and platform threads. If you spin up a traditional
new Thread()or use anExecutorServicewithout virtual thread support, those are OS threads. They don't scale. Prefer Spring's@Async, which respects the virtual thread setting, or useExecutors.newVirtualThreadPerTaskExecutor()explicitly. - Assuming all blocking libraries are safe. Some older libraries (especially those doing custom thread management or native calls) may not play well with virtual threads. Test thoroughly with your third-party dependencies.
- Over-locking in hot paths. Teams often add
synchronizedblocks for simplicity without realizing the pinning cost. Audit locking around I/O operations and useReentrantLockor concurrent collections instead. - Not monitoring thread count. With virtual threads, thread count metrics become less useful. Focus on request latency, database connection pool saturation, and actual OS thread count (should be low). Pinning shows up as OS thread CPU spikes.
Key takeaways
- Virtual threads are cheap, JVM-managed threads that park instead of blocking an OS thread. Enable them with one Spring Boot property and get million-thread scale on blocking code.
- Keep your JDBC, RestTemplate, and synchronous HTTP calls—they work as-is. The blocking behavior changes only at the OS level, where carrier threads are freed.
- Avoid holding
synchronizedlocks while doing I/O; useReentrantLockwith short critical sections or concurrent collections instead. Run with pinning diagnostics to catch violations. - Virtual threads eliminate the thread pool exhaustion problem for request-per-thread architectures. You no longer need reactive frameworks unless you want them for other reasons.
- Test your code under load with virtual threads enabled. Pinning and lock contention problems emerge under concurrency and may not show in unit tests.






Comments
Loading comments…