Virtual Threads in Java: Scalability Without Complexity
The Problem of Traditional Concurrency
Java applications have historically relied on platform threads (Platform Threads or traditional threads) for concurrency.
Platform Threads (PT):
- Wrappers around OS-managed threads with 1 mapping.
- Expensive and heavyweight, each consuming ~1-2 MB of memory and OS resources.
- Limited by system resources. Creating too many leads to
OutOfMemoryErroror saturates context switching. - In web apps, the "one thread per request" (Thread-per-Request) model is intuitive but doesn't scale well with high concurrency (e.g., >10k concurrent requests).
The Need: In high-concurrency scenarios like microservices, streaming, or trading APIs, low latency and high scalability are required. Solutions like reactive programming (Project Reactor, RxJava) improve throughput but complicate code and debugging.
What Are Virtual Threads (VT)?
Introduced by Project Loom and stabilized in JDK 21 (JEP 444), Virtual Threads are Java's solution for massive concurrency without asynchronous programming.
Key Features:
- Lightweight Threads: Instances of
java.lang.Threadmanaged by the JVM (~1 KB per thread). - Millions of Threads: Allows creating millions of threads without exhausting memory.
- I/O-bound Scalability: Optimized for throughput in blocking tasks (not speed-up in CPU-bound).
- Full Compatibility: Drop-in replacement for existing Thread code. Similar to Go's goroutines.
- Available in JDK 21+: Stable since JDK 21; ongoing improvements in JDK 22-25.
Internal Working: M Scheduling
Mapping M:N: M virtual threads (millions) over N platform threads (CPU cores).
- Carriers: Platform thread that "carries" a VT.
- Mount/Unmount: VT mounts on carrier to execute; unmounts on I/O blocks (
sleep(), sockets, DB queries). - JVM Scheduler: Dynamically reassigns carriers. Carrier stays free during I/O waits.
- Continuation: On I/O completion, VT returns to scheduler and remounts on any available carrier.
This maximizes CPU utilization: carriers always busy.
Recent Updates:
- JDK 24 (JEP 491): Eliminated pinning in
synchronizedblocks (VTs unmount correctly). - JDK 25 (JEP 506): Scoped Values as immutable alternative to
ThreadLocal, preventing memory leaks in VTs.
Practical Examples
Optimized for I/O-bound (HTTP, DB, files).
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 completion
Example 3: Spring Boot + VT (Real Benchmark)
In recent benchmarks (JDK 24 + Spring Boot + Postgres), VTs reduce latency ~50% vs platform threads at 10k req/s.
@RestController
public class ApiController {
@GetMapping("/data")
String getData() {
// VT handles request automatically in Tomcat/Undertow with config
return fetchFromDb(); // Blocks but scales
}
}
Advanced Considerations and Best Practices
CPU-bound vs I/O-bound
| Type | Recommendation | Reason |
|---|---|---|
| I/O-bound (DB, HTTP) | VT + newVirtualThreadPerTaskExecutor() | Massive scalability |
| CPU-bound (crypto, ML) | Platform Threads pool = CPU cores | Avoids carrier starvation |
Issues and Solutions
- Pinning: Resolved in JDK 24 for
synchronized. Avoid JNI/natives. - ThreadLocal: ❌ With VT (memory explosion). Use Scoped Values (JDK 25 preview).
- Metrics/Monitoring:
Thread.currentThread().isVirtual(); JFR supports VTs since JDK 21. - Frameworks: Spring Boot 3.2+, Quarkus, Helidon native.
Best Practices (2025):
- Use VTs by default in web servers.
- Profile with JDK Mission Control/JFR to detect pinning.
- Migrate
ThreadLocalto Scoped Values. - Fixed pools for CPU-intensive.
- Test with wrk/ab for >100k concurrent.
Final Summary
| Profile | Use VT? |
|---|---|
| Beginner | Yes, Thread.startVirtualThread() for all concurrent. |
| Expert | Yes for I/O; No for pure CPU. Monitor pinning. |
Metaphor: PT = Dedicated elevators per person (expensive). VT = Pneumatic tube system: thousands of lightweight capsules in few physical tubes.
Sources: OpenJDK JEPs, Spring Boot benchmarks (Reddit/Java 24), RockTheJVM/RabinaNPatra guides (2025).