- Prerequisites
- 1.2 Virtual Threads
- Covers
- Creation · Scheduling · Blocking · Parking · Resumption · Termination
- Related
- 2.1 Scheduler · 2.2 Blocking Operations · 2.3 Pinning
On this page
1Concept / Definition
A virtual thread moves through a lifecycle that mirrors a platform thread's from the application's point of view but differs underneath: it is created as a heap object, scheduled onto the virtual-thread scheduler, runs while mounted, turns blocking operations into parking (unmount), is resumed when unparked (re-queued and mounted, possibly on another carrier), and terminates when its task completes, leaving only garbage behind.

2Why It Exists
The lifecycle is what makes virtual threads cheap: no OS resources at creation, none while waiting, and a stack that exists only as heap objects when parked. Understanding each phase explains their performance profile, the cost of pinning, and what diagnostic tools show.
The lifecycle is also the contract that lets existing code run unchanged. A virtual thread is a java.lang.Thread with the usual states, interruption, thread-locals and join, so a servlet, a JDBC call or a blocking HTTP client behaves as before. The difference lies in the transitions: the expensive part of a platform thread's life, an OS thread held for the whole duration including every wait, is replaced by short mounted intervals separated by parked intervals that cost only heap. That is what allows one-thread-per-request designs at hundreds of thousands of concurrent requests.
3Core Concepts

Creation
Thread.ofVirtual().unstarted(r)orstart(r),Thread.startVirtualThread(r), a virtualThreadFactory, ornewVirtualThreadPerTaskExecutor().- Allocates the
VirtualThreadobject and its continuation — no native thread, no stack reservation. - Thread ids are unique like any thread's; names are empty unless set.
Scheduling
start()submits the virtual thread to the scheduler's queue.- A free carrier takes it and mounts it (continues the continuation).
- Unlike platform threads, the order of execution among runnable virtual threads is decided by the ForkJoinPool (FIFO within queues, work stealing across carriers).
Blocking

- Blocking JDK operations (socket I/O, sleep, locks, queues, monitors since JDK 24) are converted into parking.
- Operations that truly block the OS thread (file I/O, some native calls) either trigger scheduler compensation (a temporary extra carrier) or pin the carrier (2.2, 2.3).
Parking

LockSupport.park()on a virtual thread yields its continuation: frames are frozen into heapStackChunks and the carrier is released.- Thread state as reported by
getState(): WAITING or TIMED_WAITING (BLOCKED while waiting to enter a monitor). - A parked virtual thread costs only heap memory proportional to its stack depth.
Resumption
unpark(from I/O pollers, lock releases, timers, other threads) makes the virtual thread runnable and submits it again.- It resumes on any available carrier; frames are thawed lazily (only as the code returns into them).
- Resumption latency depends on carrier availability — CPU-hogging or pinned virtual threads delay others.
Termination
- When
run()returns or throws, the virtual thread terminates; uncaught exceptions go to its handler. - Its stack chunks become unreachable and are collected by GC.
join()works as for platform threads; structured concurrency (Chapter 3) bounds lifetimes explicitly.
4How It Works Internally
VirtualThread holds a state field (NEW, STARTED, RUNNING, PARKING, PARKED, PINNED, YIELDING, RUNNABLE, TERMINATED and timed variants) updated with CAS. park() on a mounted virtual thread attempts Continuation.yield; if frames that cannot be frozen are on the stack (native frames), yielding fails and the thread parks on the carrier instead — pinning. Timed parking uses a scheduled executor to unpark on timeout. The continuation's frames are stored in jdk.internal.vm.StackChunk objects, which the GC scans like other heap objects.
5APIs / Features / Syntax
Thread vt = Thread.ofVirtual().name("demo").unstarted(() -> {
try {
Thread.sleep(Duration.ofSeconds(1)); // parks → unmounts
fetch("https://example.com"); // socket I/O parks too
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
System.out.println(vt.getState()); // NEW
vt.start();
Thread.sleep(100);
System.out.println(vt.getState()); // TIMED_WAITING (parked in sleep)
vt.join();
System.out.println(vt.getState()); // TERMINATED
// Optional JFR events for virtual-thread start/end (disabled by default: high volume)
// jfr configure +jdk.VirtualThreadStart#enabled=true +jdk.VirtualThreadEnd#enabled=true --output vt.jfc6Practical Examples
| Observation | Explanation |
|---|---|
| 100,000 parked virtual threads, heap +200 MB | stack chunks on the heap (~KB each) |
| virtual thread resumes on a different carrier | no carrier affinity; thread identity preserved |
| virtual thread stays mounted for seconds | CPU-bound loop — no blocking point to unmount |
| jstack shows only carriers | use Thread.dump_to_file for virtual threads |
7Common Problems & Mistakes
- Expecting preemption between virtual threads during long computations.
- Relying on thread identity of carriers (e.g. native thread-local data).
- Using classic
jstackand missing virtual threads.
Most lifecycle mistakes come from assuming the thread behaves like a pooled platform thread. Pooling virtual threads, or reusing them for many tasks, defeats their cheap creation and keeps thread-locals alive longer than intended; create one per task instead. Caching expensive objects in ThreadLocal assumes few long-lived threads, so with a million short-lived virtual threads each one builds its own copy. Limiting concurrency by sizing a thread pool no longer works because there is no pool; use a Semaphore around the scarce resource. Finally, code that blocks in native calls or long CPU loops never reaches a parking point, so it holds its carrier and delays resumption of every other virtual thread queued on it.
8Performance & Production Considerations
- Heap sizing must include parked virtual-thread stacks at peak concurrency.
- Deep stacks (frameworks, recursion) increase per-thread memory and freeze/thaw work.
- Keep blocking points in long tasks to allow fair sharing of carriers.
Measure the lifecycle rather than guessing. JFR's jdk.VirtualThreadPinned event, enabled by default with a 20 ms threshold, shows where a thread blocked without unmounting, and jdk.VirtualThreadSubmitFailed signals scheduler trouble. A JSON thread dump from jcmd Thread.dump_to_file lists every virtual thread with its stack, grouped by thread container. Watch the scheduler's parallelism, which defaults to the number of available processors: if all carriers are busy, unparked threads wait in the queue and latency rises even though CPU looks only moderately high. Short mounted intervals and frequent parking keep resumption latency low.
9Architecture & Design Considerations
The lifecycle changes where a system's limits sit. With platform threads, the thread pool was the throttle: when all threads were busy, new requests queued. With virtual threads, a new thread is created for every request and parks cheaply, so nothing stops a burst of ten thousand requests from reaching the database pool, a downstream API or a rate-limited partner at once. Design explicit back-pressure: semaphores or bulkheads per dependency, bounded connection pools with short acquisition timeouts, and admission control at the edge.
Fit the workload to the model. Virtual threads suit I/O-bound request handling, where most of each thread's life is spent parked. CPU-bound work gains nothing and can starve carriers, so run it on a bounded platform pool or a ForkJoinPool sized to cores. Use structured concurrency to tie child threads' lifetimes to the request that started them, so cancellation and failures propagate and no orphaned threads outlive the request. Plan heap and observability around parked stacks rather than OS threads, since that is now where concurrent work is stored.
10Interview Questions & Answers
What does creating a virtual thread involve?
Creating a virtual thread is just object allocation on the Java heap. Thread.ofVirtual().unstarted(task) or start(task), Thread.startVirtualThread, a virtual ThreadFactory, or Executors.newVirtualThreadPerTaskExecutor allocate a VirtualThread object and a Continuation wrapping the task. No OS thread is created and no stack is reserved; the stack only appears as heap StackChunk objects once the thread first parks. The thread gets a unique id like any thread, an empty name unless one is set, and it is always a daemon. start() then submits it to the virtual-thread scheduler, a ForkJoinPool, where a carrier thread will mount it. This is why creating one per task, even millions, is the intended usage.
What happens when a virtual thread parks?
It unmounts from its carrier instead of blocking it. A blocking JDK operation, such as a socket read, sleep, a ReentrantLock wait, a queue take or since JDK 24 a contended synchronized block, ends in LockSupport.park. On a virtual thread, park calls Continuation.yield: the frames between the continuation's entry and the current point are frozen, copied into heap StackChunk objects, and the carrier returns to the scheduler to run other virtual threads. The thread's state becomes WAITING, TIMED_WAITING or BLOCKED as appropriate. If the stack contains a frame that cannot be frozen, such as a native frame from JNI, yielding fails and the thread pins, parking the carrier itself instead.
Where does a virtual thread resume after parking?
On whichever carrier the scheduler chooses, which is usually not the one it ran on before. When the event it was waiting for happens, such as data arriving on a socket, a lock being released or a timer firing, the poller or releasing thread calls unpark, which makes the virtual thread runnable and submits it to the ForkJoinPool. A free carrier takes it, possibly by work stealing, mounts it and thaws its frames lazily, only as execution returns into them. Thread identity is preserved: Thread.currentThread, the name and thread-locals stay the same. Code must therefore never rely on carrier identity, for example native thread-local data or OS thread ids.
What Thread.State does a parked virtual thread report?
The same state a platform thread would report in the equivalent situation, which is the point of the design. A virtual thread parked in sleep, a timed poll or a lock wait with a timeout reports TIMED_WAITING; one parked in take, join or an untimed lock reports WAITING; one waiting to enter a contended synchronized block reports BLOCKED. Internally the VirtualThread has a richer state field, with values such as PARKING, PARKED, PINNED and YIELDING, but getState maps these onto the standard Thread.State values. The tricky part is that WAITING no longer means an OS thread is held, so counting waiting virtual threads says nothing about resource use.
Why can a CPU-bound virtual thread delay others?
Because virtual threads are not time-sliced. The scheduler only switches a carrier to another virtual thread when the current one parks or terminates, so a virtual thread running a long computation without blocking keeps its carrier for the whole duration. The pool has only as many carriers as available processors by default, so a few such threads can occupy every carrier, and thousands of unparked virtual threads then sit in the queue waiting to resume, which shows up as latency spikes in unrelated requests. The fix is to run CPU-bound work on a separate bounded platform pool, or to split it into smaller steps, rather than relying on preemption that does not exist.
What happens to memory when a virtual thread terminates?
Nothing needs to be released explicitly. When run() returns or throws, the virtual thread becomes TERMINATED, any uncaught exception goes to its handler, and threads waiting in join are woken. Because it never owned an OS thread or a native stack, there are no OS resources to free. Its StackChunk objects, its continuation and, once no references remain, the Thread object itself become unreachable and are reclaimed by the garbage collector like any other heap objects, along with the values in its thread-locals. One caveat: anything the task registered elsewhere, such as listeners, open connections or entries in static maps, still leaks, because termination only frees what the thread itself held.
How do you size heap for a service with many concurrent virtual threads?
Treat parked stacks as part of the live set. Estimate peak concurrency with Little's law, throughput multiplied by latency, so 5,000 requests per second at 200 ms means about 1,000 concurrent virtual threads, then add headroom for bursts and slow dependencies. Measure the typical parked stack size under realistic load from a heap histogram of jdk.internal.vm.StackChunk or a heap dump; framework-heavy stacks are often a few kilobytes to tens of kilobytes. Multiply the two and add the result, plus per-request objects and thread-locals, to the baseline live set. Then validate with a load test at peak concurrency and check GC frequency and old-generation occupancy rather than trusting the arithmetic.