What problem do virtual threads actually solve, and when do they not help?
Virtual threads make blocking cheap by unmounting from their carrier OS thread whenever they block, so thread-per-request scales to hundreds of thousands of concurrent tasks. Finalised in JDK 21, they do nothing for CPU-bound work, and pooling them defeats their purpose.
What the interviewer is scoring
- Whether you frame the win as blocking becoming cheap rather than threads becoming faster
- Whether you can explain mounting and unmounting onto a carrier thread without hand-waving
- Whether you recognise that CPU-bound work gains nothing because parallelism is still bounded by cores
- Whether you can say why pooling virtual threads is an anti-pattern and what replaces pool sizing for back-pressure
- Whether you name pinning as a real constraint and are accurate about which JDK version changed it
Answer
The problem being solved
Before virtual threads, a java.lang.Thread was a thin wrapper over an operating-system thread, which meant every concurrent request in a thread-per-request server consumed a scarce, expensive resource. An OS thread reserves a fixed-size stack, costs a kernel context switch to schedule, and in practice you cannot run more than a few thousand of them on a normal server. So we sized pools. And because the pool was small, a request that spent ninety percent of its life waiting on a database or a downstream HTTP call was occupying a platform thread that did nothing for that entire wait.
The industry's answer to that was asynchronous and reactive programming: never block, hand off to a callback or a CompletableFuture chain or a reactive operator, and let a handful of event-loop threads multiplex everything. It scales, but it costs you the platform. Stack traces stop describing the request, debuggers stop being useful, try/catch and try-with-resources stop composing, and thread-local context has to be threaded through manually. Virtual threads, finalised in JDK 21 under JEP 444 after two preview rounds in 19 and 20, remove the reason you were doing that. They do not make code run faster; they make blocking cheap enough that you no longer have to avoid it.
Carrier threads, mounting and unmounting
A virtual thread is a Thread instance whose execution is scheduled by the JVM rather than the OS. To run, it is mounted onto a carrier thread — a real platform thread drawn from a dedicated ForkJoinPool whose parallelism defaults to the number of available processors. While mounted, the virtual thread's frames sit on the carrier's stack and it executes exactly like ordinary code.
The interesting part is what happens when it blocks. The JDK's blocking operations were retrofitted so that instead of parking the OS thread, they unmount the virtual thread: its stack is copied off onto the heap as a continuation, the carrier is released to run some other virtual thread, and when the operation completes the continuation is scheduled again and mounted onto whatever carrier is free. Socket I/O, Thread.sleep, BlockingQueue, ReentrantLock and the rest of java.util.concurrent all behave this way. File I/O is the notable exception — FileInputStream and RandomAccessFile operations do not unmount — and volunteering that exception is worth more than reciting the list. That is why thread-per-request stops being expensive: the cost of a waiting request drops from an OS thread with its reserved stack to a heap object holding a stack that grows and shrinks on demand. A million mostly-idle virtual threads is an ordinary thing to have.
// One virtual thread per task, created and discarded freely.
// The executor is a task factory, not a pool - nothing is reused.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Order order : orders) {
executor.submit(() -> processBlocking(order));
}
} // close() waits for every submitted task to finish
Pinning
Unmounting is not always possible. When a virtual thread cannot be detached from its carrier it is pinned, and the carrier is blocked for real. The scheduler's parallelism defaults to the processor count and can grow only as far as jdk.virtualThreadScheduler.maxPoolSize, 256 by default, so pinning usually presents as a sharp throughput collapse and becomes a genuine deadlock once that cap is reached. Native frames pin: a virtual thread with a JNI frame or a foreign-function downcall on its stack cannot have that stack moved to the heap, whatever it is blocked on.
Distinguish pinning from capture, because they behave differently. Some operations that hold the carrier — Object.wait() and most filesystem operations — are compensated for: the JDK temporarily expands the scheduler's parallelism, so the platform-thread count can briefly exceed the processor count rather than starving. True pinning gets no such compensation.
Be precise about the history too, because interviewers who have followed Loom will check it. In JDK 21 through 23, blocking inside a synchronized block or method pinned the carrier outright, which is why the early guidance was to replace hot synchronized blocks with ReentrantLock. JEP 491 in JDK 24 removed that restriction, so monitors no longer pin. Native and foreign frames still do. If you are on 21 you should still audit synchronized around I/O; if you are on 24 or later you generally should not need to rewrite locking just for Loom. Pinning shows up as the jdk.VirtualThreadPinned JFR event, which is how you find it in a running service rather than by reading code.
Where they do not help
Virtual threads shorten the time a task spends waiting, not the time it spends computing. CPU-bound work gains nothing at all: only as many virtual threads can execute simultaneously as there are carrier threads, which is your core count, so ten thousand virtual threads crunching matrices is exactly the same throughput as a fixed pool of size n plus a great deal more scheduling overhead. Parallel decomposition of compute remains a job for ForkJoinPool and parallel streams.
Pooling virtual threads is the other non-win, and it is worth stating as a principle rather than a rule: a pool exists to share a resource too expensive to create per task, and virtual threads are not that resource. Putting them behind a fixed-size pool reimposes precisely the concurrency ceiling you adopted them to remove, and reused threads bring back the ThreadLocal hygiene problems that pooling always carries. Relatedly, using a ThreadLocal to cache an expensive per-thread object — a buffer, a parser, a SimpleDateFormat — inverts from a memory optimisation into a memory leak when there are a million threads.
The trap
The weak answer says virtual threads are lightweight and fast, and stops. The strong answer names what breaks: your thread pool was your rate limiter, and you have just deleted it. A 200-thread Tomcat pool was silently capping how hard you hammered the database, the third-party API, and your own memory. Give every request its own virtual thread and that ceiling disappears, so load that used to queue harmlessly at the pool boundary now arrives all at once at whatever is genuinely scarce downstream — usually a 20-connection JDBC pool that begins timing out. Throttling has to move from pool sizing to something explicit, typically a Semaphore per downstream dependency sized to what that dependency can take. Candidates who volunteer this have run virtual threads in production; candidates who do not have read about them.
Virtual threads do not make anything faster — they make blocking cheap, which is only a win for code that blocks, and which turns your implicit thread-pool back-pressure into something you now have to model explicitly.
Likely follow-ups
- Your thread pool was your accidental rate limiter. What do you use instead once every request gets its own virtual thread?
- Why is caching an expensive object in a ThreadLocal a problem under virtual threads?
- How would you detect pinning in a running service?
- Where does structured concurrency fit, and is it a stable API yet?
Related questions
- How would you design a thread-safe component, and why is adding synchronized to every method not a design?hardAlso on concurrency7 min
- What does the GIL actually prevent, and how do you decide between threads, processes, and asyncio?mediumAlso on concurrency4 min
- How do you handle cache invalidation, and what goes wrong at scale?hardAlso on scalability3 min
- Several producers write to one channel and one consumer reads it. Who closes the channel?mediumAlso on concurrency4 min
- A family plan shares 100 GB across five SIMs with each SIM capped at 30 GB. How does charging enforce both limits at once?hardAlso on concurrency6 min
- Design a producer-consumer pipeline with a bounded buffer. What do you do when the buffer is full?hardAlso on concurrency5 min
- Two customers try to buy the last item at the same time. How do you handle inventory?hardAlso on concurrency6 min
- Design a rate limiter that allows N requests per client per minute. Write the class.mediumAlso on concurrency4 min