Virtual Threads in Java: Scalability Without Complexity
Why platform threads hit a wall
Every Java thread is a thin wrapper around an OS thread. One-to-one mapping. Each one eats 1-2 MB of stack space plus kernel resources. Create too many and you hit OutOfMemoryError or burn your CPU on context switching.
The "one thread per request" model works fine when you have a few hundred concurrent users. Push past 10,000 and it falls apart. Reactive frameworks like Project Reactor and RxJava solve the throughput problem, but the cost is code that's hard to read and harder to debug.
What virtual threads actually are
Project Loom shipped virtual threads as stable in JDK 21 (JEP 444). They're real Thread instances, managed by the JVM instead of the OS. Each one costs roughly 1 KB instead of 1-2 MB. You can spin up millions of them without running out of memory.
They're built for I/O-bound work: HTTP calls, database queries, file reads. Not CPU-bound computation. The API is a drop-in replacement for the Thread class you already know, similar in spirit to goroutines in Go.
How M:N scheduling works
M virtual threads run on N platform threads, where N is typically your CPU core count. The platform threads are called carriers. A virtual thread mounts onto a carrier, runs until it blocks on I/O (a socket read, a sleep(), a database query), then unmounts. The carrier picks up another virtual thread. When the I/O completes, the virtual thread remounts on whatever carrier is free.
The net effect: carriers stay busy doing useful work instead of sitting idle on I/O waits.
Two recent JEPs matter. JDK 24 (JEP 491) fixed the pinning problem inside synchronized blocks, so virtual threads unmount cleanly now. JDK 25 (JEP 506) brings Scoped Values as a replacement for ThreadLocal, which leaks memory badly when you have a million threads.
Code examples
Virtual threads shine on I/O-bound workloads.
Example 1: Simple Virtual Thread
Thread.startVirtualThread(() -> {
System.out.println("Hello from VT! " + Thread.currentThread());
});Example 2: ExecutorService (High Concurrency)
import java.util.concurrent.Executors;
import java.time.Duration;
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// 10k concurrent tasks
IntStream.range(0, 10_000).forEach(i ->
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // Simulates I/O
// Business logic
})
);
} // Auto-awaits completionExample 3: Spring Boot + VT (Real Benchmark)
Benchmarks with JDK 24, Spring Boot, and Postgres show roughly 50% lower latency at 10k requests per second compared to platform threads.
@RestController
public class ApiController {
@GetMapping("/data")
String getData() {
// VT handles request automatically in Tomcat/Undertow with config
return fetchFromDb(); // Blocks but scales
}
}When not to use them
For I/O-bound work (database calls, HTTP clients, file I/O), virtual threads with newVirtualThreadPerTaskExecutor() are the right default. For CPU-bound work like cryptography or machine learning inference, stick with a fixed thread pool sized to your core count. Virtual threads on CPU-bound tasks cause carrier starvation: the carrier can't unmount because nothing is blocking, so you gain nothing and eat the scheduling overhead.
Things to watch out for
Pinning inside synchronized blocks is fixed in JDK 24. If you're on an older version, avoid synchronized in hot paths. JNI and native code can still pin, so steer clear of those.
ThreadLocal is a problem at scale. One million threads each with their own ThreadLocal map is a memory explosion. Scoped Values (JDK 25 preview) are the intended replacement.
For monitoring, Thread.currentThread().isVirtual() tells you what you're dealing with. JFR has supported virtual threads since JDK 21. Spring Boot 3.2+, Quarkus, and Helidon all have native support.
What I'd recommend in 2025: use virtual threads as the default for web server workloads. Profile with JDK Mission Control or JFR to catch pinning early. Start migrating ThreadLocal usage to Scoped Values. Keep fixed pools for CPU-intensive tasks. Load test with wrk or ab before shipping high-concurrency targets to production.
Bottom line
If you're new to this, Thread.startVirtualThread() is all you need for most concurrent work. If you're experienced: use virtual threads for I/O, platform threads for CPU, and watch for pinning.
Think of it this way. Platform threads are like giving each person a dedicated elevator: expensive, and you run out of building. Virtual threads are pneumatic tubes: thousands of lightweight capsules sharing a handful of physical pipes, routed efficiently by the system.
Sources: OpenJDK JEPs, Spring Boot benchmarks (Reddit/Java 24), RockTheJVM/RabinaNPatra guides (2025).