Virtual Threads (Project Loom) — Carrier Threads, Pinning, and Massive Concurrency
Fast recall
Quick revision
Core answer
java.lang.Thread instance but scheduled by the JVM onto a small pool of carrier threads (OS threads). A typical JVM can run millions of virtual threads on a few dozen carrier threads because virtual threads unmount from their carrier whenever they block on I/O, park(), sleep(), or most blocking operations — freeing the carrier to run another virtual thread. This transforms the classic 'thread-per-request' model from a bottleneck (OS threads are ~1MB each, ~5k max) into a first-class approach (virtual threads are ~1KB, millions scale fine). The crucial caveat is pinning: if a virtual thread enters a synchronized block or a native method (JNI) and then blocks, it can't unmount — it sticks to its carrier, potentially starving other virtual threads. Modern JDK frameworks (JDBC drivers, HttpClient, Jackson) have been updated to use ReentrantLock instead of synchronized to avoid pinning. Virtual threads are created via Thread.ofVirtual().start(runnable) or Executors.newVirtualThreadPerTaskExecutor(). They support all Thread APIs (interrupt, join, ThreadLocal), debug the same way (named stack traces), and work transparently with existing blocking code — no async/await keywords, no CompletableFuture ceremony. Use them for I/O-bound workloads with high concurrency (web servers, microservice clients, database calls). Don't use them for CPU-bound work (no benefit over platform threads) or with thread pinning you can't remove.Whether the candidate understands the virtual thread model (carrier unmounting), the pinning problem, when to use them vs platform threads, and the implications for thread-per-request architectures
Thinking virtual threads make CPU-bound work faster (they don't). Not understanding that `synchronized` causes pinning. Creating a bounded pool of virtual threads (defeats the purpose)
Explain carrier thread count tuning (`jdk.virtualThreadScheduler.parallelism`), discuss pinning diagnostics via `-Djdk.tracePinnedThreads`, mention the ThreadLocal memory implications with millions of threads, and reference the server architecture shift from async back to sync
Interview-ready guide
Interview answer
Virtual threads (Java 21, JEP 444, Project Loom) are JVM-managed lightweight threads — each is a java.lang.Thread but scheduled by the JVM onto a small pool of carrier threads (real OS threads).
Why they matter
- Platform threads are expensive: ~1MB stack, practical limit ~5–10k per JVM
- Virtual threads are ~1KB, can run millions in a single JVM
- Unlock thread-per-request at massive scale, kill the need for reactive
The mount/unmount dance
- Virtual threads run on carrier threads (default: one per CPU core)
- When a virtual thread blocks on I/O,
sleep,park, lock acquisition — it unmounts from its carrier - The carrier is free to run another virtual thread
- When the blocking operation completes, the virtual thread is requeued and mounts on some (possibly different) carrier
- The JVM rewrote all blocking JDK APIs to enable this
Creating virtual threads
Thread.ofVirtual().start(runnable)Executors.newVirtualThreadPerTaskExecutor()— unbounded, one thread per task- Don't pool virtual threads; that defeats the purpose
- For rate limiting, use a
Semaphoreinside, not a bounded pool
Pinning — the critical gotcha
A virtual thread is pinned when it can't unmount despite blocking. Happens in two cases:
1. Inside a `synchronized` block
- Monitors are tied to the OS thread; JVM can't unmount while holding one
- If the thread blocks inside synchronized, the carrier is stuck with it
- Under load, pins can exhaust the carrier pool
2. Inside a native method (JNI)
- Native code often doesn't cooperate with the virtual thread machinery
Diagnosing pinning
-Djdk.tracePinnedThreads=fullprints stack traces at every pin- Run in staging, review output, fix hot paths
Fixing pinning
- Replace
synchronizedwithReentrantLockwhen blocking inside - Move blocking calls out of synchronized blocks
- Avoid blocking JNI
- Most modern JDK classes and drivers (HikariCP 5+, PostgreSQL 42.5+, JDK HttpClient) are already virtual-thread-friendly
- JEP 491 (Java 24+) plans to remove synchronized-induced pinning
When to use virtual threads
YES
- Web/microservice request handlers (Spring Boot 3.2+, Helidon, Quarkus support this)
- HTTP client calls (fan-out/fan-in)
- Database calls with a virtual-thread-friendly pool (HikariCP 5+)
- Message consumers fanning out
- Anywhere thread-per-task was previously prohibitive
NO
- CPU-bound work — no benefit; use
ForkJoinPool.commonPoolor a sized pool - GUI event dispatch threads
- Heavy ThreadLocal workloads with millions of threads (memory blowup)
- Inside synchronized blocks that block (pinning)
ThreadLocal vs ScopedValue
ThreadLocalworks but memory grows with thread countScopedValue(Java 21 preview) is the virtual-thread-friendly successor- Immutable, bounded to a scope, faster, inherited through structured concurrency
- Use for request-scoped data: user ID, trace ID, tenant ID
Carrier tuning
-Djdk.virtualThreadScheduler.parallelism=N— default = CPU cores- Usually defaults are fine
- Increase max pool size if you have unavoidable pinning
Server architecture shift
- Pre-Java 21: high-concurrency Java = reactive (Reactor, WebFlux, Netty async)
- Java 21: sync code + virtual threads is competitive, far simpler
- Many teams migrating back from reactive to sync
- Stack traces are clean; debuggers work; ThreadLocal patterns mostly work
Debugging
- Same tools: jstack, debuggers, profilers
- Stack traces show
VirtualThread[#N] - Each virtual thread has its own stack in jstack
Recommendation: For I/O-bound, high-concurrency workloads — web servers, HTTP clients, database calls — virtual threads are now the default choice on Java 21+. Use `Executors.newVirtualThreadPerTaskExecutor()`, one virtual thread per task, unbounded. Never pool them. For CPU-bound work, stick with platform thread pools sized to CPU cores. Always enable `-Djdk.tracePinnedThreads=full` in staging to catch pinning from synchronized blocks or native code — then replace problematic synchronized with `ReentrantLock`. For request-scoped data (user ID, trace ID), use `ScopedValue` instead of ThreadLocal once it's GA. On Spring Boot 3.2+, set spring.threads.virtual.enabled=true to run the web server on virtual threads; for most microservices this doubles throughput with zero code changes. Don't rush to delete your reactive code if it's working — but for new services, start with sync + virtual threads and reach for reactive only if a specific need (backpressure at the edge, streaming) demands it. Watch for JEP 491 (Java 24+) which will remove synchronized-induced pinning entirely — one of the last adoption blockers.
Virtual Threads (Project Loom) — Carrier Threads, Pinning, and Massive Concurrency
Direct Answer
Virtual threads (Java 21, JEP 444, preview in 19–20) are JVM-managed lightweight threads — each is a java.lang.Thread instance but scheduled by the JVM onto a small pool of carrier threads (OS threads). A typical JVM can run millions of virtual threads on a few dozen carrier threads because virtual threads unmount from their carrier whenever they block on I/O, park(), sleep(), or most blocking operations — freeing the carrier to run another virtual thread. This transforms the classic 'thread-per-request' model from a bottleneck (OS threads are ~1MB each, ~5k max) into a first-class approach (virtual threads are ~1KB, millions scale fine). The crucial caveat is pinning: if a virtual thread enters a synchronized block or a native method (JNI) and then blocks, it can't unmount — it sticks to its carrier, potentially starving other virtual threads. Modern JDK frameworks (JDBC drivers, HttpClient, Jackson) have been updated to use ReentrantLock instead of synchronized to avoid pinning. Virtual threads are created via Thread.ofVirtual().start(runnable) or Executors.newVirtualThreadPerTaskExecutor(). They support all Thread APIs (interrupt, join, ThreadLocal), debug the same way (named stack traces), and work transparently with existing blocking code — no async/await keywords, no CompletableFuture ceremony. Use them for I/O-bound workloads with high concurrency (web servers, microservice clients, database calls). Don't use them for CPU-bound work (no benefit over platform threads) or with thread pinning you can't remove.
Common Mistakes & Pitfalls
Thinking virtual threads make CPU-bound work faster (they don't). Not understanding that synchronized causes pinning. Creating a bounded pool of virtual threads (defeats the purpose)
Check your understanding
Answer these without looking back at the article.
- What is pinning and why is it a problem for virtual threads?
- Why shouldn't you pool virtual threads?
- How does `ScopedValue` differ from `ThreadLocal`?
- When would you prefer platform threads over virtual threads?
- How do virtual threads compare to reactive programming?
Related interview prep categories
1Looking for a structured roadmap?
Every module on this page is also part of the full Java backend interview track.