Virtual threads in Java
Java 21 lightweight threads for blocking I/O at massive scale; pinning; don't pool them.
Lightweight threads
Virtual threads (final in Java 21) are managed by the JVM, not the OS. Many virtual threads run on a few carrier platform threads. Create one with Thread.ofVirtual().start(task), or get one per task from Executors.newVirtualThreadPerTaskExecutor().
Thread.ofVirtual().start(() -> handle(req));
try (var ex = Executors
.newVirtualThreadPerTaskExecutor()) {
ex.submit(() -> handle(req));
}Unmounting
1. VT-1 runs on carrier C. 2. VT-1 blocks on a socket read, so it unmounts: its stack is parked on the heap. 3. C runs VT-2. 4. Data arrives; VT-1 remounts, maybe on another carrier. Blocking costs almost nothing, so millions of threads can wait at once.
Your turn
What does this print?
Thread t = Thread.ofVirtual().start(() ->
System.out.println(
Thread.currentThread().isVirtual()));
t.join();
System.out.println(
Thread.currentThread().isVirtual());true falsetrue truefalse false
Show the answer
The task runs on a virtual thread, so it prints true. main is an ordinary platform thread, so the last line prints false. join() makes main wait for the virtual thread first.
Made for waiting
Virtual threads shine at blocking I/O: HTTP calls, database queries, files. They give no speedup for CPU-bound work, because the cores are still the limit. One more fact: virtual threads are always daemon threads, so they never keep the JVM alive on their own.
Don't pool them
var ex = Executors.newFixedThreadPool(
200, Thread.ofVirtual().factory());Pooling a cheap thing brings back the very limit virtual threads were designed to remove.
var db = new Semaphore(100);
try (var ex = Executors
.newVirtualThreadPerTaskExecutor()) {
ex.submit(() -> query(db));
}One new virtual thread per task. Cap the scarce resource (100 DB connections) with a Semaphore or a sized connection pool; extra requests wait cheaply.
Pinning
If a blocked virtual thread can't unmount, it's pinned to its carrier. On Java 21–23, blocking inside synchronized pinned it. With only a few carriers, a handful of pinned threads stalls everything. JEP 491 (Java 24) lets virtual threads unmount while holding monitors, largely fixing this, though native calls still pin.
The Java 21 stall
A team moved to virtual threads on Java 21. Under load the service froze: virtual threads blocked on JDBC inside synchronized methods, and every carrier was pinned. Fixes: upgrade to Java 24+, or swap synchronized for ReentrantLock. JFR's jdk.VirtualThreadPinned event shows where pinning happens.
Key takeaways
- Thread.ofVirtual().start(task) or newVirtualThreadPerTaskExecutor()
- Great for blocking I/O; no speedup for CPU-bound work
- Don't pool them — cap concurrency with a Semaphore instead
- Always daemon threads
By default the virtual-thread scheduler uses one carrier per CPU core, so a million virtual threads may share just 8 platform threads.
Practice questions
What does this print?
Thread t = Thread.ofVirtual().unstarted(() -> {});
System.out.println(t.isVirtual());
System.out.println(t.isDaemon());- true true
- true false
- false true
- false false
Check your answer
true true. Thread.ofVirtual() builds a virtual thread, and virtual threads are always daemon threads.
What does this print?
var n = new AtomicInteger();
try (var ex = Executors
.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
ex.submit(() -> n.incrementAndGet());
}
}
System.out.println(n.get());- 10000
- A number below 10000
- 0
- Throws OutOfMemoryError
Check your answer
10000. 10,000 virtual threads are cheap. close() waits for every task, and AtomicInteger loses no increments.