Virtual Threads Interview Questions & Answers
Java virtual threads interview questions — Project Loom, platform vs virtual threads, carrier threads, structured concurrency, thread-per-request model, pinning, ThreadLocal, when to use virtual threads, and migration from thread pools.
Virtual threads (Java 21, JEP 444, Project Loom) are lightweight threads managed by the JVM rather than the OS. A traditional platform thread maps 1-to-1 to an OS thread — costly (~1 MB stack, slow to create, limited in number). A virtual thread is a JVM-managed construct that is mounted onto a carrier platform thread only when it needs CPU time, and unmounted (parked) during blocking I/O.
// Platform thread — one OS thread, ~1 MB stack:
Thread t = new Thread(task);
// Virtual thread — lightweight, ~few KB, millions possible:
Thread vt = Thread.ofVirtual().start(task);
This enables a thread-per-request model where each HTTP request gets its own thread, but the JVM handles thousands of concurrent requests with only a handful of OS threads underneath.
Rule of thumb: virtual threads are not faster threads — they are cheap threads that eliminate the need to avoid blocking I/O.
| Aspect | Platform Thread | Virtual Thread |
|---|---|---|
| OS mapping | 1:1 with OS thread | Many-to-few (M:N) |
| Stack size | ~1 MB (fixed) | ~few KB (growable) |
| Creation cost | High (~ms) | Very low (~µs) |
| Max concurrent | ~thousands | ~millions |
| Blocking I/O | Blocks the OS thread | JVM unmounts; carrier thread reused |
| Thread pool needed | Yes, to limit overhead | Generally no |
synchronized |
Full support | Risk of pinning (see pinning) |
// Platform threads need a pool to limit resource usage:
ExecutorService pool = Executors.newFixedThreadPool(200);
// Virtual threads: create freely per task
ExecutorService vExecutor = Executors.newVirtualThreadPerTaskExecutor();
Rule of thumb: virtual threads are the right default for I/O-bound concurrent tasks; platform threads remain necessary for CPU-bound work.
A carrier thread is a platform thread that a virtual thread runs on. The JVM maintains a small ForkJoinPool of carrier threads (defaulting to the number of CPU cores). Virtual threads are scheduled by the JVM onto available carrier threads using cooperative scheduling:
- Virtual thread starts executing on a carrier thread.
- When it hits a blocking operation (I/O,
sleep,LockSupport.park), the JVM unmounts the virtual thread from the carrier — saving its stack to the heap. - The carrier thread is now free to run another virtual thread.
- When the I/O completes, the virtual thread is remounted on any available carrier thread and resumes.
OS threads (carrier pool): [T1] [T2] [T3] [T4]
Virtual threads: [V1] [V2] ... [V10000]
V1 blocks on I/O → unmounted from T1 → T1 picks up V2 immediately
Rule of thumb: the carrier pool size is set by -Djdk.virtualThreadScheduler.parallelism=N
(default = CPU cores); don't increase it for I/O-bound workloads.
Before virtual threads, thread-per-request was impractical beyond a few hundred concurrent requests because each request consumed an OS thread. Frameworks like Servlet, Spring MVC, and JDBC used thread pools and reactive/async APIs to avoid blocking.
With virtual threads, the JVM handles the parking/scheduling:
// Spring Boot 3.2+ — enable virtual threads globally:
// spring.threads.virtual.enabled=true
// Or manually with Jakarta Servlets:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> {
String data = jdbcTemplate.queryForObject(sql, String.class); // blocks OK
return processData(data);
});
}
Each request can use blocking APIs (JDBC, REST client, file I/O) naturally — the JVM suspends the virtual thread during the wait without consuming an OS thread.
Rule of thumb: virtual threads make simple synchronous blocking code
scale as well as complex asynchronous reactive code — without callback
hell or CompletableFuture chains.
Pinning occurs when a virtual thread cannot be unmounted from its carrier thread during a blocking operation. This defeats the purpose of virtual threads because the carrier OS thread is held blocked.
Two situations cause pinning:
synchronizedblocks or methods — the JVM cannot currently unmount a virtual thread that holds a monitor lock.- Native methods — JNI frames cannot be parked.
synchronized (lock) {
Thread.sleep(1000); // virtual thread PINNED — carrier blocked for 1 sec
}
// Fix: replace synchronized with ReentrantLock:
lock.lock();
try {
Thread.sleep(1000); // virtual thread parks correctly — carrier freed
} finally {
lock.unlock();
}
Detect pinning with: -Djdk.tracePinnedThreads=full (logs whenever
pinning occurs). Java 24+ is improving synchronized to avoid pinning
in most cases.
Rule of thumb: replace synchronized blocks that contain blocking
calls with ReentrantLock; avoid long-running native method calls on
virtual threads.
ThreadLocal works with virtual threads but has two problems at scale:
- If each virtual thread (potentially millions) initialises a heavy
ThreadLocal(e.g., a database connection), memory usage explodes. - Thread pools reuse threads, so
ThreadLocalvalues survive across tasks — with virtual threads there's no pooling, so values don't leak between tasks, but the allocation-per-thread cost remains.
Java 20 introduced Scoped Values (ScopedValue, JEP 446 — finalized
in Java 21) as the preferred alternative for virtual threads:
// ThreadLocal — allocated per virtual thread:
private static final ThreadLocal<User> CURRENT_USER = new ThreadLocal<>();
// ScopedValue — immutable, inherited by child threads, no per-thread alloc:
private static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
ScopedValue.where(CURRENT_USER, user).run(() -> {
// CURRENT_USER.get() returns 'user' within this scope
processRequest();
});
ScopedValue is immutable and automatically cleaned up when the scope
exits — no remove() needed, no accidental cross-task contamination.
Rule of thumb: prefer ScopedValue over ThreadLocal for new code
on Java 21+, especially context propagation (user, trace-id, locale).
Structured concurrency (Java 21 preview, JEP 453) ensures that when a task spawns subtasks, their lifetimes are bounded by the parent task's scope. If the parent fails or is cancelled, subtasks are automatically cancelled — no orphaned threads.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> user = scope.fork(() -> fetchUser(id));
Future<List<Order>> orders = scope.fork(() -> fetchOrders(id));
scope.join(); // wait for both
scope.throwIfFailed(); // propagate first failure
return new Response(user.resultNow(), orders.resultNow());
}
// Scope exits → both subtasks guaranteed to be done or cancelled
ShutdownOnFailure cancels the remaining subtasks if one fails.
ShutdownOnSuccess returns as soon as any subtask succeeds (useful for
parallel search / hedging).
Rule of thumb: structured concurrency + virtual threads = simple synchronous-looking code that is safe, cancellable, and leak-free.
Virtual threads are optimised for I/O-bound work. They are not appropriate for:
- CPU-bound tasks — a CPU-heavy virtual thread still occupies a
carrier thread for its entire run; many CPU-bound virtual threads compete
for the same carrier pool, starving each other. Use platform threads
in a
ForkJoinPoolornewFixedThreadPoolinstead. - Code that relies on thread identity — some frameworks or libraries
assume thread-pool reuse of
ThreadLocal. Virtual threads don't pool, so thread-identity assumptions break. - Heavy synchronized sections over blocking I/O — pinning turns virtual threads into expensive platform threads during those sections.
// Bad — CPU-bound on virtual threads starves the carrier pool:
var executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> computePrimes(10_000_000)); // blocks a carrier
// Better — CPU work on platform threads:
var pool = ForkJoinPool.commonPool();
pool.submit(() -> computePrimes(10_000_000));
Rule of thumb: virtual threads excel at I/O-bound concurrent tasks; stick to platform thread pools for CPU-intensive computation.
Java 21 provides several APIs for creating virtual threads:
// 1. Thread.ofVirtual() builder:
Thread vt = Thread.ofVirtual()
.name("my-virtual-thread")
.start(myTask);
// 2. Thread.startVirtualThread() — shorthand:
Thread vt2 = Thread.startVirtualThread(myTask);
// 3. ExecutorService — one virtual thread per task:
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
exec.submit(task1);
exec.submit(task2);
} // waits for all tasks on close
// 4. ThreadFactory for frameworks:
ThreadFactory factory = Thread.ofVirtual().factory();
Spring Boot 3.2+ and Tomcat 10.1.x+ support virtual threads with a single
property: spring.threads.virtual.enabled=true.
Rule of thumb: for servers, use newVirtualThreadPerTaskExecutor()
or the framework's built-in virtual thread support; avoid creating raw
virtual threads manually in application code.
Standard observability tools work with virtual threads in Java 21+:
# Thread dump — shows virtual threads:
jcmd <pid> Thread.dump_to_file -format=json /tmp/threads.json
# JFR events — virtual thread mount/unmount, pinning:
java -XX:StartFlightRecording=filename=recording.jfr MyApp
# Detect pinning at runtime:
java -Djdk.tracePinnedThreads=full MyApp
jstack was updated to show virtual threads. JDK Flight Recorder (JFR)
has dedicated events for virtual thread lifecycle and pinning incidents.
In thread dumps, virtual threads are grouped and summarised by stack trace if many share the same waiting state (e.g., 50,000 threads all blocked on JDBC).
Rule of thumb: enable JFR in production for virtual-thread apps; the pinning events are the most important signal to watch for performance regressions.
For most Spring/Jakarta EE applications, migration is a single config change. For manual migration:
// Before — fixed platform thread pool:
ExecutorService executor = Executors.newFixedThreadPool(200);
// After — virtual thread per task:
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Steps:
- Replace pool creation — swap to
newVirtualThreadPerTaskExecutor(). - Remove artificial pool-size tuning (thread count limits are no longer meaningful for I/O-bound tasks).
- Audit
synchronizedblocks that contain blocking operations — replace withReentrantLockto avoid pinning. - Replace heavy
ThreadLocalwithScopedValuewhere context propagation is needed. - Run under load and check JFR for pinning events.
Rule of thumb: the migration is usually 1–3 lines for the executor; the real work is finding and fixing pinning in synchronized/native code.
For most applications, virtual threads are a simpler alternative to
reactive stacks for I/O-bound concurrency. Instead of chaining
Mono/Flux operators or CompletableFuture, you write straightforward
synchronous blocking code that scales equally well.
// Reactive — non-blocking but complex:
Mono.fromCallable(() -> userRepo.findById(id))
.subscribeOn(Schedulers.boundedElastic())
.flatMap(user -> orderRepo.findByUser(user.id()))
.map(orders -> new Response(user, orders))
.subscribe(...);
// Virtual threads — blocking but simple:
User user = userRepo.findById(id); // blocks a virtual thread
List<Order> orders = orderRepo.findByUser(user.id());
return new Response(user, orders);
Reactive frameworks like Reactor and RxJava still excel at:
- Backpressure — controlling producer/consumer rates.
- Streaming large data sets where you process elements as they arrive.
- Ecosystems already built on reactive (Vert.x, Quarkus reactive).
Rule of thumb: for new I/O-bound services, prefer virtual threads + synchronous code over reactive; keep reactive where backpressure or streaming semantics are genuinely needed.
Java 21 added Thread.isVirtual():
Thread t = Thread.currentThread();
System.out.println(t.isVirtual()); // true if running on a virtual thread
// Also available as a static check:
if (Thread.currentThread().isVirtual()) {
// avoid synchronized blocks with blocking I/O here
}
Virtual threads also have distinct characteristics:
isDaemon()always returnstrue.getPriority()is alwaysNORM_PRIORITY(5) and cannot be changed.- They are not in any
ThreadGroupthat is meaningful to the application.
Rule of thumb: Thread.isVirtual() is mainly useful in libraries
and frameworks that need to adapt behaviour based on whether the caller
is using virtual threads.
More Modern Java interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.