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).