Virtual Threads (Loom)
Virtual vs platform threads, carrier threads and pinning, Scoped Values as a final ThreadLocal replacement, and Structured Concurrency while it is still a preview API.
7 questions
JuniorTheoryVery commonWhat is a virtual thread, and how does it differ from a platform thread?
What is a virtual thread, and how does it differ from a platform thread?
A virtual thread is a lightweight thread scheduled by the JVM, not the OS. Many are multiplexed onto a small pool of platform (carrier) threads; a blocking call unmounts the virtual thread and frees its carrier, so millions can exist cheaply. A platform thread wraps one OS thread and is costly. Virtual threads are final since Java 21.
Common mistakes
- ✗Thinking a virtual thread owns an OS thread for its whole lifetime like a platform thread
- ✗Assuming you must rewrite blocking code as non-blocking to benefit from virtual threads
- ✗Expecting virtual threads to speed up CPU-bound work, not just I/O-bound concurrency
Follow-up questions
- →Why does blocking I/O on a virtual thread not tie up its carrier thread?
- →When does creating millions of virtual threads still not improve throughput?
MiddleTheoryOccasionalWhat is carrier-thread pinning, and how did Java 24 change it?
What is carrier-thread pinning, and how did Java 24 change it?
Pinning is a virtual thread that cannot unmount from its carrier while it blocks, so the carrier thread stays occupied and throughput collapses back to the pool size. Before Java 24, blocking inside a synchronized block pinned, because the monitor was owned by the carrier thread. JEP 491 in Java 24 made the monitor travel with the virtual thread, so synchronized no longer pins; only native frames, such as a JNI call, still do.
Common mistakes
- ✗Still rewriting every
synchronizedblock as aReentrantLockon Java 24, where monitors no longer pin - ✗Confusing pinning with a virtual thread that is merely busy: a CPU-bound loop occupies a carrier but does not pin it
- ✗Assuming a bigger carrier pool fixes pinning, when it only masks the throughput loss
Follow-up questions
- →How would you detect pinning in production on a JDK where
synchronizedstill pinned? - →Which frames still pin a virtual thread to its carrier after JEP 491?
MiddleTheoryOccasionalHow does a ScopedValue differ from a ThreadLocal under virtual threads?
How does a ScopedValue differ from a ThreadLocal under virtual threads?
A ScopedValue is bound immutably for the dynamic extent of a ScopedValue.where(...).run(...) call and unbinds itself when that call returns; code inside can read it but never set it. A ThreadLocal is mutable, lives as long as its thread, and must be cleared by hand — and with millions of virtual threads every per-thread entry costs memory. Scoped values are shared with threads forked inside the scope by reference, and are final in Java 25 (JEP 506).
Common mistakes
- ✗Calling a
ScopedValuemutable — there is noset(); a different value needs a newwhere(...)binding - ✗Keeping per-request
ThreadLocalcontext in a service with millions of virtual threads and expecting the entries to be free - ✗Forgetting that a
ThreadLocalset on a virtual thread still needsremove(), or it lives for the whole thread
Follow-up questions
- →How does a thread forked inside a
ScopedValuebinding see the caller's value? - →When is a
ThreadLocalstill the right choice over aScopedValue?
MiddleDesignOccasionalA product-page handler fans out to three downstream services — inventory, pricing and reviews. Today it submits three tasks to a shared ExecutorService and then calls get() on each Future in turn; the caller enforces a timeout, and cancellation is best-effort. In a design review a colleague proposes rewriting the fan-out with StructuredTaskScope, the Structured Concurrency API (JEP 505, still a preview in Java 25), forking each subtask onto a virtual thread. Take a position: what does the structured task-scope model give this handler that the manual Future fan-out does not, and what would you weigh against adopting a preview API for it?
A product-page handler fans out to three downstream services — inventory, pricing and reviews. Today it submits three tasks to a shared ExecutorService and then calls get() on each Future in turn; the caller enforces a timeout, and cancellation is best-effort. In a design review a colleague proposes rewriting the fan-out with StructuredTaskScope, the Structured Concurrency API (JEP 505, still a preview in Java 25), forking each subtask onto a virtual thread. Take a position: what does the structured task-scope model give this handler that the manual Future fan-out does not, and what would you weigh against adopting a preview API for it?
StructuredTaskScope binds subtask lifetimes to the scope: subtasks are forked onto virtual threads, and the scope cannot be exited until every one of them finishes or is cancelled, so nothing outlives the request. A failure cancels the siblings at once instead of leaving the caller blocked in get(), and the parent–child relation stays visible in thread dumps. The cost: it is still a preview API in Java 25 — it needs --enable-preview and its shape can change between releases.
Common mistakes
- ✗Treating
StructuredTaskScopeas a final API and shipping it without weighing the preview risk - ✗Believing the manual
Futurefan-out already cancels the siblings when one downstream call fails - ✗Selling the rewrite as a throughput win rather than a lifetime and cancellation guarantee
Follow-up questions
- →What happens to the sibling subtasks when one of them throws inside a scope?
- →How would you contain the risk of shipping a preview API into production?
SeniorDesignOccasionalYour gateway stamps every request with a correlation id and stores it in the MDC — the logging framework's ThreadLocal-backed context map — so each log line carries it. After request handling moved to one virtual thread per request, two things broke: log lines emitted by subtasks the handler forks no longer carry the id, and a load test shows the MDC maps being retained far longer than a request lives. A request may fan out to several subtasks, and a third-party HTTP client library logs through the same MDC. On Java 25, design how correlation ids should be carried through this service, and state plainly what the design gives up.
Your gateway stamps every request with a correlation id and stores it in the MDC — the logging framework's ThreadLocal-backed context map — so each log line carries it. After request handling moved to one virtual thread per request, two things broke: log lines emitted by subtasks the handler forks no longer carry the id, and a load test shows the MDC maps being retained far longer than a request lives. A request may fan out to several subtasks, and a third-party HTTP client library logs through the same MDC. On Java 25, design how correlation ids should be carried through this service, and state plainly what the design gives up.
Bind the id as a ScopedValue (final in Java 25) around the request — ScopedValue.where(REQUEST_ID, id).run(handler). It is immutable, unbinds when that call returns so nothing is retained past the request, and threads forked inside the binding see it by reference, so fan-out log lines keep the id without a per-thread copy. Since the third-party client logs through MDC, keep MDC as a bridge, populated from the scoped value on each thread that logs. You give up changing the id mid-request.
Common mistakes
- ✗Assuming MDC keeps working unchanged once each request runs on its own virtual thread and forks subtasks
- ✗Relying on copied
InheritableThreadLocalcontext at virtual-thread scale, where every per-thread copy costs memory - ✗Forgetting that the logging library still reads MDC, so the scoped value has to be bridged into it
Follow-up questions
- →How does a subtask forked inside a
ScopedValuebinding get the caller's value? - →What should the logger emit when the correlation id is read outside any binding?
SeniorDesignOccasionalAn order service runs on Tomcat with a fixed 200-thread request pool. Under load the pool saturates and requests queue while CPU sits at 20%, because every request makes three blocking JDBC and HTTP calls. You are asked to migrate it to virtual threads on Java 24. The codebase has a synchronized guard around a hot in-memory cache, one endpoint that calls a JNI image codec, a ThreadLocal tenant context, and a shared JDBC connection pool of 50 connections. Describe how you would carry out the migration, what you would verify before declaring the throughput problem solved, and where virtual threads will not help.
An order service runs on Tomcat with a fixed 200-thread request pool. Under load the pool saturates and requests queue while CPU sits at 20%, because every request makes three blocking JDBC and HTTP calls. You are asked to migrate it to virtual threads on Java 24. The codebase has a synchronized guard around a hot in-memory cache, one endpoint that calls a JNI image codec, a ThreadLocal tenant context, and a shared JDBC connection pool of 50 connections. Describe how you would carry out the migration, what you would verify before declaring the throughput problem solved, and where virtual threads will not help.
Replace the fixed request pool with a virtual thread per request and leave the blocking calls as they are — that is the point. On Java 24 the synchronized cache guard no longer pins (JEP 491) and can stay; the JNI codec still pins a carrier, so that endpoint belongs on a bounded platform-thread pool. The ThreadLocal context keeps working. The real limit is now the 50-connection pool: bound concurrency explicitly, or you have only moved the queue. CPU-bound work gains nothing.
Common mistakes
- ✗Assuming unbounded virtual threads remove the need to bound access to a fixed-size connection pool
- ✗Rewriting
synchronizedblocks asReentrantLockon Java 24, where monitors no longer pin the carrier - ✗Expecting virtual threads to raise throughput on a CPU-bound endpoint
Follow-up questions
- →How would you bound concurrency against a 50-connection database pool once requests run on virtual threads?
- →Which endpoint would you keep on a platform-thread pool after the migration, and why?
SeniorDesignOccasionalYou are starting a new checkout-summary endpoint that must call four internal services in parallel, return once all four succeed, abandon the rest the moment any one fails, and honour a 300 ms deadline. Your team already writes CompletableFuture chains elsewhere and finds them hard to operate — stack traces lose the call site, and cancelling a stage does not reliably stop the work already running behind it. A colleague proposes StructuredTaskScope on virtual threads instead, but in Java 25 that is still a preview API (JEP 505, fifth preview) and the service ships to production next quarter. Argue the decision: what the task-scope model buys you over CompletableFuture, and how you weigh that against building a shipping feature on a preview API.
You are starting a new checkout-summary endpoint that must call four internal services in parallel, return once all four succeed, abandon the rest the moment any one fails, and honour a 300 ms deadline. Your team already writes CompletableFuture chains elsewhere and finds them hard to operate — stack traces lose the call site, and cancelling a stage does not reliably stop the work already running behind it. A colleague proposes StructuredTaskScope on virtual threads instead, but in Java 25 that is still a preview API (JEP 505, fifth preview) and the service ships to production next quarter. Argue the decision: what the task-scope model buys you over CompletableFuture, and how you weigh that against building a shipping feature on a preview API.
The scope model buys lifetime containment: subtasks run on virtual threads, a first failure or the deadline cancels the siblings, and the scope cannot be exited while any subtask is still alive — precisely where the CompletableFuture chains fail. Against it: StructuredTaskScope is still preview in Java 25, needs --enable-preview at compile and run time, and carries no compatibility promise. Hide it behind an interface, or ship on plain virtual threads until it is final.
Common mistakes
- ✗Believing
CompletableFuture.allOf(...).orTimeout(...)actually stops the work running behind the futures - ✗Treating
--enable-previewas a compile-time-only flag, when the JVM demands it at run time too - ✗Selling structured concurrency as a throughput win rather than a cancellation and lifetime guarantee
Follow-up questions
- →What does
CompletableFuture.cancel(true)actually do to the task already running behind that future? - →How would you keep a preview-API dependency from spreading through the codebase?