Learning goals
By the end of this chapter you should be able to:
- Explain virtual threads vs platform threads and when each fits
- Start virtual threads with
Thread.startVirtualThreadandExecutors.newVirtualThreadPerTaskExecutor - Avoid pinning carrier threads by holding monitors or doing heavy CPU on virtual threads
- Refactor “thread-per-request” blocking IO designs to virtual threads on JDK 21
The platform thread problem
A platform thread maps to an OS thread — typically ~1 MB stack, costly to create tens of thousands.
Classic fix: small fixed thread pool + non-blocking IO (callbacks, reactive stacks). That trades simple blocking code for complex async chains.
Virtual threads (Project Loom, Java 21) give you millions of lightweight threads scheduled on a small pool of carrier platform threads. When a virtual thread blocks on IO, the carrier is freed for another virtual thread.
Write blocking style; JVM mounts/unmounts virtual threads efficiently.
Creating virtual threads
Thread vthread = Thread.startVirtualThread(() -> {
String body = fetchHttp(url); // blocking OK
process(body);
});
vthread.join();
Builder API:
Thread.ofVirtual().name("request-", 0).start(task);
Executor — one virtual thread per task:
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i ->
exec.submit(() -> handleRequest(i))
);
}
No need to size the pool to CPU count for IO-bound workloads — size to concurrent operations you tolerate (memory, downstream limits).
Structured concurrency (preview)
StructuredTaskScope (Java 21+, evolving) groups subtasks with clear lifetime:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<String> user = scope.fork(() -> fetchUser(id));
Subtask<Integer> balance = scope.fork(() -> fetchBalance(id));
scope.join();
scope.throwIfFailed();
return new Dashboard(user.get(), balance.get());
}
Parent scope waits for children — avoids orphaned background threads.
What virtual threads are good for
- HTTP servers handling many concurrent blocking requests
- JDBC queries (blocking driver) per request
- File IO with blocking APIs
- Replacing cached thread pools that grew unbounded waiting on sockets
CPU-bound work still belongs on platform threads or a fixed pool sized to cores — virtual threads do not multiply CPU throughput.
Pinning — the footgun
When a virtual thread blocks the carrier because code holds a monitor (synchronized) during native/blocking work, or runs long CPU without yielding, you lose scalability — pinning.
synchronized (lock) {
socket.getInputStream().read(); // may pin carrier during block
}
Mitigations evolving across JDK releases:
- Prefer
ReentrantLockoversynchronizedin hot blocking paths inside virtual threads (when profiling shows pinning) - Keep synchronized sections short
- Use async drivers or dedicated platform pool for CPU-heavy stages
Profile with JDK tools (jcmd Thread.dump_to_file -format=json) — pinning shows carrier monopolization.
ThreadLocal and virtual threads
ThreadLocal on millions of virtual threads can consume huge memory — each virtual thread has slot storage.
Java 21+ introduces scoped values (preview in 21, evolving in 25) as a structured alternative for immutable context propagation across virtual threads.
For new code on 21, minimize ThreadLocal in virtual-thread servers; pass context explicitly or use scoped values when available.
Migrating a blocking service
Before — platform thread pool sized to connection count:
ExecutorService platform = Executors.newFixedThreadPool(200);
After — virtual thread per request:
ExecutorService virtual = Executors.newVirtualThreadPerTaskExecutor();
Keep downstream connection pool limits (DB pool size) — virtual threads cheap ≠ unlimited DB connections.
Common mistakes
- Millions of virtual threads + unbounded memory per task — still OOM if each holds large buffers.
- CPU work on virtual thread executor — starves carriers; use separate platform pool.
- Ignoring DB/client pool sizing — threads scale faster than connections.
- Assuming reactive code needed — virtual threads revive servlet-style blocking handlers.
- Long synchronized blocks in request path — check for pinning under load.
Practice checkpoint
-
50,000 concurrent HTTP clients, blocking
HttpClient— platform pool 200 or virtual threads? Answer: virtual threads (with back-pressure at gateway); platform pool would queue or reject. -
Video transcoding farm — virtual thread per job? Answer: no — CPU-bound; use platform pool ≈ core count.
-
What schedules virtual threads on carriers? Answer: JVM fork-join style scheduler (implementation detail); carriers are platform threads.
-
Why can
synchronizedhurt virtual thread throughput? Answer: may pin carrier during block, reducing effective concurrency.
Interview angles
- Loom value prop: cheap threads + blocking code vs reactive complexity.
- Not faster CPU: more concurrency for waiting work, not more compute per core.
- Carrier pinning: know symptom and mitigation direction.
- Structured concurrency: child tasks tied to parent scope — failure propagation, cancellation.