Lesson 28 of 71
Virtual Threads in Depth
A virtual thread is a java.lang.Thread that the JDK schedules onto a small number of operating-system threads, instead of mapping one-to-one to an OS thread. Virtual threads became final in Java 21 (JEP 444). They let blocking, thread-per-request code scale to hundreds of thousands of concurrent tasks. This lesson explains the mechanism, the cases where it breaks down, and ends with a fan-out client. It uses executors from Executors and CompletableFuture and semaphores from Locks and Synchronizers.
| Term | Meaning |
|---|---|
| Platform thread | A Thread backed one-to-one by an OS thread |
| Virtual thread | A Thread whose stack lives on the heap and runs on a carrier when it has work |
| Carrier thread | The platform thread a virtual thread currently runs on |
| Continuation | The JDK-internal object that captures a virtual thread's stack so it can stop and resume |
| Mount / unmount | Attaching a virtual thread to a carrier / detaching it and saving its stack |
| Pinning | A state where a blocked virtual thread cannot unmount and holds its carrier |
Example 1: Creating virtual threads
Thread.ofVirtual() returns a builder; Thread.startVirtualThread(runnable) is a shortcut. Virtual threads are always daemon threads and have no name unless one is set.
public class FirstVirtual {
public static void main(String[] args) throws InterruptedException {
Thread vt = Thread.ofVirtual().name("vt-1").start(() ->
System.out.println("inside " + Thread.currentThread().getName()
+ ", virtual = " + Thread.currentThread().isVirtual()));
vt.join();
Thread unnamed = Thread.startVirtualThread(() -> {});
unnamed.join();
System.out.println("daemon = " + unnamed.isDaemon()
+ ", name = '" + unnamed.getName() + "'");
Thread platform = Thread.ofPlatform().name("pt-1").start(() -> {});
platform.join();
System.out.println("platform virtual = " + platform.isVirtual());
}
}inside vt-1, virtual = true
daemon = true, name = ''
platform virtual = falseVirtual threads share the Thread API, so existing code that uses Thread.currentThread(), join, interruption, and ThreadLocal keeps working. setDaemon(false) on a virtual thread throws IllegalArgumentException, and virtual threads always have Thread.NORM_PRIORITY.
Example 2: How mounting works
When a virtual thread starts, the scheduler mounts it on a carrier and runs it. When the code reaches a blocking operation the JDK has adapted (socket read, Thread.sleep, BlockingQueue.take, ReentrantLock.lock), the runtime yields the continuation: it copies the changed stack frames to the heap, frees the carrier, and parks the virtual thread. When the operation can proceed (data arrived, timer fired, lock released), the virtual thread is submitted to the scheduler again and may resume on a different carrier.
The default scheduler is a dedicated ForkJoinPool in FIFO mode, separate from the common pool. Its parallelism defaults to Runtime.availableProcessors() and can be changed with the system property jdk.virtualThreadScheduler.parallelism; jdk.virtualThreadScheduler.maxPoolSize caps how many carriers it may add to compensate for blocking (default: the larger of 256 and the parallelism).
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
public class TenThousand {
public static void main(String[] args) {
AtomicInteger done = new AtomicInteger();
try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
ex.submit(() -> {
Thread.sleep(100); // unmounts; the carrier runs other threads
done.incrementAndGet();
return null;
});
}
} // close() waits for all 10,000
System.out.println("completed = " + done.get());
}
}completed = 10000Ten thousand platform threads that each sleep 100 ms need ten thousand OS threads, each with a stack reserved by the OS (often 1 MB of address space by default on Linux x64). The virtual-thread version runs on a few carriers, and the whole program takes a little over 100 ms because all sleeps overlap. The submitted lambda returns null so it is a Callable, which allows the checked InterruptedException from sleep.
newVirtualThreadPerTaskExecutor() starts one new virtual thread per task. Virtual threads are cheap to create, so they are never pooled: a pool exists to reuse expensive threads, and reusing virtual threads only adds a queue and leaks ThreadLocal values between tasks.
Example 3: Blocking IO behavior
Most blocking calls in the JDK unmount the virtual thread. Network IO through java.net sockets and java.nio channels is implemented with non-blocking sockets and an internal poller, so a virtual thread waiting on a socket costs only its heap stack. File IO is different: most file system calls cannot be made non-blocking, so the JDK pins the carrier during the call and temporarily adds a carrier to the scheduler to compensate.
import java.util.List;
import java.util.concurrent.*;
public class BlockingOps {
public static void main(String[] args) throws Exception {
BlockingQueue<String> q = new LinkedBlockingQueue<>();
try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> consumer = ex.submit(() -> "got " + q.take()); // parks, unmounts
Future<String> timer = ex.submit(() -> { Thread.sleep(20); return "slept"; });
q.put("tick");
List<String> results = List.of(consumer.get(), timer.get());
System.out.println(results);
}
}
}[got tick, slept]BlockingQueue.take parks through LockSupport.park, which the JDK implements for virtual threads by yielding the continuation. The results are printed in a fixed order because the code calls get() in a fixed order, not because of scheduling.
Example 4: Pinning and how Java 24 changed it
A virtual thread is pinned when it blocks but cannot unmount, so its carrier blocks too. If all carriers are pinned, other virtual threads cannot run.
In Java 21 to 23, a virtual thread is pinned when it blocks inside a synchronized block or method, or inside Object.wait(), because the monitor implementation records the owner as the carrier thread. JEP 491, delivered in Java 24, reimplemented monitors so that virtual threads can acquire, hold, and release monitors independently of their carriers. From Java 24, blocking inside synchronized unmounts normally. Pinning still happens when:
- the stack contains a native method frame or a foreign function call (JNI or the FFM API), or
- the thread blocks during class initialization (a static initializer, or waiting for another thread to finish one).
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
public class Pinning {
static final Object monitor = new Object();
static final ReentrantLock lock = new ReentrantLock();
static final AtomicInteger counter = new AtomicInteger();
static void withSynchronized() throws InterruptedException {
synchronized (monitor) { // pins on Java 21-23 while sleeping
Thread.sleep(1);
counter.incrementAndGet();
}
}
static void withLock() throws InterruptedException {
lock.lock(); // never pins
try {
Thread.sleep(1);
counter.incrementAndGet();
} finally { lock.unlock(); }
}
public static void main(String[] args) {
try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100; i++) {
ex.submit(() -> { withSynchronized(); return null; });
ex.submit(() -> { withLock(); return null; });
}
}
System.out.println("counter = " + counter.get());
}
}counter = 200The output is the same on every release; only the carrier usage differs. On Java 21 to 23, code that may block inside synchronized on a hot path was commonly moved to ReentrantLock to avoid pinning. On Java 24 and later, that change is no longer needed for this reason.
Diagnostics. The JFR event jdk.VirtualThreadPinned is recorded when a virtual thread blocks while pinned, by default when the pinned time exceeds 20 ms, and includes the stack trace. Recording it:
java -XX:StartFlightRecording=filename=rec.jfr,settings=profile Main
jfr print --events jdk.VirtualThreadPinned rec.jfrThe system property -Djdk.tracePinnedThreads=full|short printed a stack trace when a virtual thread blocked while pinned on Java 21 to 23. JEP 491 removed it in Java 24; JFR is the supported tool from then on.
Example 5: Limiting concurrency with a semaphore
Virtual threads remove the thread pool as an accidental limit on concurrency. A downstream service that accepts 10 concurrent requests still accepts 10. The limit belongs on the resource, with a Semaphore, not on the number of threads.
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class Limited {
static final Semaphore dbPermits = new Semaphore(10);
static final AtomicInteger inFlight = new AtomicInteger();
static final AtomicInteger peak = new AtomicInteger();
static String query(int id) throws InterruptedException {
dbPermits.acquire();
try {
peak.accumulateAndGet(inFlight.incrementAndGet(), Math::max);
Thread.sleep(5);
return "row-" + id;
} finally {
inFlight.decrementAndGet();
dbPermits.release();
}
}
public static void main(String[] args) throws Exception {
try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = new java.util.ArrayList<Future<String>>();
for (int i = 0; i < 1000; i++) {
int id = i;
futures.add(ex.submit(() -> query(id)));
}
int rows = 0;
for (Future<String> f : futures) if (f.get().startsWith("row-")) rows++;
System.out.println("rows = " + rows);
}
System.out.println("peak <= 10: " + (peak.get() <= 10));
}
}rows = 1000
peak <= 10: trueA thousand virtual threads exist at once, but at most ten run the query. Waiting on acquire() unmounts the virtual thread, so the 990 waiters cost only memory.
Example 6: ThreadLocal cost and scoped values
Each virtual thread has its own ThreadLocal map. With a pool of 200 platform threads, a cached ThreadLocal<SimpleDateFormat> creates 200 objects. With a million virtual threads, the same code creates a million objects, and the cache no longer saves anything. Expensive per-thread caches should become shared thread-safe objects (for example DateTimeFormatter, which is immutable).
A scoped value is an immutable value bound for the duration of a method call and visible to code called from it, including child threads in structured concurrency. It replaces ThreadLocal for passing context such as a request id. Scoped values were preview in Java 21 to 24 and became final in Java 25 (JEP 506). The example uses the Java 25 API.
// Java 25
public class RequestContext {
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
static void handle() {
log("validated");
log("stored");
}
static void log(String msg) {
System.out.println("[" + REQUEST_ID.get() + "] " + msg);
}
public static void main(String[] args) {
ScopedValue.where(REQUEST_ID, "req-42").run(RequestContext::handle);
System.out.println("bound outside: " + REQUEST_ID.isBound());
}
}[req-42] validated
[req-42] stored
bound outside: falseCompared with ThreadLocal, a scoped value cannot be changed by called code (only rebound for a nested scope), is unbound automatically when run returns, and needs no remove() call, so it cannot leak across tasks.
Structured concurrency treats a group of subtasks started in one block as a unit: the block waits for all of them, and failure or cancellation of one can cancel the others. StructuredTaskScope was a preview API in Java 21 to 24, and Java 25 has a fifth preview (JEP 505) that redesigned how a scope is opened and how its completion policy is chosen. It is not final in Java 25, so code needs --enable-preview and may change. The Java 25 API is not shown here because it differs from earlier previews; the official JEP text shows the current form.
Example 7: Project: fan-out client
A request handler fetches a user profile, then fetches orders, limits, and positions in parallel, with a deadline per call and a cap on concurrent backend calls. Each remote call is simulated with sleep so the program has no network dependency.
import java.util.List;
import java.util.Map;
import java.util.concurrent.*;
public class FanOutClient {
static final Semaphore backend = new Semaphore(50);
static String call(String service, String user, long latencyMs) throws InterruptedException {
backend.acquire();
try {
Thread.sleep(latencyMs); // stands in for an HTTP call
if (service.equals("limits") && user.equals("u7")) throw new IllegalStateException("limits down");
return service + ":" + user;
} finally {
backend.release();
}
}
record Dashboard(String user, String orders, String limits, String positions) {}
static Dashboard load(ExecutorService ex, String user) throws Exception {
String profile = call("profile", user, 5); // sequential dependency
Future<String> orders = ex.submit(() -> call("orders", user, 20));
Future<String> limits = ex.submit(() -> call("limits", user, 10));
Future<String> positions = ex.submit(() -> call("positions", user, 10_000));
String lim;
try {
lim = limits.get(1, TimeUnit.SECONDS);
} catch (ExecutionException e) {
lim = "limits:unavailable"; // degrade instead of failing
}
String pos;
try {
pos = positions.get(100, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
positions.cancel(true); // interrupt the slow call
pos = "positions:timeout";
}
return new Dashboard(profile, orders.get(1, TimeUnit.SECONDS), lim, pos);
}
public static void main(String[] args) throws Exception {
try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Dashboard>> pages = new java.util.ArrayList<>();
for (int i = 0; i < 100; i++) {
String user = "u" + i;
pages.add(ex.submit(() -> load(ex, user)));
}
int ok = 0, degradedLimits = 0, timeouts = 0;
for (Future<Dashboard> f : pages) {
Dashboard d = f.get();
ok++;
if (d.limits().endsWith("unavailable")) degradedLimits++;
if (d.positions().endsWith("timeout")) timeouts++;
}
System.out.println(Map.of("pages", ok, "degradedLimits", degradedLimits)
.entrySet().stream().sorted(Map.Entry.comparingByKey()).toList());
System.out.println("position timeouts = " + timeouts);
}
}
}[degradedLimits=1, pages=100]
position timeouts = 100Every page completes, one page (u7) shows degraded limits, and every positions call (10 s) misses its 100 ms deadline and is cancelled, which interrupts its sleep. Each page blocks in plain sequential style while it waits for its futures, and 100 pages each start one page thread plus three call threads, about 400 virtual threads. The semaphore keeps at most 50 backend calls in flight. Because a cancelled call is interrupted inside sleep and its finally releases the permit, cancelled calls do not leak permits.
The margins keep the counts deterministic: a 10 second call can never meet a 100 ms deadline, even when a busy scheduler resumes the waiting page thread late, and the 1 second deadlines for orders and limits are far above the time any call waits for a permit with 100 pages and 50 permits. A positions call that is cancelled while still waiting in acquire() throws InterruptedException before it holds a permit, so it releases nothing.
When virtual threads do not help
| Workload | Effect |
|---|---|
| Many concurrent blocking IO tasks | Large gain: throughput scales with concurrency, not thread count |
| CPU-bound computation | No gain: the work still needs a core; use a pool sized to cores or ForkJoinPool |
| Few concurrent tasks | No measurable gain |
| Strict low-latency paths (trading hot loops) | Unsuitable: scheduling is not controllable, and unmount/remount adds latency; dedicated, pinned platform threads that busy-spin are used instead |
Code that pins (native calls, blocking in class init, synchronized on Java 21-23) |
Gain reduced or lost while pinned |
Virtual threads raise throughput, the number of tasks completed per second when tasks wait. They do not make a single task faster.
Interview points
- Virtual threads are final in Java 21 (JEP 444); they are
Threadobjects whose stacks are saved to the heap as continuations. - The default scheduler is a FIFO
ForkJoinPoolwith parallelism equal to the number of processors; it is separate from the common pool. - Blocking in JDK-adapted operations unmounts the virtual thread; file IO pins and the scheduler compensates with an extra carrier.
- Before Java 24, blocking inside
synchronizedorObject.waitpins; JEP 491 (Java 24) removed that; native frames and class initialization still pin. jdk.tracePinnedThreadsexisted in Java 21 to 23 and was removed in Java 24; use the JFR eventjdk.VirtualThreadPinned.- Do not pool virtual threads; limit access to scarce resources with a
Semaphore. - Per-thread caches in
ThreadLocallose their value with millions of threads; scoped values are final in Java 25. - Structured concurrency is still preview in Java 25 (fifth preview, JEP 505).
- CPU-bound and ultra-low-latency work gain nothing from virtual threads.
Summary
- A virtual thread runs on a carrier only while it has work to do.
- Mounting copies frames back from the heap; unmounting saves them.
Executors.newVirtualThreadPerTaskExecutor()creates one virtual thread per task.- Pinning holds the carrier while the virtual thread blocks.
- Concurrency limits belong on resources, not on thread counts.
- Virtual threads improve throughput of waiting tasks, not latency of computing tasks.
