Since Java 21 made virtual threads a final feature, interviewers ask: “What are virtual threads, and when would you use them?” Good follow-ups include “Will they make my CPU-heavy service faster?”, “Should I put them in a thread pool?” and “What is pinning?” The questions check whether you understand the problem virtual threads solve, the cost model of platform threads, and the operational traps teams hit when they flip the switch.
The short version of the motivation: thread-per-request is the simplest way to write server code, but platform threads are expensive, so services ran out of threads long before CPU or network capacity. Virtual threads keep the simple blocking style while removing that ceiling.
Before you start
You should know ExecutorService, the difference between I/O-bound and CPU-bound work, and why thread pools have a size limit. Familiarity with CompletableFuture or reactive frameworks helps, since virtual threads are often presented as an alternative to them. Code here targets Java 21.
The short answer
A virtual thread is a java.lang.Thread scheduled by the JVM rather than the operating system. When it blocks on I/O, a lock or a sleep, the JVM unmounts it from its carrier platform thread, which then runs another virtual thread. That makes millions of concurrent blocking tasks practical, so throughput for I/O-bound, high-concurrency workloads rises. They do not make computation faster, should never be pooled, and on Java 21 can pin their carrier when blocking inside synchronized. Limit concurrency to downstream resources with semaphores or connection pools rather than thread counts.
How it works
A platform thread is a thin wrapper around an OS thread. Each reserves a stack (often 1 MB of virtual memory by default) and costs a kernel context switch to schedule. A few thousand is a practical ceiling.
A virtual thread’s stack lives on the heap as a resizable chunk and starts small. The JVM runs virtual threads on a ForkJoinPool of carrier threads, by default one per CPU core. When a virtual thread calls a blocking operation that the JDK has adapted, such as socket reads, Thread.sleep, BlockingQueue.take or ReentrantLock.lock, the JVM saves its stack frames to the heap and frees the carrier. When the operation can proceed, the virtual thread is scheduled again, possibly on a different carrier.
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
int id = i;
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // unmounts; no carrier is blocked
return id;
});
}
} // close() waits for all tasksThis completes in a little over a second with 100,000 concurrent sleeping tasks. With a fixed pool of 200 platform threads, the same work takes about 500 seconds.
Throughput follows Little’s law: concurrency equals throughput times latency. If each request waits 100 ms on a database, handling 10,000 requests per second needs 1,000 concurrent requests in flight. Virtual threads make that concurrency cheap. If each request instead burns 100 ms of CPU, the number of cores is the limit, and more threads only add scheduling overhead.
Pinning is the main caveat on Java 21. A virtual thread cannot unmount while it is inside a synchronized block or method, or inside a native method call. If it blocks there, its carrier is blocked too. With few carriers, a handful of pinned threads can stall the whole application. JDK 24 (JEP 491) lets virtual threads unmount inside synchronized, removing the most common source; native frames still pin.
Step-by-step walkthrough
Step 1: Confirm the workload is I/O-bound and concurrent
Look at a thread dump or profiler: are most request threads waiting on sockets, JDBC or remote calls? Is the thread pool saturated while CPU is low? That profile benefits. A service that spends its time in JSON serialization or image processing does not.
Step 2: Switch the execution model
In plain Java, replace the pool with a per-task executor or create threads directly:
Thread t = Thread.ofVirtual().name("import-", 0).start(() -> importFile(path));In Spring Boot 3.2 and later, one property moves Tomcat request handling and @Async tasks onto virtual threads:
spring.threads.virtual.enabled=trueStep 3: Replace thread-count limits with resource limits
A fixed pool of 50 threads used to cap calls to a fragile partner API at 50. With virtual threads, that cap disappears. Make it explicit:
private final Semaphore partnerPermits = new Semaphore(50);
Quote fetchQuote(String sku) throws InterruptedException {
partnerPermits.acquire();
try {
return partnerClient.quote(sku);
} finally {
partnerPermits.release();
}
}Database access is already limited by the connection pool (HikariCP), so 10,000 virtual threads will wait for 20 connections rather than overwhelm the database. Size timeouts accordingly.
Step 4: Find and fix pinning on Java 21
Record a JFR session and look for jdk.VirtualThreadPinned events, or run with -Djdk.tracePinnedThreads=full on Java 21 to print stack traces when a pinned thread blocks. Replace synchronized around blocking calls with ReentrantLock:
private final ReentrantLock lock = new ReentrantLock();
void refreshToken() throws IOException {
lock.lock();
try {
token = authClient.fetch(); // blocking I/O: virtual thread can unmount
} finally {
lock.unlock();
}
}Short synchronized sections that never block are fine.
Step 5: Revisit ThreadLocal usage
Each virtual thread has its own ThreadLocal values. Libraries that cached expensive objects per thread, assuming a few hundred pooled threads, now create one per task, which can multiply memory. Prefer passing context explicitly, or use ScopedValue (a preview API in Java 21) for request-scoped data.
Worked scenario
A team enables virtual threads on an order API that calls an inventory service. Throughput climbs at first, then the inventory service starts returning errors and the order API’s latency explodes. Before the change, Tomcat’s 200 platform threads implicitly capped concurrent inventory calls at 200. After it, every incoming request gets its own virtual thread, and 5,000 concurrent calls hit an inventory service sized for 300.
At the same time, a thread dump shows the order API itself stalling: a shared HTTP client wrapper holds a synchronized block while refreshing an OAuth token over the network. On an 8-core machine, eight pinned virtual threads blocked all eight carriers, freezing every other request.
The fixes address both problems:
private final Semaphore inventoryPermits = new Semaphore(250);
Stock checkStock(String sku) throws InterruptedException {
if (!inventoryPermits.tryAcquire(200, TimeUnit.MILLISECONDS)) {
throw new ServiceBusyException("inventory"); // shed load instead of queueing forever
}
try {
return inventoryClient.stock(sku);
} finally {
inventoryPermits.release();
}
}The token refresh moved to a ReentrantLock, and the team planned an upgrade to a JDK release with JEP 491. Afterwards, throughput stayed high and the inventory service saw a bounded, predictable load.
Common mistake
- “Virtual threads are faster threads.” They are cheaper to create and block; each one runs code at the same speed.
- Pooling virtual threads. A fixed pool of virtual threads reintroduces the limit they remove. Create one per task.
- Using them for CPU-bound parallelism. Use a fixed pool sized to cores, or parallel streams, for computation.
- Removing all concurrency limits. Downstream services and databases still need protection.
- Ignoring pinning on Java 21. Blocking inside
synchronizedcan starve carriers.
Verify the behavior
This program compares a fixed platform pool with a virtual-thread executor for 10,000 one-second blocking tasks:
import java.time.Duration;
import java.util.concurrent.*;
import java.util.stream.IntStream;
public class VirtualVsPlatform {
static long run(ExecutorService executor) {
long start = System.nanoTime();
try (executor) {
IntStream.range(0, 10_000).forEach(i -> executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
}));
}
return (System.nanoTime() - start) / 1_000_000;
}
public static void main(String[] args) {
System.out.println("virtual: " + run(Executors.newVirtualThreadPerTaskExecutor()) + " ms");
System.out.println("platform(200): " + run(Executors.newFixedThreadPool(200)) + " ms");
// expect roughly 1000-1500 ms versus roughly 50000 ms
}
}Run with java VirtualVsPlatform.java on Java 21. Replacing Thread.sleep with a CPU loop shows no advantage for virtual threads.
Follow-up questions
How do virtual threads compare to reactive programming? Both achieve high concurrency for I/O. Reactive code avoids blocking with callbacks and operators; virtual threads let you write ordinary blocking code with readable stack traces and normal exception handling.
Are virtual threads daemon threads? Yes, always. They do not keep the JVM alive, so the application must wait for them, for example through executor close().
What is structured concurrency? A preview API (StructuredTaskScope) that treats a group of subtasks as one unit, so failures cancel siblings and no task outlives its scope. It pairs naturally with virtual threads.
Can I set priorities or make a virtual thread non-daemon? No. Priority is ignored and setDaemon(false) throws IllegalArgumentException.
Interview exercise
A Spring Boot service on Java 21 must handle 2,000 requests per second but tops out near 700. Each request makes two sequential JDBC calls of 20 ms each and spends 5 ms of CPU. The database pool has 30 connections. Will enabling virtual threads help, and what will limit throughput?
Answer and reasoning
Each request holds a database connection for about 40 ms. By Little’s law, 2,000 requests per second times 0.04 seconds means about 80 connections busy on average, but the pool has 30. That pool, not the thread model, is the bottleneck: at most 30 / 0.04 = 750 requests per second can get through the database layer. Virtual threads would remove any limit from Tomcat’s thread pool, but requests would simply queue for connections, and the queue would grow until connection timeouts fire.
The CPU side is fine: 2,000 times 5 ms is 10 CPU-seconds per second, which needs about 10 cores. The real fixes are database-side: run the two queries in one round trip or in parallel, add caching, or increase the pool if the database can handle it. Virtual threads help once threads are the limiting resource, which this analysis shows they are not. Interviewers want to see you find the actual bottleneck before choosing a tool.
Continue learning
Practise more with the Java interview questions and the Java MCQs. Related notes: executor shutdown and task lifecycle, synchronized blocks and invariants and CompletableFuture error composition. The design and API are described in JEP 444: Virtual Threads.