Why should you NOT pool virtual threads, and what should you do instead?
answer
- Pools amortize expensive resources; VTs are cheap
- Fixed VT pool = re-imposes the old thread limit
- newVirtualThreadPerTaskExecutor = per task, not a pool
- Bound the resource with a Semaphore, not threads
- Pooling reuses threads -> ThreadLocal leaks across tasks
basics
~20 sVirtual threads are cheap and disposable, so you create a fresh one per task instead of reusing them from a pool. Pooling exists to reuse expensive resources; virtual threads aren't expensive, so a pool only adds a bottleneck. Use Executors.newVirtualThreadPerTaskExecutor().
solid answer
~40 sThread pools exist to reuse a scarce, expensive resource: platform (OS) threads. A virtual thread is the opposite — it is cheap to create (a small heap object) and you can have millions of them, so reusing them buys nothing and reintroduces the very limit pools were meant to remove. Capping a virtual-thread pool to, say, 200 threads silently serializes your tasks back to 200-at-a-time, erasing the scalability win. The idiom is one virtual thread per task, via Executors.newVirtualThreadPerTaskExecutor() or Thread.ofVirtual().start(). If you must bound concurrency it should be against a downstream resource (a database, an API) using a Semaphore, not by limiting threads. Pooling also breaks ThreadLocal hygiene, since pooled threads carry state between unrelated tasks.
code
java · 21 lines// WRONG: a fixed pool of virtual threads caps concurrency at 200
ExecutorService bad = Executors.newFixedThreadPool(200, Thread.ofVirtual().factory());
// RIGHT: one fresh virtual thread per task, unbounded
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (var request : requests) {
executor.submit(() -> handle(request)); // new virtual thread each time
}
} // close() waits for all tasks
// RIGHT: still one-per-task, but bound *the resource* with a semaphore
Semaphore dbLimit = new Semaphore(50); // DB pool has 50 connections
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (var request : requests) {
executor.submit(() -> {
dbLimit.acquire();
try { queryDatabase(request); }
finally { dbLimit.release(); }
});
}
}go deeper
Knows virtual threads are cheap and that you create one per task instead of reusing them, and can name newVirtualThreadPerTaskExecutor().
Explains why pooling cheap threads is pointless and that a fixed pool silently caps concurrency back to the pool size, erasing the benefit.
Articulates the resource-pooling rationale, distinguishes bounding threads from bounding a downstream resource with a Semaphore, and notes ThreadLocal leakage in pools.
Frames it as a design principle (amortize scarce resources, not cheap ones), reasons about where backpressure truly belongs in the system, and can guide a team migrating an executor-pool codebase to per-task virtual threads safely.
## Background: what a thread is A **thread** is an independent path of execution. Traditionally, each Java thread is a **platform thread** — a thin wrapper over an **operating-system (OS) thread**. OS threads are *expensive*: each reserves a large stack (often ~1 MB), creating one is a system call, and the OS scheduler can only juggle a few thousand before context-switching overhead dominates. So you can realistically run only a few thousand platform threads. ## Why thread pools exist Because platform threads are scarce and costly, the classic solution is a **thread pool** (e.g. `Executors.newFixedThreadPool(200)`): create a fixed set of threads *once*, then feed them a queue of tasks. The pool **reuses** each thread for many tasks so you pay the creation cost only once and never exceed a safe thread count. This is a form of **resource pooling** — the same reason we pool database connections. ## What a virtual thread is A **virtual thread** (finalized in Java 21, JEP 444) is a thread managed by the **JVM**, not the OS. It is just a small Java heap object holding a stack that grows and shrinks on demand. Many virtual threads are multiplexed onto a small pool of **carrier** (platform) threads. When a virtual thread does a blocking I/O call (network, file, `sleep`), the JVM **unmounts** it from its carrier and parks it cheaply, freeing the carrier to run another virtual thread. You can create **millions** of them. ## The key insight: don't pool what is already cheap Pooling is a technique to *amortize the cost of an expensive resource*. A virtual thread is **not** an expensive resource — creating one is roughly as cheap as allocating an object. Therefore: 1. **Pooling buys nothing.** There is no creation cost worth amortizing. 2. **Pooling reintroduces the bottleneck you were escaping.** If you put virtual threads behind a fixed pool of size N, only N tasks run concurrently — you have re-imposed the exact platform-thread limit virtual threads were designed to eliminate. With 10,000 concurrent I/O-bound requests and a pool of 200, 9,800 wait in a queue. 3. **Pooling corrupts thread-local state.** A pooled thread outlives one task and is reused, so any `ThreadLocal` it holds leaks into the next, unrelated task. ## The correct idiom Create **one virtual thread per task** and let it die when the task finishes: - `Executors.newVirtualThreadPerTaskExecutor()` — an `ExecutorService` that spawns a brand-new virtual thread for every submitted task (it is *not* a pool — the name says "per task"). - `Thread.ofVirtual().start(runnable)` or `Thread.startVirtualThread(runnable)` for a one-off. ## But I still need to limit concurrency! Unbounded concurrency is sometimes wrong — not because of the threads, but because a **downstream resource** (a database with 50 connections, a rate-limited API) can't take 10,000 simultaneous calls. The right tool is a **`Semaphore`** sized to that resource, *not* a thread pool. Each virtual thread acquires a permit before touching the resource and releases it after; you still get one cheap virtual thread per task, but only N of them touch the constrained resource at once. This separates *how many threads exist* (as many as tasks) from *how many may use a resource* (bounded by the semaphore). ## Summary Pools amortize expensive resources; virtual threads are cheap, so pooling them only re-creates the limit you wanted gone and breaks `ThreadLocal` hygiene. One virtual thread per task; bound concurrency at the resource with a semaphore.
- If unbounded virtual threads are fine, how do you protect a database that only has 50 connections?Keep one virtual thread per task, but guard the database call with a Semaphore(50). Each thread acquires a permit before the call and releases it after, so at most 50 hit the DB concurrently while the rest park cheaply — limiting the resource, not the threads.
- Is Executors.newVirtualThreadPerTaskExecutor() a thread pool?No. Despite returning an ExecutorService, it creates a brand-new virtual thread for each submitted task and discards it on completion — there is no reuse. Its name literally says 'per task'.
saying these in an interview costs you the question
- Saying 'just make a pool of virtual threads sized large' — any fixed size still caps concurrency and defeats the purpose
- Believing newVirtualThreadPerTaskExecutor is a reusing pool (it spawns a new thread per task)
- Confusing limiting threads with limiting access to a downstream resource
- Thinking pooling is needed to avoid 'too many' virtual threads when the real constraint is the downstream resource