Virtual threads (Project Loom, Java 21+) make 'one thread per request' feasible at million-concurrent scale. Most code 'just works'. The footguns are subtle — pinning, ThreadLocal cost, blocking synchronized — and worth knowing before you hit them.
Pinning by synchronized
Inside a synchronized block, the carrier thread can't be unmounted while the virtual thread blocks. Long blocking I/O inside synchronized = your carrier pool dies. Use ReentrantLock instead — Loom handles it correctly.
ThreadLocal cost
Virtual threads are cheap to create; ThreadLocals are not — each VT has its own. A million VTs with several ThreadLocals each = huge memory. Use ScopedValue (Java 21+ preview, now standard) instead.
Native code pinning
JNI calls pin carriers. Mostly invisible unless you're using libraries with native blocking I/O. Profile under load to see if carrier pool is starved.