What are Virtual Threads in Java 21?
Virtual Threads are lightweight threads managed by the JVM, not the operating system. They became a stable feature in Java 21 (LTS version), but were developed under Project Loo...
Virtual Threads are lightweight threads managed by the JVM, not the operating system. They became a stable feature in Java 21 (LTS version), but were developed under Project Loom since 2017 and went through several preview releases (Java 19-20).
Important caveat: while the API is stable in Java 21, some related features (Scoped Values, Structured Concurrency) are still in preview. This means their API may change in future versions. If you only use basic Thread.ofVirtual(), it is production-ready.
Junior Level
Basic Understanding
Virtual Threads are lightweight threads managed by the JVM, not the operating system. They allow creating millions of threads instead of thousands.
Why regular threads are expensive: each Platform Thread reserves ~1MB of OS memory on creation, even if it uses only a few kilobytes. Additionally, switching between threads requires entering kernel mode, which takes thousands of CPU cycles.
Why virtual threads are cheap: their stack is stored in the regular JVM heap and grows dynamically — from a few kilobytes. Switching happens in user-space without system calls, which is 100-1000x faster.
Simple Analogy
- Platform Threads (regular): like taxis — each requires a car (1MB of memory)
- Virtual Threads: like a bus — many passengers on one vehicle
Creating and Using
// Platform Thread (old way)
Thread platform = new Thread(() -> {
System.out.println("Platform thread");
});
platform.start();
// Virtual Thread (Java 21+)
Thread virtual = Thread.ofVirtual().start(() -> {
System.out.println("Virtual thread");
});
// Or via builder
Thread vThread = Thread.startVirtualThread(() -> {
System.out.println("Virtual thread");
});
Comparison
| Characteristic | Platform Threads | Virtual Threads |
|---|---|---|
| Managed by | OS (Kernel) | JVM (Runtime) |
| Stack Size | ~1 MB (static) | Several KB (dynamic) |
| Creation Time | ~1-10 ms | ~microseconds |
| Maximum | Thousands | Millions |
Example: Thread-per-Request
// With virtual threads — the simple model works again!
ServerSocket server = new ServerSocket(8080);
while (true) {
Socket client = server.accept();
Thread.ofVirtual().start(() -> {
handleRequest(client); // One virtual thread per request
});
}
// Millions of connections — without reactive programming!
Middle Level
Architecture: Continuations
Virtual threads are based on the concept of Continuations:
1. Virtual thread runs code on a Carrier Thread (real OS thread)
2. On a blocking operation (sleep, I/O):
a. JVM saves the virtual thread's stack to the Heap
b. The thread "unmounts" from the Carrier Thread
c. The Carrier Thread goes to execute another virtual thread
3. When I/O completes:
a. JVM restores the stack from the heap
b. The thread "mounts" to a free Carrier Thread
c. Continues execution from where it stopped
Carrier Thread Pool
// Virtual threads share real threads (Carrier Threads)
// Default: ForkJoinPool sized = number of CPU cores
// Example: 1,000,000 virtual threads on 8 Carrier Threads
// Each Carrier Thread handles ~125,000 virtual threads
// in turn, when they unmount
Stack Chunking Mechanism
The JVM stores a virtual thread’s stack as StackChunk objects in the heap:
Virtual Thread:
┌────────────────────────┐
│ StackChunk #1 (heap) │ ← Current stack
│ StackChunk #2 (heap) │ ← Previous frame
│ StackChunk #3 (heap) │ ← Deeper
└────────────────────────┘
On mounting, data is copied from the heap to the real CPU stack; on unmounting — back.
Creating a Virtual Thread Pool
// ExecutorService for virtual threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
// Process the task
Thread.sleep(1000); // Unmounts — doesn't block Carrier
});
}
}
// A million tasks — and the JVM didn't crash!
Senior Level
Under the Hood: Pinning
This is the main trap of virtual threads. A virtual thread can “stick” to a Carrier Thread and not unmount on blocking:
Causes of Pinning
| Cause | Description | Solution |
|---|---|---|
| synchronized block/method | Virtual thread in synchronized does NOT unmount | Replace with ReentrantLock |
| Native methods (JNI) | Native code calls don’t support unmount | Wrap in a separate thread |
// BAD: synchronized blocks the Carrier Thread
synchronized(lock) {
Thread.sleep(10000); // Virtual thread "stuck" to carrier for 10 seconds!
}
// GOOD: ReentrantLock supports unmount
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
Thread.sleep(10000); // Unmounts — Carrier Thread is free
} finally {
lock.unlock();
}
Diagnosing Pinning
# Mandatory flag when developing with Virtual Threads
java -Djdk.tracePinnedThreads=full MyApp
# Will print stack trace every time a thread "sticks"
ThreadLocal and Virtual Threads
// CAUTION: million VT = million copies of ThreadLocal!
ThreadLocal<ExpensiveObject> local = ThreadLocal.withInitial(ExpensiveObject::new);
// Solution: Scoped Values (Java 21 Preview)
static final ScopedValue<UserContext> CONTEXT = new ScopedValue<>();
ScopedValue.runWhere(CONTEXT, new UserContext("user123"), () -> {
// CONTEXT.get() is available inside
});
// After exit — automatically cleaned, no leaks
Performance and Highload
Thread-per-Request Returns
Before VT:
1000 platform threads max → Reactive/CompletableFuture → Callback Hell
With VT:
1,000,000 virtual threads → Thread-per-request → Simple blocking code
Spring Boot 3.2+
# application.yml
spring:
threads:
virtual:
enabled: true
# All of Tomcat/Undertow switches to virtual threads!
Carrier Thread Pool Sizing
// Default: ForkJoinPool.commonPool()
// Size = number of logical CPU cores
// Customization:
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "16");
Diagnostics
JFR Events
java -XX:StartFlightRecording=filename=rec.jfr MyApp
Events:
jdk.VirtualThreadStart/jdk.VirtualThreadEndjdk.VirtualThreadPinned— critical for monitoring
jcmd for Dumps
# jstack may "freak out" with a million threads
jcmd <pid> Thread.dump_to_file -format=json threads.json
# JSON format is more efficient for analysis
Memory Analysis
Million virtual threads = million objects in heap
Each VT ~ several KB (stack) + objects
Check -Xmx before launch!
Recommended: at least 256MB for a million VT
When VT Do NOT Provide Benefits
| Scenario | Why | Alternative |
|---|---|---|
| CPU-bound tasks | VT don’t unmount, scheduler overhead | FixedThreadPool (N+1) |
| synchronized-heavy code | Pinning — blocks Carrier Threads | ReentrantLock |
| Limited resources | Million VT will kill the DB | Semaphore |
Best Practices
- Use VT for I/O-bound — web servers, API gateways, proxies
- Replace synchronized with ReentrantLock — avoid pinning
- Use Semaphore — for limiting access to scarce resources
- Be careful with ThreadLocal — million copies = lots of memory
- Scoped Values — for context propagation (Java 21 Preview)
- -Djdk.tracePinnedThreads=full — during development
- Don’t use VT for CPU-bound — FixedThreadPool is better
- Monitor jdk.VirtualThreadPinned — via JFR
When NOT to Use Virtual Threads
- Below Java 21 — VT not available. For Java 17 use reactive stack (WebFlux, CompletableFuture)
- CPU-bound tasks (computations, ML, cryptography) — VT don’t unmount during computation, only add scheduler overhead. Use
Executors.newFixedThreadPool(N)where N = number of cores + 1 - synchronized-heavy legacy code — pinning will block Carrier Threads. Refactor to ReentrantLock first
- Limited resources (DB pool of 20 connections) — million VT will kill the DB. Use Semaphore
- ThreadLocal-heavy applications — million VT = million ThreadLocal copies = OutOfMemoryError. Switch to Scoped Values
Virtual Threads vs Platform Threads vs ExecutorService: What to Choose?
| Situation | Choice | Why |
|---|---|---|
| Web server, API gateway (I/O-bound) | Virtual Threads | Millions of concurrent connections, simple code |
| Computations (ML, parsing) | FixedThreadPool | VT don’t unmount, overhead without benefit |
| Legacy with synchronized | FixedThreadPool + refactoring | VT pinning will kill performance |
| Limited resource (DB, API limit) | VT + Semaphore | VT scale, Semaphore limits |
Interview Cheat Sheet
Must know:
- Virtual Threads (VT) — lightweight threads managed by JVM, not OS; stack in heap (dynamic), not native stack (static 1MB)
- On blocking I/O, VT “unmounts” — JVM saves stack to StackChunk and gives Carrier Thread to another VT
- Carrier Thread Pool defaults to ForkJoinPool sized = number of CPU cores
- Pinning: synchronized and native methods prevent VT from unmounting — Carrier Thread is blocked; solution: ReentrantLock
- ThreadLocal + million VT = OutOfMemoryError; alternative: Scoped Values (Java 21 Preview)
- VT for I/O-bound (web servers, APIs), NOT for CPU-bound (computations)
- Spring Boot 3.2+:
spring.threads.virtual.enabled=true
Frequent follow-up questions:
- Why aren’t VT suitable for CPU-bound tasks? — VT don’t unmount during computation, only add JVM scheduler overhead
- What is StackChunk? — An object in the heap storing a virtual thread’s stack; on mounting, copied to the real CPU stack
- How to diagnose pinning? — Flag
-Djdk.tracePinnedThreads=fulloutputs stack trace on every pinning event - How many VT can be created? — Millions; the limit is heap (recommended at least 256MB for a million VT)
Red flags (DO NOT say):
- “Virtual Threads are the same as goroutines in Go” — VT are similar to goroutines but run on JVM with Carrier Threads
- “VT are always faster than Platform Threads” — VT are faster only for I/O-bound; for CPU-bound VT are slower due to overhead
- “You can just replace all synchronized with VT without changes” — synchronized causes pinning, need to replace with ReentrantLock
- “VT are incompatible with ExecutorService” —
Executors.newVirtualThreadPerTaskExecutor()is the standard way to work with VT
Related topics:
- [[24. What are the advantages of Virtual Threads over regular threads]]
- [[25. When should you use Virtual Threads]]
- [[26. What is structured concurrency]]
- [[27. What is the difference between Thread and Runnable]]