Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 014 / 100

Virtual threads in Java 21: patterns, pitfalls and fixes

Thread-per-request without the thread cost — blocking code that scales.

Expona Admin8 min read

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:

java
spring:
  threads:
    virtual:
      enabled: true

That'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:

java
@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:

java
@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:

java
@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:

java
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:

java
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:

java
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.enabled unset 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 an ExecutorService without virtual thread support, those are OS threads. They don't scale. Prefer Spring's @Async, which respects the virtual thread setting, or use Executors.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 synchronized blocks for simplicity without realizing the pinning cost. Audit locking around I/O operations and use ReentrantLock or 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 synchronized locks while doing I/O; use ReentrantLock with 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles