ArchitectMind
  1. Home
  2. Tutorials
  3. Book 2 · JVM, Concurrency & Performance
  4. Volume V
  5. Virtual Threads
Book 2 · Volume V — Modern Concurrency, Virtual Threads & Performance Engineering · Topic 1.2

Virtual Threads

Concept, JVM scheduling, carrier threads and mount/unmount

8 min readStage 1 · Foundations10 interview questions
Prerequisites
1.1 Platform Threads · B2V4 Chapter 3
Covers
Concept · JVM scheduling · Carrier threads · Mount/unmount
Related
1.3 Lifecycle · 2.1 Scheduler · 2.3 Pinning
On this page
  1. 1. Concept / Definition
  2. 2. Why It Exists
  3. 3. Core Concepts
  4. 4. How It Works Internally
  5. 5. APIs / Features / Syntax
  6. 6. Practical Examples
  7. 7. Common Problems & Mistakes
  8. 8. Performance & Production Considerations
  9. 9. Architecture & Design Considerations
  10. 10. Interview Questions & Answers

1Concept / Definition

A virtual thread (JEP 444, final in Java 21) is a java.lang.Thread that is not tied to an OS thread. The JVM schedules virtual threads onto a small pool of platform threads called carrier threads. When a virtual thread performs a blocking operation, the JDK unmounts it — saving its stack to the heap and freeing the carrier — and mounts it again, possibly on a different carrier, when it can continue. Code runs unchanged: blocking calls, try/catch, ThreadLocals, stack traces and debuggers all work as with platform threads.

Many virtual threads, few carriers
Figure 1 — Many virtual threads, few carriers

2Why It Exists

Server applications spend most request time waiting on networks and databases. With platform threads, waiting holds scarce OS threads, so high concurrency required asynchronous code (callbacks, CompletableFuture, reactive streams) that is harder to write, read, debug and profile. Virtual threads restore the simple thread-per-request style while achieving the scalability of asynchronous code for I/O-bound workloads.

The virtual thread idea
Figure 2 — The virtual thread idea

3Core Concepts

Concept

  • Virtual threads are cheap: creating one costs about as much as a small object allocation plus a stack chunk; millions can exist.
  • They are not pooled: create one per task and let it end; pooling adds no benefit and hurts (ThreadLocal leakage, artificial limits).
  • They are not faster: a virtual thread runs code at the same speed; the gain is concurrency (more tasks waiting at once), i.e. throughput for I/O-bound work.
  • Properties: always daemon; priority fixed at NORM; no thread groups; isVirtual() returns true; names default to empty.

JVM scheduling

  • The JDK — not the OS — decides which virtual thread runs on which carrier.
  • The default scheduler is a ForkJoinPool in FIFO mode with parallelism equal to available processors (2.1).
  • Scheduling is not time-sliced: a virtual thread runs until it blocks, parks, yields or ends.
  • The OS still schedules the carriers preemptively among all OS threads.

Carrier threads

  • Carriers are platform threads owned by the scheduler (ForkJoinPool-1-worker-N in dumps).
  • While mounted, Thread.currentThread() returns the virtual thread, not the carrier — the carrier is invisible to application code.
  • Stack traces of a virtual thread show only its own frames.
  • If a virtual thread cannot unmount while blocked (pinning, 2.3), its carrier is blocked too.

Mount/unmount

Mounting and unmounting
Figure 3 — Mounting and unmounting

Mounting is the JVM's continuation mechanism: a virtual thread wraps a Continuation; mounting continues it on the carrier; blocking operations in the JDK call park, which yields the continuation — frames are copied into StackChunk heap objects — and returns control to the carrier's scheduler loop.

4How It Works Internally

The JDK reimplemented blocking APIs to be virtual-thread-aware: socket I/O uses non-blocking sockets plus a poller; java.util.concurrent locks and queues park; Thread.sleep parks with a timer; since JDK 24, monitors (synchronized, Object.wait) also unmount. The JVM supports freezing and thawing stack frames efficiently (copying only frames that changed, lazily thawing on return), so switching between virtual threads costs far less than an OS context switch.

5APIs / Features / Syntax

Creating virtual threads
Figure 4 — Creating virtual threads
JAVAUsing virtual threads
// One virtual thread per task
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<Quote>> quotes = suppliers.stream()
            // blocking HTTP call inside
            .map(s -> executor.submit(() -> s.quote(sku)))
            .toList();
    for (Future<Quote> q : quotes) best = better(best, q.get());
}

// Named virtual threads via builder / factory
Thread vt = Thread.ofVirtual().name("order-", 0).start(() -> process(order));
ThreadFactory factory = Thread.ofVirtual().name("ingest-", 0).factory();

// Spring Boot 3.2+: run request handling on virtual threads
// application.properties →  spring.threads.virtual.enabled=true

// Limit concurrency towards a downstream (virtual threads are unbounded!)
Semaphore dbPermits = new Semaphore(50);
<T> T withDb(Callable<T> call) throws Exception {
    dbPermits.acquire();
    try { return call.call(); } finally { dbPermits.release(); }
}

6Practical Examples

ScenarioWith virtual threads
REST service calling 3 downstreams per requestthread per request; blocking clients; thousands in flight
batch of 10,000 HTTP fetchesnewVirtualThreadPerTaskExecutor + Semaphore limit
Kafka consumer with blocking processingvirtual thread per record batch, bounded
CPU-heavy image processingno gain — use a platform pool sized to cores

7Common Problems & Mistakes

  • Pooling virtual threads (fixed pools of virtual threads).
  • Removing concurrency limits — databases and APIs still have capacity limits.
  • Expecting faster CPU-bound code.
  • Heavy ThreadLocal caches (per-thread buffers) multiplied by millions of threads (2.4).
  • Native calls or class-initialisation blocking causing pinning (2.3).

8Performance & Production Considerations

  • Throughput gains appear when the workload is I/O-bound and concurrency was limited by threads.
  • Watch downstream saturation, connection pools (now the real limit) and heap usage (stack chunks).
  • Diagnose with jcmd Thread.dump_to_file -format=json (includes virtual threads) and JFR (jdk.VirtualThreadPinned, optional start/end events).

9Architecture & Design Considerations

Virtual threads shift the scalability bottleneck from threads to real resources — connections, downstream capacity, memory. Architectures can return to simple, synchronous service code with explicit concurrency limits per dependency, reserving reactive frameworks for streaming and backpressure-centric designs (Chapter 4).

10Interview Questions & Answers

Q1KnowledgeConcept

What is a virtual thread?

A virtual thread is a java.lang.Thread that the JVM schedules itself rather than mapping it one-to-one to an OS thread; it became final in Java 21 through JEP 444. The JDK runs virtual threads on a small pool of platform carrier threads, and when one blocks on I/O, a lock or a sleep, it unmounts, saving its stack to the heap so the carrier can run another. Because a virtual thread costs little more than an object and a small stack chunk, an application can have millions. The programming model is unchanged: blocking calls, exceptions, ThreadLocals, stack traces and debuggers work as normal, which makes thread-per-request scalable again.

Q2KnowledgeConcept

What is a carrier thread?

A carrier thread is a platform thread owned by the virtual-thread scheduler, which by default is a ForkJoinPool in FIFO mode with parallelism equal to the number of available processors. A carrier executes a virtual thread while it is mounted, then picks up another runnable one when it unmounts. In thread dumps carriers appear as ForkJoinPool-1-worker-N. They are deliberately invisible to application code: Thread.currentThread() returns the virtual thread, ThreadLocals belong to the virtual thread, and stack traces show only its frames. The important operational fact is that if a virtual thread cannot unmount while blocked, known as pinning, its carrier is blocked too, reducing the scheduler's effective parallelism.

Q3UnderstandingInternals

What happens when a virtual thread blocks on I/O?

The JDK's blocking APIs are virtual-thread-aware, so the thread parks instead of blocking an OS thread. For sockets, the JDK uses a non-blocking socket, registers interest with a poller and parks. Parking yields the virtual thread's continuation: its frames are frozen into StackChunk objects on the heap, it unmounts, and the carrier returns to the scheduler to run other virtual threads. When the socket is ready, the poller unparks the thread, which is mounted again, possibly on a different carrier, with its frames thawed lazily. To the code it is an ordinary blocking read. Some file operations cannot avoid blocking, so the scheduler temporarily adds a carrier to compensate.

Q4UnderstandingTricky

Are virtual threads faster than platform threads?

No, not for running code. A virtual thread executes the same bytecode at the same speed as a platform thread, and CPU-bound work gains nothing because the number of cores is unchanged. What virtual threads provide is cheap concurrency: they make it practical to have tens of thousands of blocking tasks in flight, where platform threads would hit memory or OS limits. That raises throughput for I/O-bound workloads whose concurrency was previously capped by the thread-pool size. The latency of a single request does not improve, and switching, while much cheaper than an OS context switch, is not free. So the honest answer is better scalability, not faster execution.

Q5ApplicationTricky

Why should virtual threads not be pooled?

Pooling solves a problem virtual threads do not have. Pools exist to amortise the cost of expensive platform threads, but a virtual thread costs about as much as a small object, so reuse saves nothing. A fixed pool of virtual threads also reintroduces the concurrency cap that virtual threads were meant to remove, and reused threads carry ThreadLocal values from one task into the next, which can leak data between requests. Create one per task, for example with Executors.newVirtualThreadPerTaskExecutor() or Thread.ofVirtual(). If the real purpose of the pool was to limit concurrency against a database or API, express that limit explicitly with a Semaphore or the connection pool.

Q6ApplicationInternals

Is scheduling of virtual threads preemptive?

Not among virtual threads. The JDK scheduler is cooperative in practice: a mounted virtual thread runs until it blocks, parks, yields or finishes, and it is not time-sliced off its carrier. The carriers themselves are ordinary platform threads, so the OS still schedules them preemptively against every other thread on the machine. The consequence is that a CPU-heavy loop on a virtual thread monopolises its carrier, and if as many such loops run as there are carriers, all other virtual threads wait, even ones whose I/O is ready. That is why virtual threads suit I/O-bound work, while long CPU-bound computation belongs on a platform-thread pool sized to the cores.

Q7Senior EngineerScenario

What becomes the bottleneck after switching to virtual threads?

The real resources, because threads no longer act as an implicit throttle. With a 200-thread pool, at most 200 requests could hit the database at once; with virtual threads, ten thousand can try. The bottleneck moves to the database connection pool, downstream service capacity, rate limits and heap memory for stack chunks and per-request objects. Typical symptoms are connection-pool timeouts, a downstream service overwhelmed by a traffic spike, or steady memory growth. The fix is explicit limits per dependency: size connection pools deliberately, guard calls with semaphores, rate limiters or bulkheads, and apply timeouts everywhere. Then monitor pool wait time and downstream latency rather than thread counts.

Q8Senior EngineerCode

How do you enable virtual threads in Spring Boot?

On Spring Boot 3.2 or later running on Java 21 or newer, set spring.threads.virtual.enabled=true in the application properties. Boot then configures embedded Tomcat or Jetty to handle each request on a virtual thread, and switches the application task executor used by @Async, the task scheduler and several messaging listener containers to virtual threads. Controllers using blocking clients such as JdbcClient or RestClient need no code changes. Two follow-ups matter. Virtual threads are daemon threads, so set spring.main.keep-alive=true if only scheduled work keeps the application alive. And on Java 21 to 23, check for pinning in synchronized code; Java 24 removed that limitation.

Q9Technical ArchitectScenario

What must you check before migrating a service to virtual threads?

First confirm the workload is I/O-bound and that thread count, not CPU, limits throughput today. Then look for pinning: on Java 21 to 23, blocking inside synchronized blocks pins the carrier, and on all versions blocking inside native frames or class initialisation still does, so check JDBC drivers and other libraries. Review ThreadLocal-heavy code, such as per-thread caches or buffers, which multiplies by the number of threads. Most importantly, size connection pools and add explicit concurrency limits, because the old thread pool was silently protecting downstreams. Finally, load-test against realistic dependencies and verify with JFR jdk.VirtualThreadPinned events, JSON thread dumps and pool wait metrics before moving production traffic.

Q10Solution ArchitectTrade-off

How do virtual threads change the architecture choice between synchronous and reactive stacks?

They remove the main reason most request/response services adopted reactive stacks: scalability under high concurrency without exhausting threads. With virtual threads, plain synchronous code on Spring MVC with blocking clients reaches similar throughput for I/O-bound services, while being far easier to write, debug, profile and hire for. So for new CRUD and orchestration services the default shifts to synchronous code with explicit concurrency limits. Reactive still earns its place where the problem is inherently a stream: backpressure across a pipeline, long-lived streaming connections, event-driven processing and fine-grained composition of many asynchronous sources. Existing reactive systems that work well rarely justify a rewrite just to adopt virtual threads.

Newsletter

Get new tutorials every week

New ArchitectMind tutorials and interview questions, once a week. No spam, unsubscribe anytime.

Almost done! Check your inbox and click the confirmation link to complete your subscription.