Modern Java concurrency has evolved from a 1:1 mapping of Java threads to Operating System (OS) threads toward a highly efficient M:N scheduling model. This transition, finalized in Java 21 via Project Loom, decouples the unit of concurrency (the Virtual Thread) from the unit of scheduling (the Carrier Thread).
Traditional Platform Threads are wrappers around OS threads. They are expensive (~1MB stack pre-allocation) and context-switching requires a kernel transition.
Virtual Threads (VT) are "shallow" objects managed by the JVM. They are mounted and unmounted from Carrier Threads (usually a ForkJoinPool) based on blocking operations.
| Feature | Platform Thread | Virtual Thread |
|---|---|---|
| Memory | ~1MB (reserved) | ~200B - few KB (on heap) |
| Creation | ~1ms (expensive) | ~1µs (cheap) |
| Context Switch | Kernel-level (slow) | User-level (fast) |
| Capacity | Thousands | Millions |
The power of VTs lies in the JVM's ability to yield. When a VT hits a blocking I/O call (e.g., Socket.read()), the JVM catches the call, captures the thread's stack onto the heap, and unmounts it. The carrier thread is immediately free to run another VT.
A Virtual Thread is pinned to its carrier thread if it blocks while:
synchronized block or method.native method or foreign function.Concrete Example: The Database Deadlock Trap
If you use a legacy JDBC driver that uses synchronized heavily, switching to Virtual Threads can paradoxically decrease performance because carrier threads become exhausted by pinned VTs.
// ANTI-PATTERN: This pins the carrier thread
public synchronized String fetchLegacyData() {
return restClient.get(); // Blocking I/O inside synchronized = Pinning
}
// MODERN PATTERN: Use ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
public String fetchModernData() {
lock.lock();
try {
return restClient.get(); // VT yields carrier thread correctly
} finally {
lock.unlock();
}
}
For I/O-bound services, the "Thread Pool" is an anti-pattern. Instead of pooling, you simply create a new thread for every task.
Example: High-Throughput Proxy
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i -> {
executor.submit(() -> {
// High-latency I/O call
var result = httpClient.send(request, BodyHandlers.ofString());
System.out.println("Handled " + i);
return result;
});
});
} // Auto-close waits for all tasks
Virtual Threads are not faster than platform threads for CPU-bound tasks.
Executors.newFixedThreadPool(nCores) or Parallel Streams.ThreadLocal, but if you have 1,000,000 VTs each holding a large ThreadLocal object, you will hit an OutOfMemoryError. Prefer Scoped Values (Java 21+) for VT-intensive applications.See Also: