Java 21 virtual threads change everything about concurrency. Learn how they work, how to use them in Spring Boot, and when platform threads are still the right choice.
JOptimize Team
Java 21 made virtual threads a stable feature with Project Loom, and they fundamentally change how you think about concurrency in Spring Boot. You can now write simple, blocking code that scales to hundreds of thousands of concurrent requests - without reactive programming, callbacks, or thread pool math.
Traditional Java threads are OS threads - creating one allocates ~1MB of stack space and requires a kernel context switch. A typical server handles 200-500 concurrent platform threads before performance degrades. When your app makes a blocking I/O call (database, HTTP, file), the thread sits idle but still consumes those resources.
// Traditional blocking code - each request ties up a platform thread @RestController public class OrderController { @GetMapping("/orders/{id}") public Order getOrder(@PathVariable Long id) { // Thread is BLOCKED here while DB responds return orderRepository.findById(id).orElseThrow(); } }
With 1000 concurrent requests, this needs 1000 platform threads - ~1GB of stack memory, massive scheduling overhead, and throughput collapse once the pool is exhausted.
Virtual threads are JVM-managed threads multiplexed onto a small pool of carrier (platform) threads. When a virtual thread blocks on I/O, the JVM parks it and reassigns the carrier thread to another virtual thread. No OS thread sits idle.
// Virtual threads are cheap - create millions Thread vt = Thread.ofVirtual().start(() -> { System.out.println("Running on: " + Thread.currentThread()); }); // With ExecutorService: try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { IntStream.range(0, 1_000_000).forEach(i -> executor.submit(() -> { Thread.sleep(Duration.ofMillis(100)); return i; }) ); }
One million virtual threads use only a handful of OS threads and run comfortably on a laptop.
Key point: Virtual threads are NOT faster for CPU-bound work. They solve throughput under I/O blocking, not raw computation speed.
One property switches Tomcat's thread pool to virtual threads:
# application.properties spring.threads.virtual.enabled=true
Spring Boot replaces the Tomcat thread pool, async task executor, and scheduled task executor with virtual thread executors. Your existing blocking controllers now scale like reactive code, with no code changes.
To verify it's active:
@RestController public class ThreadInfoController { @GetMapping("/thread-info") public String threadInfo() { Thread t = Thread.currentThread(); return "Virtual: " + t.isVirtual() + ", Name: " + t.getName(); } }
Structured concurrency (stable in Java 23) makes forking and joining concurrent tasks safe and readable:
import java.util.concurrent.StructuredTaskScope; public record UserProfile(User user, List<Order> orders, AccountStatus status) {} public UserProfile fetchUserProfile(Long userId) throws Exception { try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { var userTask = scope.fork(() -> userService.findById(userId)); var ordersTask = scope.fork(() -> orderService.findByUserId(userId)); var statusTask = scope.fork(() -> accountService.getStatus(userId)); scope.join().throwIfFailed(); // waits for all, cancels on any failure return new UserProfile( userTask.get(), ordersTask.get(), statusTask.get() ); } }
All three calls run in parallel on virtual threads. If any fails, the others are cancelled automatically. No orphaned threads, no manual CompletableFuture exception handling.
A virtual thread is "pinned" (can't unmount from its carrier) inside a synchronized block or when calling native code. Use ReentrantLock instead:
// Bad - pins the virtual thread synchronized (lock) { doBlockingWork(); } // Good - virtual thread can unmount while waiting private final ReentrantLock lock = new ReentrantLock(); lock.lock(); try { doBlockingWork(); } finally { lock.unlock(); }
With millions of virtual threads, per-thread caches (large objects in ThreadLocal) can exhaust memory. Prefer ScopedValue (Java 21+) for request-scoped data.
Virtual threads unblock the thread pool, but your DB still has limited connections. With spring.threads.virtual.enabled=true, tune HikariCP's maximum-pool-size based on DB capacity, not thread count.
Don't spawn virtual threads for pure computation. They'll compete for the same carrier threads and perform worse than a sized thread pool.
| Scenario | Platform Threads | Virtual Threads |
|---|---|---|
| 1000 concurrent I/O requests | ~1GB stack memory | ~10MB |
| Thread creation time | ~1ms | ~1?s |
| Context switch overhead | High (OS) | Low (JVM) |
| CPU-bound tasks | ? Ideal | ? No benefit |
| Blocking I/O tasks | ? Pool exhaustion | ? Scales freely |
Java virtual threads, enabled with a single property in Spring Boot 3.2+, let you write straightforward blocking code that scales to massive concurrency without reactive frameworks. They are the right tool for I/O-heavy applications - REST APIs, database-backed services, microservices calling other services. Avoid them for CPU-bound work and watch out for synchronized pinning.
As you migrate to virtual threads, mutable state in Spring singletons becomes a critical risk. JOptimize scans your codebase for shared mutable fields in @Service and @Component beans, and flags synchronized blocks that will pin virtual threads.
Catch concurrency bugs before switching to virtual threads - free scan, no setup required.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.