Virtual Threads (Project Loom)
Virtual threads (JDK 21+) make blocking I/O cheap again. Treat them as the default for request handling; keep platform threads for CPU-bound work.
Deep dive
What changed
Before Loom, scaling I/O-heavy services meant reactive code (WebFlux, RxJava) — non-trivial to debug. Virtual threads let you keep imperative, blocking code while the JVM multiplexes thousands of them onto a small carrier pool.
When they shine
HTTP request handlers that mostly wait on DB, downstream HTTP, or message brokers. One virtual thread per request scales to hundreds of thousands.
When they don't
- CPU-bound work: virtual threads give no benefit — use
ForkJoinPoolor platform threads. - Pinning:
synchronizedblocks pin the carrier thread. UseReentrantLockinstead, or wait for JDK 24+ which removes most pinning. - ThreadLocal abuse: virtual threads are cheap, so a ThreadLocal per request now means millions of objects. Prefer
ScopedValue.
Real-world example
From productionA gateway service handling 30k req/s on WebFlux was hard to debug — stack traces were useless. Migrated to Spring Boot 3.2 + virtual threads (spring.threads.virtual.enabled=true), kept the imperative code, and matched throughput with simpler operations. Stack traces became readable; on-call burden dropped.
Interview questions
2 senior-levelQ1When would you NOT use virtual threads?▾
CPU-bound work (compression, encryption, heavy serialization) — they give no benefit and add overhead. Also avoid in code that uses synchronized heavily on hot paths until JDK 24, due to carrier-thread pinning.
Q2Virtual threads vs reactive (WebFlux)?▾
Both target I/O scalability. Reactive is harder to write, debug, and integrate with blocking libraries, but gives backpressure primitives. Virtual threads keep imperative code and tools. For most CRUD-ish services, virtual threads now win on simplicity.
Common mistakes
Pooling virtual threads — they're meant to be created and discarded.
Sharing a fixed-size DB connection pool with thousands of virtual threads (you'll just queue on the pool).
Using
synchronizedon hot paths and getting silent carrier-thread pinning.
Trade-offs
Virtual threads don't add backpressure — your DB pool or downstream service becomes the bottleneck.
Reactive remains valuable when you need explicit backpressure or stream composition.