Skip to content
Path to Engineer
All lessons
JavaConcurrency16 min read

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.

Day 72 of the 488-day pathway. Published in full — nothing held back.

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:

  1. No leaks — subtasks cannot outlive the scope.
  2. Automatic cancellation — one failure cancels the siblings (ShutdownOnFailure); ShutdownOnSuccess gives you the first-wins/hedged pattern.
  3. 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:

  1. Inside a synchronized block (in Java 21 — see below).
  2. 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

  1. Run VirtualThreads section 2. Record the time for a million virtual threads.
  2. Run section 3 and record the platform-thread comparison. Compute the ratio.
  3. Print Thread.currentThread() inside a virtual thread and identify the carrier worker.
  4. Write a task that sleeps inside a synchronized block, run 1,000 of them on Java 21 with -Djdk.tracePinnedThreads=full, and observe the pinning reports. Convert to ReentrantLock and re-measure.
  5. 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.
  6. Write the structured-concurrency version of a two-call fan-out and force one call to fail; confirm the sibling is cancelled.
  7. 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

  1. What is a virtual thread, and where does its stack live?
  2. Describe mount/unmount in terms of continuations.
  3. Give the four numeric comparisons against platform threads.
  4. What problem with async code do virtual threads eliminate?
  5. Why must you never pool them?
  6. Which Day 069 formula becomes obsolete, and which rule survives?
  7. Why do CPU-bound workloads gain nothing?
  8. What is pinning, what causes it, how do you detect it, and how do you fix it?
  9. What changed in JDK 24 regarding pinning?
  10. Why does removing the thread limit make an explicit rate limit more necessary?
  11. What three guarantees does structured concurrency provide?
  12. 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 ThreadLocal day. 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 volatile doesn't make i++ 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.