Definition
- Anti-pattern – wrapping virtual threads in a fixed-size pool (e.g.,
newFixedThreadPool(100, Thread.ofVirtual().factory())) reintroduces the queuing bottleneck that virtual threads were designed to eliminate. - Intended model – create one virtual thread per task with
Executors.newVirtualThreadPerTaskExecutor()and let it terminate when the task ends. - Semaphore – when you need to bound concurrency against a finite resource (database connection pool of 50, API rate limit of 500 RPS), use
new Semaphore(50)to limit concurrent access without limiting the number of virtual threads. - Principle – bound the RESOURCE, not the THREADS; millions of virtual threads can exist while only 50 access the database at any time.
Virtual threads are free disposable tokens at a bank. Pooling them is like saying "only give out 100 tokens total" when tokens cost nothing. The real constraint is the number of tellers (database connections). Use a Semaphore as a queue manager: only 50 customers can approach tellers at once, but the bank can issue unlimited tokens.
Understanding the Concepts
Why is pooling virtual threads an anti-pattern?
Thread pools exist because platform threads are expensive. A pool of 200 platform threads reuses those threads across thousands of tasks, amortizing the cost of creation and the 1 MB stack per thread. Virtual threads cost roughly 1 KB and the JVM creates them in microseconds. Pooling them adds complexity for no benefit and reintroduces the exact queuing delay that virtual threads were designed to remove.
package com.bank.service;
import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;
public class ReportService {
// WRONG: pooling virtual threads limits concurrency to 100
ExecutorService wrong = Executors.newFixedThreadPool(100,
Thread.ofVirtual().factory());
// Task 101 waits in queue even though virtual threads are free!
// CORRECT: one virtual thread per task
ExecutorService correct = Executors.newVirtualThreadPerTaskExecutor();
// All 10,000 report requests get their own thread immediately
}
When do you need to limit concurrency?
Virtual threads can create 100,000 concurrent tasks, but downstream resources cannot handle 100,000 simultaneous connections. A database connection pool might have 50 connections. An external fraud-check API might allow 500 requests per second. Without limiting access to these resources, you convert thread exhaustion into connection-pool exhaustion or downstream overload.
How does a Semaphore solve this?
A Semaphore limits the number of tasks that can access a resource concurrently without limiting the number of threads. Each task acquires a permit before accessing the resource and releases it afterward. Unobtained permits cause the task's virtual thread to park (unmounting from the carrier), which is cheap.
package com.bank.service;
import java.math.BigDecimal;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
public class StatementService {
// Database pool has 50 connections
private final Semaphore dbPermits = new Semaphore(50);
public void generateStatements(java.util.List<String> accountIds) {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (String accountId : accountIds) {
executor.submit(() -> generateOne(accountId));
}
}
}
private void generateOne(String accountId) {
try {
dbPermits.acquire(); // park if 50 already using DB
try {
BigDecimal balance = fetchBalance(accountId); // DB call
buildStatement(accountId, balance);
} finally {
dbPermits.release();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
10,000 virtual threads created immediately.
At most 50 access the database at any time.
Remaining 9,950 park cheaply on the Semaphore.
How is a Semaphore different from a bounded thread pool?
| Aspect | Bounded Thread Pool | Semaphore + Unlimited VTs |
|---|---|---|
| Thread count | Fixed (e.g., 100) | Unlimited virtual threads |
| Queuing | Tasks queue at pool level | Tasks queue at resource level |
| Granularity | One limit for all resources | Separate limit per resource |
| Multiple bottlenecks | Cannot differentiate | Separate Semaphore for DB, API, etc. |
| Virtual thread benefit | Wasted (capped) | Fully utilized |
What are the rules for virtual thread usage?
- Create one virtual thread per task with
newVirtualThreadPerTaskExecutor(). - Never pool virtual threads with
newFixedThreadPoolornewCachedThreadPool. - Use
Semaphoreto limit access to finite resources (database pools, rate-limited APIs). - Use separate Semaphores for each bottleneck resource.
- Do not reuse virtual threads across tasks. Let them die when the task completes.
What are common mistakes?
// WRONG: caching virtual threads
ExecutorService cached = Executors.newCachedThreadPool(Thread.ofVirtual().factory());
// CachedThreadPool keeps idle threads alive for 60 seconds, wasting memory
// WRONG: limiting virtual threads to match CPU count
ExecutorService cpuSized = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors(), Thread.ofVirtual().factory());
// 8 virtual threads for 10,000 tasks = same as platform threads!
// CORRECT: unlimited virtual threads, bounded resources
var executor = Executors.newVirtualThreadPerTaskExecutor();
var dbPermits = new Semaphore(50); // DB pool size
var apiPermits = new Semaphore(500); // API rate limit
What should a senior engineer know?
- The
newVirtualThreadPerTaskExecutor()creates a new virtual thread for eachsubmit()orexecute()call. The executor itself is lightweight. - Semaphore permits are released in the
finallyblock to prevent permit leaks on exceptions. - For rate limiting (requests per second), combine a Semaphore with a scheduled permit replenisher or use a dedicated rate limiter library.
- The pattern "unlimited threads + bounded access" is the standard virtual thread architecture pattern.
What are common interview follow-up questions?
- What happens if you forget to release a Semaphore permit? (Permit leak: eventually all permits are consumed and all tasks block permanently.)
- Can you use a CountDownLatch instead of a Semaphore? (No. CountDownLatch counts down to zero once and cannot be reused. Semaphore permits are acquired and released repeatedly.)
- How do you choose the Semaphore permit count? (Match it to the downstream resource capacity: database connection pool size, API rate limit, file descriptor limit.)
Visual Overview
┌────────────────────────────────────────────────────┐ │ 10,000 Virtual Threads (one per task) │ │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌─────┐ │ │ │VT-1│ │VT-2│ │VT-3│ │... │ │9999│ │10000│ │ │ └──┬─┘ └──┬─┘ └──┬─┘ └──┬─┘ └──┬─┘ └──┬──┘ │ │ │ │ │ │ │ │ │ │ ▼ ▼ ▼ ▼ ▼ ▼ │ │ ┌──────────────────────────────────────────────┐ │ │ │ Semaphore (50 permits) │ │ │ │ 50 pass through ──▶ 9,950 park (cheap) │ │ │ └───────────────────────┬──────────────────────┘ │ │ │ │ │ ▼ │ │ ┌──────────────────────────────────────────────┐ │ │ │ Database Connection Pool (50) │ │ │ └──────────────────────────────────────────────┘ │ └────────────────────────────────────────────────────┘
Key Takeaway
Pooling virtual threads is an anti-pattern because it reintroduces the very bottleneck that virtual threads were designed to remove. Platform thread pools exist to amortize the cost of expensive OS threads. Virtual threads cost roughly 1 KB each and are created in microseconds, so there is no cost to amortize. A fixed pool of 100 virtual threads is no better than 100 platform threads.
The correct model is one virtual thread per task, created with Executors.newVirtualThreadPerTaskExecutor(). When a task completes, its virtual thread is discarded. When you need to bound concurrency against a finite downstream resource, use a Semaphore rather than a bounded thread pool. A Semaphore limits how many tasks ACCESS the resource without limiting how many threads EXIST.
In a banking statement generation service, 10,000 account statements are requested. Each gets its own virtual thread immediately. A Semaphore with 50 permits gates access to the database connection pool. At most 50 virtual threads query the database at any time while the remaining 9,950 park cheaply on the Semaphore, unmounted from their carriers. The carriers stay busy serving whichever virtual threads have permits.
Remember one sentence: do not pool virtual threads because they are cheap; instead use a Semaphore to limit access to each finite resource.
---