Java virtual threads: what Loom changes and what it doesn't
What virtual threads actually solve, what stays exactly the same, and the pinning cases that quietly undo the benefit.
Virtual threads (Project Loom) — what changes and what doesn't
Closes the concurrency block. Virtual threads (final in Java 21) are the biggest change to Java concurrency since Java 5. The interview question is never "what are they" — it is "what changes, and what does not", because the second half is where people over-claim.
Part 1 — L-072 · The problem being solved
Where we are
Day 064: a platform thread is an OS thread, ~1 MB of stack, µs to switch. Day 069: an I/O-bound
workload needs cores × utilisation × (1 + wait/service) threads — around 150 for one slow
dependency, and thousands if you have several. That does not fit.
So the industry went asynchronous: CompletableFuture, reactive streams, callbacks. Day 067's
B-14 showed what that costs:
// Blocking — readable, debuggable, and it does not scale
Order order = orderService.find(id);
User user = userService.find(order.userId());
Prefs prefs = prefsService.find(user.id());
return new Summary(order, user, prefs);
// Async — scales, and everything else gets worse
return orderService.findAsync(id)
.thenCompose(o -> userService.findAsync(o.userId())
.thenCompose(u -> prefsService.findAsync(u.id())
.thenApply(p -> new Summary(o, u, p))));
The async version costs you: unreadable stack traces (the frames belong to the executor, not
your logic), broken debuggers (step-over does not follow the continuation), no try/catch
across stages, no loops or conditionals without contortions, and ThreadLocal context that
does not propagate (Day 066A). This is the "async colouring" problem — one async call forces every
caller to become async.
Virtual threads eliminate the trade-off: write the blocking version, get the async version's scalability.
How they work
A virtual thread is a thread managed by the JVM, not the OS. It runs on a carrier — a platform
thread from a ForkJoinPool — but is not bound to it.
Virtual threads (millions) ┌──► mounted on ──► Carrier threads (≈ #cores)
┌──┐┌──┐┌──┐┌──┐┌──┐┌──┐ │ ┌────┐┌────┐┌────┐┌────┐
│v1││v2││v3││v4││v5││v6│ ──────┘ │ P1 ││ P2 ││ P3 ││ P4 │
└──┘└──┘└──┘└──┘└──┘└──┘ └────┘└────┘└────┘└────┘
stacks live on the HEAP real OS threads
The mechanism, in one paragraph you should be able to reproduce:
When a virtual thread performs a blocking operation, the JVM unmounts it from its carrier and copies its stack to the heap. The carrier immediately runs another virtual thread. When the I/O completes, the stack is copied back and the virtual thread is mounted on some carrier — possibly a different one — and continues from the exact point it left off.
That is a continuation. Blocking is no longer a thread parking in the kernel; it is the JVM
saving your call stack and scheduling something else. The underlying I/O readiness is delivered by
the same epoll from Day 064's B-13 — the JVM does the multiplexing so your code does not have to.
The numbers that follow:
| Platform thread | Virtual thread | |
|---|---|---|
| Stack | ~1 MB, fixed, native | grows/shrinks on the heap, starts ~few hundred bytes |
| Creation | ~1 ms, a syscall | ~1 µs, an object allocation |
| Switch | ~1–10 µs, kernel | ~100–200 ns, a copy in user space |
| Practical count | thousands | millions |
| Scheduled by | the OS | the JVM (ForkJoinPool) |
Code to type
import java.time.Duration;
import java.util.concurrent.*;
import java.util.stream.IntStream;
public class VirtualThreads {
public static void main(String[] args) throws Exception {
// 1. Creation
Thread v = Thread.ofVirtual().name("v-1").start(() ->
System.out.println("hello from " + Thread.currentThread()));
v.join();
Thread p = Thread.ofPlatform().name("p-1").start(() ->
System.out.println("hello from " + Thread.currentThread()));
p.join();
// 2. One million of them
long t0 = System.currentTimeMillis();
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) { // AutoCloseable!
IntStream.range(0, 1_000_000).forEach(i ->
exec.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
}));
} // close() waits for all tasks
System.out.printf("1,000,000 virtual threads slept 1s in %d ms%n",
System.currentTimeMillis() - t0);
// 3. Try the same with platform threads — DO NOT run above ~10k
long t1 = System.currentTimeMillis();
try (var exec = Executors.newFixedThreadPool(200)) {
IntStream.range(0, 100_000).forEach(i ->
exec.submit(() -> { Thread.sleep(Duration.ofSeconds(1)); return i; }));
}
System.out.printf("100,000 tasks on 200 platform threads: %d ms%n",
System.currentTimeMillis() - t1);
// 4. Who is the carrier?
Thread.ofVirtual().start(() -> {
System.out.println(Thread.currentThread()); // VirtualThread[#25]/runnable@ForkJoinPool-1-worker-3
}).join();
}
}
Section 2 finishes in a little over one second. A million threads, each sleeping a second, on a laptop. Section 3 takes ~500 seconds for a tenth of the work, because 100,000 tasks through 200 threads is 500 sequential rounds. That contrast is the whole pitch, and running it yourself is more persuasive than any explanation.
Note ExecutorService is now AutoCloseable (Java 19+), so try-with-resources shuts it down and
waits — the correct shutdown from Day 069, in one construct.
Structured concurrency
The companion feature. Ordinary concurrent code leaks tasks: if one subtask fails, the others keep running, and there is no relationship between them.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> user = scope.fork(() -> userService.find(id));
Subtask<Orders> orders = scope.fork(() -> orderService.findFor(id));
scope.join(); // wait for both
scope.throwIfFailed(); // propagate the first failure
return new Summary(user.get(), orders.get());
} // ← leaving the block CANCELS anything still running
Three guarantees, all from the block structure:
- No leaks — subtasks cannot outlive the scope.
- Automatic cancellation — one failure cancels the siblings (
ShutdownOnFailure);ShutdownOnSuccessgives you the first-wins/hedged pattern. - Real stack traces — the subtask's trace includes the parent frames.
This is try/finally semantics for concurrency, and it is why virtual threads and structured
concurrency are designed together: cheap threads are only safe if their lifetimes are contained.
(Still a preview API in 21; final form is settling — know the shape.)
ScopedValue (Day 066A) completes the trio: immutable, block-scoped context that propagates into
forked subtasks, replacing ThreadLocal for exactly this pattern.
Part 2 — What changes, and what does not
What changes ✅
1. Never pool virtual threads. Pooling exists to amortise expensive creation. Creating a virtual thread costs ~1 µs, so a pool adds contention and removes the per-task isolation. One virtual thread per task. Full stop.
Executors.newFixedThreadPool(200) // ❌ pointless for virtual
Executors.newVirtualThreadPerTaskExecutor() // ✅
2. The sizing formula is gone. No more cores × utilisation × (1 + wait/service) for I/O work.
You do not size anything.
3. Blocking is fine again. Thread.sleep, InputStream.read, JDBC, HttpClient — all unmount
the virtual thread instead of pinning an OS thread. Sequential blocking code is the recommended
style.
4. ThreadLocal works but is discouraged — a million threads each with a map is real memory.
Use ScopedValue.
5. Debugging and profiling come back. Ordinary stack traces, working step-debuggers, thread
dumps that make sense (jcmd <pid> Thread.dump_to_file -format=json).
6. Rate limiting must move. This one catches people. Your thread pool was implicitly your
concurrency limiter — 200 threads meant at most 200 concurrent calls to the database. With unbounded
virtual threads, a traffic spike can open 10,000 database connections and take the database down.
The limit has to become explicit: a Semaphore (Day 070), a bounded queue, or the connection pool
itself. Removing the thread limit does not remove the need for a limit.
What does NOT change ❌
1. Every synchronization rule still applies. The JMM, happens-before, volatile, locks, atomics,
races, deadlocks, ThreadLocal leaks — all identical. Virtual threads are threads; sharing mutable
state is exactly as dangerous.
2. CPU-bound work gains nothing. You have the same number of cores. A million virtual threads doing arithmetic is a million tasks contending for the same cores, plus scheduling overhead. Use a platform-thread pool sized to the core count for CPU work — the Day 069 rule survives for this case.
3. Deadlocks are unchanged — arguably easier to hit, since more work runs concurrently.
4. Downstream capacity is unchanged. Ten thousand concurrent requests to a service that handles 500 is ten thousand failures instead of a queue.
The pinning caveat — the thing to actually know
A virtual thread cannot unmount in two situations. When it blocks there, it pins its carrier thread, and you lose the benefit:
- Inside a
synchronizedblock (in Java 21 — see below). - Inside a native frame (JNI, some native crypto or driver code).
synchronized (lock) {
Thread.sleep(1000); // 💀 in Java 21: PINS the carrier for a full second
}
lock.lock(); // ✅ ReentrantLock is Loom-aware: unmounts correctly
try { Thread.sleep(1000); }
finally { lock.unlock(); }
With only a handful of carriers (default = core count), a few pinned carriers can stall everything. Detect it:
-Djdk.tracePinnedThreads=full
So: in Java 21, replace synchronized with ReentrantLock around any blocking operation. This
is the single most practical migration action, and it is a genuinely good interview answer.
Important update to state precisely: JDK 24 (JEP 491) removed this limitation — virtual threads
can now unmount inside synchronized. So the honest answer is "pinning on synchronized was a
real problem in 21 and was fixed in 24; native frames still pin." Knowing both halves — and which
version you are targeting — is what separates a current answer from a stale one.
When to use which
I/O-bound, many concurrent tasks (a web service) → virtual threads
CPU-bound (parsing, hashing, image work) → platform pool sized to cores
Long-running background daemon → platform thread
Existing reactive codebase that works → leave it; the gain is readability, not speed
Need a hard concurrency limit → virtual threads + an explicit Semaphore
And the honest framing for an interview: virtual threads are not faster per-operation. They raise the concurrency ceiling and let you write simple code at high concurrency. Throughput for one request is unchanged, latency is unchanged, and CPU work is unchanged.
Framework support
Spring Boot 3.2+: spring.threads.virtual.enabled=true — Tomcat serves each request on a virtual
thread. Combined with the pinning rule and an explicit database concurrency limit, that is a
production-ready configuration and a very concrete thing to say you have done.
Common mistakes
| Mistake | Consequence |
|---|---|
| Pooling virtual threads | Defeats the purpose; adds contention |
| Expecting CPU-bound speedup | No gain; scheduling overhead |
synchronized around blocking on Java 21 |
Pinned carrier; scalability lost |
| Removing rate limits with the thread limit | The database takes the spike instead |
| Assuming the JMM changed | Same races, same bugs |
Heavy ThreadLocal use |
Multiplied by the thread count |
| Not closing the executor | Tasks abandoned; use try-with-resources |
| Claiming "faster" rather than "more concurrent" | Wrong in an interview |
Interview questions
Q: What is a virtual thread? A JVM-managed thread whose stack lives on the heap. It runs on a carrier platform thread and unmounts when it blocks, so blocking costs a heap copy instead of an OS thread. Creation ~1 µs; millions are practical.
Q: What problem do they solve? The thread-per-request model's memory and switching cost, which pushed everyone to async code that scales but is unreadable and undebuggable. Virtual threads give async scalability with blocking-style code.
Q: How do they work internally?
Continuations. Blocking unmounts the virtual thread and copies its stack to the heap; the carrier
runs another; on I/O completion the stack is copied back and execution resumes. The JVM does the
epoll multiplexing underneath.
Q: Should you pool them? No. Pools amortise expensive creation; virtual threads are cheap. One per task.
Q: What is pinning?
A virtual thread that cannot unmount while blocked — in Java 21 inside synchronized, and in any
version inside a native frame. It occupies its carrier. Detect with -Djdk.tracePinnedThreads, fix
by using ReentrantLock. JEP 491 in JDK 24 removed the synchronized case.
Q: What does not change? Everything about correctness: the memory model, visibility, races, deadlock, and the need for synchronization. Also CPU-bound work, and downstream capacity — which is why you still need an explicit concurrency limit.
Q: Do virtual threads replace reactive programming? For most services, yes — the readability and debuggability win is large. Reactive still has a case for backpressure-heavy streaming pipelines and where its operator vocabulary genuinely fits.
Mini task
- Run
VirtualThreadssection 2. Record the time for a million virtual threads. - Run section 3 and record the platform-thread comparison. Compute the ratio.
- Print
Thread.currentThread()inside a virtual thread and identify the carrier worker. - Write a task that sleeps inside a
synchronizedblock, run 1,000 of them on Java 21 with-Djdk.tracePinnedThreads=full, and observe the pinning reports. Convert toReentrantLockand re-measure. - Write a CPU-bound task (hash a million strings) with virtual threads and with a fixed pool sized to your core count. Confirm virtual threads do not win.
- Write the structured-concurrency version of a two-call fan-out and force one call to fail; confirm the sibling is cancelled.
- Build a virtual-thread service that hits a fake database with a
Semaphore(20)limit, and prove that concurrency at the database never exceeds 20 even under 10,000 concurrent requests.
Exit questions
- What is a virtual thread, and where does its stack live?
- Describe mount/unmount in terms of continuations.
- Give the four numeric comparisons against platform threads.
- What problem with async code do virtual threads eliminate?
- Why must you never pool them?
- Which Day 069 formula becomes obsolete, and which rule survives?
- Why do CPU-bound workloads gain nothing?
- What is pinning, what causes it, how do you detect it, and how do you fix it?
- What changed in JDK 24 regarding pinning?
- Why does removing the thread limit make an explicit rate limit more necessary?
- What three guarantees does structured concurrency provide?
- State what does not change — at least four things.
Articulation drill
Two minutes: "What are virtual threads and what changes in how you write code?"
The problem first (thread-per-request cost pushed us to async, async cost us readability), then the mechanism (unmount, heap stack, continuation), then the practical rules: never pool, blocking is fine, watch pinning, keep an explicit concurrency limit. Close on what does not change — the memory model and CPU-bound work. That closing is what makes the answer sound considered rather than enthusiastic.
Previous: Day 071 · Next: Day 073 — Maven & Gradle (not yet written — see Days index)
Concurrency block complete: Days 064–072, plus the added
ThreadLocalday. You can now explain the memory model, choose between locks, atomics and confinement, size a pool with arithmetic, diagnose all three liveness failures from a thread dump, and say precisely what virtual threads do and do not change.Two Stage 1 exit-gate items are now answerable: "explain the JMM and why
volatiledoesn't makei++safe" (065) and "live-code a thread-safe bounded blocking queue" (067, 070).Remaining in Stage 1: Days 073–077 — build tools, logging, testing, and the interview-traps drill.