Java · Java 21 Features (18 to 21)

Java 21: Virtual Threads and Structured Concurrency

How virtual threads unmount from their carriers, why you create one per task and never pool them, pinning inside synchronized on Java 21, ThreadLocals at scale, and the structured concurrency and scoped value previews.

  • 6 min read
  • 12 practice questions
  • 1 graded problem
On this page
  1. Description
  2. What a virtual thread is
  3. The rules
  4. Pinning: the one trap on Java 21
  5. Structured concurrency and scoped values (previews)
  6. Common mistakes
  7. Check yourself
  8. Key takeaways
  9. Quick revise
  10. Practice

Description

Java 21 (September 2023) is the long-term-support release after 17, and its headline feature is virtual threads: the biggest change to Java concurrency since java.util.concurrent arrived in Java 5.

Here's the problem they solve. A typical service handles each request on a thread from a pool of, say, 200 threads. Each request spends most of its time waiting: for the database, for two HTTP calls to other services. At 200 concurrent requests the pool is used up, and new requests queue, while the CPU sits at 10%. The threads aren't working; they're waiting. Until now the fixes were a bigger pool (but each classic thread is an operating-system thread with its own memory, so thousands get expensive) or rewriting the code as callbacks and CompletableFuture chains (fast, but hard to read and debug).

Virtual threads let simple, blocking, one-thread-per-request code scale to many thousands of concurrent requests. This skill covers how to use them, the rules that keep them working well, and two related features that were previews in Java 21.

Here's the path, from easiest to most technical:

  1. What a virtual thread is, and how to start one.
  2. The rules: what they're good for, and why you never pool them.
  3. Pinning: the one trap on Java 21.
  4. Structured concurrency and scoped values (previews).

What a virtual thread is

A virtual thread is a real java.lang.Thread, but a very cheap one. The JVM runs it on one of a small number of carrier threads (one per CPU core, by default). Whenever the virtual thread waits for something (sleeping, reading from the network, waiting for a lock), the JVM sets it aside and the carrier runs another virtual thread. When the wait is over, it's picked up again.

Starting one looks like starting any thread:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (Request r : requests) {
        executor.submit(() -> handle(r));     // a brand-new virtual thread for each task
    }
}                                             // the end of the block waits for all of them

Thread t = Thread.ofVirtual().name("import-", 0).start(task);

The effect is dramatic for waiting-heavy work: in the course's sandbox, 10,000 tasks that each slept for one second all finished in under three seconds, on a single carrier thread.

The rules

1. They help with waiting, not with computing. Virtual threads let many tasks wait at the same time. They don't make CPU-heavy work faster: there are still only as many carriers as cores.

2. Never put them in a pool. Pools exist to ration expensive threads. Virtual threads are cheap, so create one per task and let it go. If you need to protect something, like a database that can handle only 50 queries at once, limit that explicitly with a Semaphore(50) or the connection pool, not by limiting threads.

3. The JVM doesn't wait for them at exit. Virtual threads are always daemon threads. Wait for them yourself, for example with the executor's try-with-resources block.

4. Be careful with ThreadLocal. It still works, but every virtual thread gets its own copy, so a "one cached object per thread" trick that made 200 copies on a pool now makes one per task, potentially millions.

Pinning: the one trap on Java 21

On Java 21, a virtual thread that waits while inside a synchronized block can't be set aside. It pins its carrier: the carrier sits there, occupied but idle, until the wait ends. With only a few carriers, a few pinned threads can stall everything.

In the sandbox, with one carrier, four virtual threads that each slept half a second inside synchronized took about 2 seconds, one after another. The same tasks using a ReentrantLock instead finished in about half a second, because waiting while holding a ReentrantLock doesn't pin.

So on Java 21:

  • Don't wait (sleep, do I/O, call other services) inside synchronized. Use ReentrantLock around waiting code. Short synchronized sections that don't wait are fine.
  • -Djdk.tracePinnedThreads=full prints a stack trace whenever a thread pins, which helps find the culprits.
  • Java 24 removed this limitation (JEP 491), one more reason teams move to newer releases.

Structured concurrency and scoped values (previews)

Two related features shipped in Java 21 as previews (they compile only with --enable-preview).

Structured concurrency treats a group of subtasks like a block of code: they start inside a scope and must all finish before it ends.

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<Profile> profile = scope.fork(() -> fetchProfile(id));
    Subtask<List<Order>> orders = scope.fork(() -> fetchOrders(id));
    scope.join().throwIfFailed();       // wait for both; rethrow the first failure
    return new Page(profile.get(), orders.get());
}

If one subtask fails, the others are cancelled straight away. In the sandbox, when one subtask failed immediately, the scope gave up in well under a second instead of waiting two seconds for the other. No thread is left running after the block, and no failure is forgotten.

Scoped values pass context, such as the current user, to everything a piece of code calls, including subtasks:

static final ScopedValue<String> USER = ScopedValue.newInstance();

ScopedValue.where(USER, "alice").run(() -> handle());   // USER.get() is "alice" inside, unset afterwards

Unlike a ThreadLocal, a scoped value can't be changed, is only set for the duration of run, and needs no cleanup. Scoped values became final in Java 25; structured concurrency was still being refined there.

Common mistakes

MistakeWhat happensDo this instead
Virtual threads for CPU-heavy workno speedupa platform-thread pool sized to the cores
A fixed-size pool of virtual threadsqueuing, for nothingone virtual thread per task
Limiting concurrency with the thread countnothing protects the databasea Semaphore or the connection pool
Waiting inside synchronized on Java 21pins the carrierReentrantLock around waiting code
Expecting the JVM to wait for them at exitwork is cut offjoin them, or use the executor's try-with-resources
Per-thread ThreadLocal cachesone copy per taskshared immutable objects, or scoped values

Check yourself

Try to answer before opening each one. Recalling beats re-reading.

1. A service's 200 threads are all busy, the CPU is at 10%, and each request mostly waits on the database and HTTP calls. Why would virtual threads help?

Show answer

The threads are mostly waiting, not computing. Virtual threads are cheap enough to have one per request, and while one waits, its carrier runs others, so thousands of waiting requests no longer need thousands of expensive threads.

2. Why shouldn't you put virtual threads in a fixed-size pool?

Show answer

Pools exist to ration expensive threads, and virtual threads are cheap. A pool only brings back queuing. Create one per task, and limit concurrency with a Semaphore where something downstream needs protecting.

3. What is pinning, and how do you avoid it on Java 21?

Show answer

A virtual thread that waits inside a synchronized block can't be set aside, so it holds its carrier thread. Use ReentrantLock around code that waits. Java 24 removed the limitation.

4. What does StructuredTaskScope.ShutdownOnFailure give you over submitting two tasks to an executor?

Show answer

When one subtask fails, the others are cancelled, join().throwIfFailed() rethrows the failure, and no thread outlives the block.

Key takeaways

  • Virtual threads make blocking, one-thread-per-request code scale for work that mostly waits.
  • Create one per task, never pool them, and limit concurrency with semaphores or connection pools.
  • They don't speed up CPU-heavy work, and they're daemon threads.
  • On Java 21, don't wait inside synchronized: use ReentrantLock.
  • Structured concurrency and scoped values (previews in 21) fix leaked subtasks and ThreadLocal cleanup.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Using them

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (Request r : requests) executor.submit(() -> handle(r));   // one virtual thread per task
}                                                                  // waits for all of them
Thread.ofVirtual().name("import-", 0).start(task);
Semaphore db = new Semaphore(50);                                  // limit what's fragile, not threads

The rules

RuleWhy
Use for waiting-heavy (I/O) workthey free the carrier while waiting
Not for CPU-heavy workcarriers = CPUs
Never pool themthey're cheap; pools only add queuing
Limit with semaphores and connection poolsprotect the database, not the thread count
Join themthey're daemon threads
Avoid per-thread ThreadLocal cachesone copy per task
On 21, no waiting inside synchronizedpinning: use ReentrantLock

Previews in 21

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {   // --enable-preview on 21
    var a = scope.fork(() -> fetchProfile(id));
    var b = scope.fork(() -> fetchOrders(id));
    scope.join().throwIfFailed();                                  // first failure cancels the rest
}
ScopedValue.where(USER, "alice").run(() -> handle());             // immutable, scoped, no cleanup

The traps

  • Pooling or "sizing" virtual threads.
  • Expecting CPU-bound speedups.
  • Blocking inside synchronized (and Object.wait()) on 21.
  • Letting the JVM exit before they finish.
  • Unlimited concurrency against a small connection pool.

Staff-level points, one line each

  • Continuations: frames copied to heap stack chunks on yield; mount on any carrier on resume.
  • Scheduler: a FIFO ForkJoinPool, parallelism = CPUs (jdk.virtualThreadScheduler.parallelism).
  • Pins on 21: synchronized waits, Object.wait(), native calls. File I/O compensates with extra carriers (max 256).
  • JDK 24 (JEP 491): synchronized no longer pins.
  • Observe: jcmd Thread.dump_to_file -format=json, JFR jdk.VirtualThreadPinned, -Djdk.tracePinnedThreads=full; name your threads.
  • The bottleneck moves to connection pools, rate limits and memory: plan with explicit limits and timeouts.
  • Scoped values final in 25 (cheap inheritance, no copying); structured concurrency reworked by 25 (open + joiners).

Say it in an interview

  • "Our requests mostly wait on I/O, so one virtual thread per request lets us drop the 200-thread pool."
  • "I'd keep the connection pool and put a semaphore in front of the payment API: virtual threads remove the thread limit, not the database's."
  • "On Java 21 I'd replace synchronized around those HTTP calls with a ReentrantLock to avoid pinning."

Practice

This skill comes with 12 multiple-choice questions that tell you which misconception a wrong answer reveals and 1 graded problem. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Java 21: Virtual Threads and Structured Concurrency free

Fetch Partner Quotes

MediumProCoding problemJava

A travel site in Denver asks dozens of airline partners for a price at the same time, then shows the cheapest. Each partner's API takes about 100 ms, so asking 150 partners one after another would take 15 seconds. Some partners are occasionally down.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice Java 21: Virtual Threads and Structured Concurrency free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime