Level 3 · 25 min
Virtual Threads
Project Loom (Java 21+) introduced virtual threads — lightweight threads managed by the JVM rather than the OS. They fundamentally change how you design I/O-bound services: instead of reactive/async patterns, you can write simple blocking code and get the scalability of thousands of concurrent operations.
Virtual Threads vs Platform Threads
Platform threads are 1:1 wrappers around OS threads. OS threads are expensive (~1MB stack, kernel scheduling overhead) — most JVMs max out at a few thousand. Virtual threads are M:N: the JVM schedules millions of virtual threads across a small pool of carrier (platform) threads. When a virtual thread blocks on I/O, the JVM unmounts it from the carrier thread (which then picks up another virtual thread), and remounts it when I/O completes. The result: you can have 100,000 concurrent virtual threads with minimal memory.
Thread-per-Request and Blocking I/O
The thread-per-request model (one thread handles one request end-to-end) is the simplest programming model but traditionally required thread pools because OS threads are expensive. With virtual threads, thread-per-request becomes viable even for high-concurrency services. Standard blocking I/O (JDBC, HTTP clients, file I/O) works transparently — the JVM automatically unmounts the virtual thread during blocking calls. You do NOT need to rewrite code to use CompletableFuture or reactive streams to get concurrency benefits. Virtual threads are implemented as continuations scheduled by the JVM on a pool of platform (carrier) threads, typically sized to the number of CPU cores (Runtime.getRuntime().availableProcessors()). When a virtual thread blocks on I/O, it is 'unmounted' from its carrier thread — the carrier thread is free to run other virtual threads. The blocking syscall is rewritten as a non-blocking operation at the JVM level. Two important limitations: synchronized blocks pin the virtual thread to its carrier (the carrier cannot be reused while the virtual thread is blocked inside a synchronized block), so prefer ReentrantLock for locks that wrap I/O. Also, ThreadLocal variables work but can be wasteful at millions-of-threads scale — prefer ScopedValue (Java 21 preview) for per-request context propagation.
Structured Concurrency and Pinning
Structured Concurrency (JEP 453, preview in Java 21) groups related virtual threads into a scope — if any thread fails, the scope can cancel the rest. This makes concurrent fan-out code readable without callback hell. Pinning is the main gotcha: a virtual thread is pinned to its carrier when it calls synchronized code or JNI. While pinned, the carrier cannot serve other virtual threads, effectively reducing to platform-thread behavior. The fix: replace synchronized with ReentrantLock (which is virtual-thread-aware). Detect pinning with -Djdk.tracePinnedThreads=full.
Code example
// Create a virtual thread (Java 21+)
Thread vt = Thread.ofVirtual().start(() -> {
// blocking I/O is fine here
String result = httpClient.send(request, BodyHandlers.ofString()).body();
System.out.println(result);
});
// ExecutorService backed by virtual threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000)
.forEach(i -> executor.submit(() -> doBlockingWork(i)));
} // auto-closes, waits for all tasks
// Structured concurrency (preview)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> user = scope.fork(() -> fetchUser(id));
Future<String> order = scope.fork(() -> fetchOrder(id));
scope.join().throwIfFailed();
return new Response(user.get(), order.get());
}