Virtual Threads vs Platform Threads in Java 21 — Complete Comparison
Fast recall
Quick revision
Core answer
java.lang.Thread instances backed 1:1 by OS kernel threads — ~1 MB stack, ~1 ms creation, scheduled preemptively by the OS, limited to ~thousands per JVM. Virtual threads (Java 21 JEP 444) are lightweight JVM-managed threads scheduled onto a small pool of carrier platform threads. Stack is resizable (stored on the heap, KB not MB), creation is microseconds, and you can have millions concurrently. Both are subclasses of java.lang.Thread and expose the same API, so existing code just works. The tradeoffs: virtual threads pin on synchronized blocks and native frames (JVM can't unmount them), which under load can starve carriers. They don't add CPU parallelism — parallelism is still capped at CPU count. Use virtual threads for I/O-bound server code (REST APIs, message handlers, HTTP fan-out). Use platform threads for CPU-bound pools (ForkJoinPool) and when working with legacy synchronized-heavy code you can't refactor.Whether the candidate understands the fundamental differences (OS-backed vs JVM-scheduled, memory footprint, blocking behavior) and can pick the right type for a given workload
Assuming virtual threads always beat platform threads. Not recognizing that CPU-bound work doesn't benefit. Forgetting about pinning
Mention concrete numbers (1MB vs KB, thousands vs millions), the mount/unmount scheduling model, pinning on synchronized, and that `spring.threads.virtual.enabled=true` on Spring Boot 3.2+ is a one-line migration for request handlers
Interview-ready guide
Interview answer
Platform threads and virtual threads differ fundamentally in how they're scheduled and what they cost.
Platform threads — the classic
- 1:1 mapped to OS kernel threads
- ~1 MB stack, created via syscall (~1 ms)
- Scheduled preemptively by the OS
- Memory per thread dominates — thousands is the practical ceiling
- Good for CPU-bound work (ForkJoinPool, fixed pool sized to cores)
Virtual threads — Java 21 Project Loom
- Scheduled by the JVM onto a small pool of carrier platform threads (by default, ForkJoinPool sized to CPU count)
- Stack stored on the heap as a resizable object — KB, not MB
- Creation is microseconds, pure Java
- Millions concurrent
- Subclass of
java.lang.Thread— same API, existing code just works - Can detect with
Thread.currentThread().isVirtual()
The mount/unmount mechanism
- Virtual thread starts running on a carrier
- Hits blocking I/O — JDK's instrumented blocking call triggers
Continuation.yield() - Stack copied to heap; carrier freed to serve another virtual thread
- I/O completes — kernel notification wakes the virtual thread
- Scheduler picks any free carrier; continuation restored; execution resumes
The killer numbers
- Platform: ~1 MB → max ~5K-10K threads
- Virtual: ~few KB → millions
- Parallelism unchanged: still capped at CPU count
- Concurrency: limited by heap, not thread count
Creating each
- Platform:
new Thread(),Executors.newFixedThreadPool(n) - Virtual:
Thread.ofVirtual().start(),Executors.newVirtualThreadPerTaskExecutor(),Thread.startVirtualThread() - Spring Boot 3.2+:
spring.threads.virtual.enabled=true
Don't pool virtual threads — creation is free; one-per-task is the pattern
The gotchas
Pinning on `synchronized`:
- While inside
synchronized, virtual thread can't unmount - Blocking I/O inside synchronized holds a carrier
- Under load: carrier exhaustion; system appears frozen
- Fix: migrate to
ReentrantLock(Loom-aware viaLockSupport.park) - Detect:
-Djdk.tracePinnedThreads=fullor JFRjdk.VirtualThreadPinned
Pinning on native (JNI) frames:
- Fundamental; native code can't participate in continuation mechanism
- Usually fine unless hot path does heavy native work
ThreadLocal at scale:
- Each virtual thread carries its own copy
- Millions of threads × large ThreadLocals = heap pressure
- Alternative:
ScopedValue(Java 21 preview) for immutable per-scope values
Downstream saturation:
- Old 200-thread pool implicitly rate-limited DB connections
- Virtual threads remove that limit; must add explicit
Semaphoreor Bulkhead
Decision guide
- CPU-bound work → platform threads (ForkJoinPool)
- I/O-bound server code → virtual threads
- Legacy synchronized-heavy → platform threads or refactor first
- Java < 21 → no choice, platform threads
- Hybrid: virtual for I/O, platform ForkJoinPool for CPU slices offloaded via CompletableFuture
Recommendation: On Java 21+, default to virtual threads for server-side I/O-bound code — Executors.newVirtualThreadPerTaskExecutor() or Spring Boot's spring.threads.virtual.enabled=true. Keep platform threads for CPU-bound pools (ForkJoinPool.commonPool, fixed platform-thread pools sized to cores). Before migrating existing code, audit for `synchronized` around blocking I/O (migrate to ReentrantLock) and large ThreadLocal state (reduce or move to ScopedValue), and add explicit downstream flow control via Semaphore because thread count no longer implicitly rate-limits you. Don't rewrite working Netty/WebFlux systems just because virtual threads exist — the migration cost exceeds the benefit; save them for new code and thread-per-request refactors.
Virtual Threads vs Platform Threads in Java 21 — Complete Comparison
Direct Answer
Platform threads are traditional java.lang.Thread instances backed 1:1 by OS kernel threads — ~1 MB stack, ~1 ms creation, scheduled preemptively by the OS, limited to ~thousands per JVM. Virtual threads (Java 21 JEP 444) are lightweight JVM-managed threads scheduled onto a small pool of carrier platform threads. Stack is resizable (stored on the heap, KB not MB), creation is microseconds, and you can have millions concurrently. Both are subclasses of java.lang.Thread and expose the same API, so existing code just works. The tradeoffs: virtual threads pin on synchronized blocks and native frames (JVM can't unmount them), which under load can starve carriers. They don't add CPU parallelism — parallelism is still capped at CPU count. Use virtual threads for I/O-bound server code (REST APIs, message handlers, HTTP fan-out). Use platform threads for CPU-bound pools (ForkJoinPool) and when working with legacy synchronized-heavy code you can't refactor.
Common Mistakes & Pitfalls
Assuming virtual threads always beat platform threads. Not recognizing that CPU-bound work doesn't benefit. Forgetting about pinning
Check your understanding
Answer these without looking back at the article.
- Why don't virtual threads increase CPU parallelism?
- What happens when a virtual thread calls `Thread.sleep`?
- How does `synchronized` pinning actually starve carriers?
- What's `ScopedValue` and when would you use it over `ThreadLocal`?
- Why shouldn't you pool virtual threads?
Related interview prep categories
2Looking for a structured roadmap?
Every module on this page is also part of the full Java backend interview track.