Why this matters
- Traditional servlet containers allocate one platform thread per request — under load, thousands of threads waiting on database or payment calls waste memory and context-switch time.
- Virtual threads are lightweight: blocking on I/O releases the underlying carrier thread so another virtual thread can run, giving you reactive-style concurrency with blocking code.
- Spring Boot 3.2+ enables virtual threads for Tomcat,
@Async, and scheduled tasks with a single property — no rewrite to WebFlux required.
Where Boot enables virtual threads
spring.threads.virtual.enabled=true— switches Tomcat's request handling to virtual threads.@Asyncexecutor — auto-configured to use virtual threads when the property is set.@Scheduledtasks — run on virtual threads in the same configuration.- Carrier pool — a small set of platform threads that multiplex many virtual threads.
- Pinning — when code holds a monitor (
synchronized) or uses native code during I/O, the virtual thread pins its carrier.
Platform threads (carriers)
VT-1Blocked on I/O
VT-2Running
VT-3Blocked on I/O
Blocked virtual threads release their carrier — other virtual threads continue
Enable virtual threads
spring:
threads:
virtual:
enabled: true
That single property switches the embedded Tomcat connector, the default @Async executor, and the task scheduler to virtual threads. No other code changes are required for a typical BookStore REST API.
Verify with a diagnostic endpoint
@RestController
@RequestMapping("/api/diagnostics")
public class ThreadDiagnosticsController {
@GetMapping("/thread")
public Map<String, String> currentThread() {
Thread t = Thread.currentThread();
return Map.of(
"name", t.getName(),
"virtual", String.valueOf(t.isVirtual()),
"class", t.getClass().getSimpleName()
);
}
}
A response with "virtual": "true" confirms Tomcat is dispatching requests on virtual threads.
Custom executor for background work
@Configuration
@EnableAsync
public class VirtualThreadAsyncConfig {
@Bean(name = "bookstoreTaskExecutor")
public Executor bookstoreTaskExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}
@Service
public class ReportGenerationService {
@Async("bookstoreTaskExecutor")
public void generateMonthlySalesReport(int year, int month) {
List<Order> orders = orderRepository.findByMonth(year, month);
byte[] pdf = pdfRenderer.render(orders);
storageService.upload("reports/" + year + "-" + month + ".pdf", pdf);
}
}
When virtual threads are not enough
// Pinning risk: synchronized block during external call
public synchronized InventoryStatus checkStock(String isbn) {
return inventoryClient.fetch(isbn); // pins carrier while blocked
}
// Prefer ReentrantLock or move synchronization after the I/O
public InventoryStatus checkStock(String isbn) {
InventoryStatus status = inventoryClient.fetch(isbn);
return lockAndUpdateCache(isbn, status);
}
Quick recall
Everything you need if you only revisit this box.
spring.threads.virtual.enabled=trueswitches Tomcat,@Async, and scheduling to virtual threads.- Virtual threads release their carrier when blocked on I/O — thousands of concurrent requests, few platform threads.
- Avoid
synchronizedaround blocking I/O — it pins the carrier and defeats the purpose. - CPU-bound work still needs a traditional thread pool with bounded size.
- Virtual threads are the pragmatic alternative to rewriting BookStore as WebFlux.
Test yourself
Answer these before moving on — recall is what makes it stick.