Java Virtual Threads
— Complete Guide
The biggest concurrency change in Java's 25-year history. Carrier threads, mount/unmount, Continuation objects, Spring Boot integration, thread pinning, Structured Concurrency, and 8 senior interview Q&As.
Platform Threads Don't Scale
Platform Thread Cost
- Each thread = ~1MB OS stack memory (native, not heap)
- OS allocates it at thread creation — never GC'd until thread exits
- OS context switching between threads is expensive
- Tomcat default pool: 200 threads = 200MB OS memory locked
- Thread blocked on I/O = wasted resource, doing nothing
The Real-World Problem
- REST API calls a database — 20ms wait
- Thread is BLOCKED for that 20ms
- Can't serve other requests during the wait
- Under load: thread pool exhausted at 200 requests
- Result: timeouts, 503 errors, latency spikes
Platform Threads (200 max) — serving 200 concurrent API calls
Virtual Threads — millions available, carriers always busy
How Virtual Threads Work
Carrier Threads
Small pool of OS "carrier" threads — one per CPU core via ForkJoinPool (work-stealing). Virtual threads mount onto carriers to run. The OS only sees carriers — never the virtual threads.
Mount / Unmount
When a virtual thread hits a blocking call (DB, HTTP, sleep), the JVM unmounts it from the carrier. The carrier immediately picks up another virtual thread. Zero wasted OS time.
Heap-Stored Stack
Virtual thread stacks live on the Java heap — not OS memory. Start at ~1–2 KB, grow dynamically. Eligible for GC when done. Invisible to the OS scheduler.
synchronized breaks it — the monitor lock is tied to the carrier OS thread, the Continuation can't be detached while a monitor is held.Mount / Unmount Lifecycle — HTTP request with DB call
new virtual thread per request. No thread pool. No queuing. Instant.Creating Virtual Threads — 3 Ways
- ▸Thread.ofVirtual().start() — creates and immediately starts a virtual thread. Name is optional but useful for debugging stack traces.
- ▸isVirtual() — new in Java 21. Confirm a thread is virtual in logs:
Thread.currentThread().isVirtual() - ▸newVirtualThreadPerTaskExecutor() — creates a new virtual thread per submitted task. This is the preferred executor for production. Do NOT use a fixed pool.
- ▸Anti-pattern: Executors.newFixedThreadPool(100, Thread.ofVirtual().factory()) — pooling virtual threads defeats their purpose. They're nearly free to create; pooling adds complexity for zero benefit.
- ▸Spawning 100,000 virtual threads is normal and expected. Each uses only a few KB of heap. This would crash with platform threads (~100GB of stack memory needed).
Enable Virtual Threads — Spring Boot 3.2+
- ▸Spring Boot 3.2 — spring.threads.virtual.enabled=true configures Tomcat, Spring's task executor, and @Scheduled tasks all at once. One line.
- ▸Your existing @RestController, @Service, @Repository code needs zero changes. Write normal synchronous Spring Boot code.
- ▸HikariCP gotcha: Default pool = 10 connections. With VTs you can now have thousands of concurrent requests — all wanting a DB connection. If pool stays at 10–200, threads will wait on the pool, not on the DB. Set maximum-pool-size based on your actual database capacity (e.g. 50 for PostgreSQL).
- ▸HikariCP 5.1.0+ is virtual-thread aware. Spring Boot 3.2 auto-configures pool size correctly when VTs are enabled.
Structured Concurrency — Parallel Calls Made Simple
- ▸scope.fork() — spawns a virtual thread for each task. Both run in parallel. Calling thread waits at scope.join() without blocking a carrier thread.
- ▸ShutdownOnFailure — if either subtask fails, the scope cancels both automatically. No orphaned threads. Far cleaner than CompletableFuture exception chaining.
- ▸ShutdownOnSuccess — cancels all remaining forks as soon as the first one succeeds. Classic use case: query multiple pricing vendors simultaneously and return the fastest/cheapest result.
- ▸This replaces CompletableFuture.allOf() patterns with readable synchronous-style code. No callbacks, no thenCompose, no thenApply chains. Old = ~15 lines. New = 6 lines.
- ▸CompletableFuture is not deprecated — passing a VT executor to supplyAsync() is still valuable. Every thenApply/thenCompose stage runs on its own VT. Blocking in any stage no longer holds a platform thread.
Thread Pinning — The Hidden Killer
When Does Pinning Occur?
- Blocking inside a synchronized block or method
- Native method calls (JNI)
- Foreign function calls
Java's monitor locks (synchronized) are fundamentally tied to the carrier OS thread. The JVM cannot detach the Continuation while a monitor is held.
How to Fix Pinning
- Replace synchronized with ReentrantLock
- ReentrantLock uses a mechanism that is virtual-thread aware
- HikariCP 5.1+, Lettuce 6.3+, Kafka 3.6+ already use ReentrantLock internally
- Audit only your own code for synchronized
- ▸ReentrantLock is virtual-thread-aware. When a virtual thread blocks on lock.lock(), the JVM can unmount it from the carrier while it waits for the lock.
- ▸synchronized is an intrinsic lock tied to the carrier OS thread. This is a fundamental JVM constraint — not fixable by libraries. Replace with ReentrantLock.
- ▸Most modern libraries have already replaced internal synchronized with ReentrantLock — HikariCP 5.1+, Lettuce 6.3+, Kafka client 3.6+, Spring JDBC (Java 21), OkHttp 5+. Audit only your own code.
synchronized lockReentrantLock.lock()-Djdk.tracePinnedThreads=full — logs a stack trace every time a virtual thread is pinned. Add to run config during development.jdk.VirtualThreadPinned — non-intrusive, can be enabled at runtime with JFR streaming. Zero overhead when no pinning.When NOT to Use Virtual Threads
| Scenario | Use Virtual Threads? | Why |
|---|---|---|
| I/O-bound workloads | ✅ Yes — ideal | DB calls, REST calls, Kafka — blocking releases the carrier thread |
| High-concurrency APIs | ✅ Yes — great fit | Handle millions of simultaneous requests without reactive code |
| CPU-bound workloads | ⚠️ No benefit | Image processing, crypto, ML inference — CPU never blocks, no gain. Use bounded platform thread pool sized to CPU cores. |
| synchronized + blocking I/O | ❌ Risk of pinning | Thread pinning eliminates the benefit and can degrade performance below platform threads. Fix first. |
| Pooled virtual threads | ⚠️ Anti-pattern | Don't use newFixedThreadPool with VT factory. VTs are cheap to create — pooling adds overhead for zero benefit. Use newVirtualThreadPerTaskExecutor(). |
| ThreadLocal caching | ❌ Memory leak risk | Patterns that cache objects per-thread (SimpleDateFormat, DB connections) create one cache per VT. With millions of VTs — millions of cached objects, never reused. Use proper object pooling instead. |
Virtual Threads vs Reactive (WebFlux)
Reactive — WebFlux
- Async, non-blocking all the way down
- Complex — Mono, Flux, back-pressure
- Steep learning curve, hard to debug
- Stack traces fragmented across thread boundaries
- Best for streaming, very high event-driven throughput
- Still better for protocol-level streaming efficiency
Virtual Threads — Loom
- Synchronous code — easy to read and debug
- No Mono/Flux — normal Java style
- Complete stack traces — easy to debug in production
- Handles I/O-bound load at massive scale
- Drop-in for any Spring MVC application
- Same performance as reactive for most microservice use cases
| Aspect | Platform Threads | Virtual Threads | Reactive (WebFlux) |
|---|---|---|---|
| Concurrency model | 1 thread per request | 1 virtual thread per request | Async callbacks (Mono) |
| Max concurrency | ~200 (Tomcat default) | Millions | Millions |
| Code style | Synchronous (simple) | Synchronous (simple) ✅ | Async/functional (complex) |
| Migration effort | Baseline (no change) | 1 line in yml ✅ | Full rewrite required |
| Debug stack traces | Full trace | Full trace ✅ | Fragmented across threads |
| Best for | Low-medium concurrency | I/O-bound microservices ✅ | Streaming / back-pressure |
HTTP Request Lifecycle With Virtual Threads
new virtual thread is created immediately for every request.OrderController.getOrder() synchronously.JwtAuthFilter validates token — fast CPU operation, no blocking, no unmounting.OrderService.findById() — normal synchronous call. No reactive types needed.synchronized around the DB call, the carrier thread blocks. Detect with -Djdk.tracePinnedThreads=full.maximum-pool-size based on DB capacity.Senior-Level Interview Q&A — 8 Questions
synchronized blocks/methods with blocking I/O inside, or JNI native method calls. Detect: -Djdk.tracePinnedThreads=full JVM flag prints a stack trace every time a VT is pinned; or use JFR event jdk.VirtualThreadPinned in production. Fix: replace synchronized with ReentrantLock — it uses a different locking mechanism that supports unmounting while waiting.Executors.newVirtualThreadPerTaskExecutor() as the executor to supplyAsync(), every stage of the future chain runs on a virtual thread. When any stage blocks on I/O, the carrier is freed instead of being held. Additionally, Java 21's StructuredTaskScope replaces complex CompletableFuture chains with readable synchronous-style parallel code — fork two VTs, join them, get both results. throwIfFailed() gives clean error propagation instead of ExecutionException wrapping.Executors.newVirtualThreadPerTaskExecutor() — creates a fresh VT per task, then lets it be GC'd.Quick Reference — Numbers, Flags & Anti-Patterns
-Djdk.tracePinnedThreads=full — detect thread pinning (dev/test)-Djdk.virtualThreadScheduler.parallelism=N — set carrier thread countjcmd <PID> Thread.dump_to_file -format=json — thread dump with VTs
synchronized with ReentrantLock.1. Executors.newFixedThreadPool(N, Thread.ofVirtual().factory()) — pooling VTs
2. synchronized + blocking I/O = thread pinning
3. ThreadLocal caching patterns — caches never reused with VTs, memory leak risk
Old: CompletableFuture.supplyAsync(() -> ...) — uses default ForkJoinPool (platform threads)
Better: supplyAsync(() -> ..., Executors.newVirtualThreadPerTaskExecutor())
Best: StructuredTaskScope.ShutdownOnFailure — parallel forks, clean error handling
spring.threads.virtual.enabled: true — enables Tomcat, @Async, @Scheduled
spring.datasource.hikari.maximum-pool-size: 50 — tune for DB capacity (not thread count)