When a virtual thread executes the following code, it appears no different from a regular thread:
int count = socket.getInputStream().read(buffer);
process(buffer, count);
When the Socket has no data temporarily, the current virtual thread pauses. The Carrier Thread that carries it can then run other virtual threads. When network data arrives, the original virtual thread gets scheduled again, read() returns, and the code continues executing process().
Several questions naturally arise:
- The Carrier Thread can only execute code linearly. How can it run another task before the current method finishes?
- Where is a virtual thread stored after it pauses?
- When network events arrive, how does the Poller find the corresponding virtual thread?
- What's stored in the scheduler queue—virtual threads, callback functions, or complete call stacks?
- How does network data get back to the original buffer?
- What do Continuation.yield(), Thread.yield(), and LockSupport.park() each handle?
This article uses JDK 21's implementation as the baseline, tracing through a Socket read operation to answer these questions. JDK 21's virtual threads use M:N scheduling, where many virtual threads are scheduled onto a small number of platform threads; the default scheduler is a standalone ForkJoinPool. (OpenJDK)
Carrier Thread Has No Task Switching Instructions
First, let's remove virtual threads and look at a regular worker thread:
final class Worker extends Thread {
private final BlockingQueue<Runnable> queue;
Worker(BlockingQueue<Runnable> queue) {
this.queue = queue;
}
@Override
public void run() {
while (!isInterrupted()) {
try {
Runnable task = queue.take();
task.run();
} catch (InterruptedException e) {
interrupt();
}
}
}
}
This thread always executes linearly. It takes a Runnable from the queue, calls run(), waits for that method to return, then enters the next loop iteration.
Runnable taskA = queue.take();
taskA.run();
Runnable taskB = queue.take();
taskB.run();
A Carrier Thread works the same way. It doesn't suddenly jump from taskA.run() to taskB.run() while taskA.run() hasn't returned.
Virtual threads can switch because taskA.run() can temporarily return before the business call completes. Before returning, the JVM saves the virtual thread's call stack. When this task is executed again, the JVM restores the original call stack and the code continues.
Therefore, what actually happens on a Carrier Thread is:
virtualThreadAResumeTask.run(); // temporarily returns after saving call stack
virtualThreadBResumeTask.run(); // Carrier returns to scheduling loop and executes
The Carrier Thread still follows normal method invocation rules. The special capability lies in Continuation, which allows a yet-to-be-completed call stack to pause and resume.
What's Stored in the Scheduler Queue
JDK 21's VirtualThread has several key fields. Omitting diagnostics, thread containers, and interrupt handling, the structure can be summarized as:
final class VirtualThread extends Thread {
private final Executor scheduler;
private final Continuation cont;
private final Runnable runContinuation;
private volatile int state;
private volatile boolean parkPermit;
private volatile Thread carrierThread;
}
Where:
- scheduler points to the virtual thread scheduler.
- cont stores the pausable and resumable execution state.
- runContinuation is the task submitted to the scheduler.
- state records the virtual thread's current state: running, parked, or schedulable.
- parkPermit stores one unpark permit.
- carrierThread points to the platform thread currently carrying this virtual thread.
When constructing a virtual thread, a Continuation is created, and a method reference bound to the current virtual thread is generated:
this.cont = new VThreadContinuation(this, task);
this.runContinuation = this::runContinuation;
The method reference can be expanded into a regular Runnable:
final class ResumeTask implements Runnable {
private final VirtualThread virtualThread;
ResumeTask(VirtualThread virtualThread) {
this.virtualThread = virtualThread;
}
@Override
public void run() {
virtualThread.runContinuation();
}
}
The core content stored in the scheduler queue is such a resume task. It holds a VirtualThread reference, which in turn holds the Continuation.
ForkJoinPool.WorkQueue
└── ForkJoinTask
└── runContinuation
└── VirtualThread
└── Continuation
VirtualThread doesn't directly operate on a specific work queue in ForkJoinPool. It only calls:
scheduler.execute(runContinuation);
When the default scheduler receives a regular Runnable, it wraps it into a ForkJoinTask and puts it into an internal queue. OpenJDK's comments explain that when submitting to a scheduler Worker, the task enters the local queue; when other threads submit, the task enters the external submission queue. (GitHub)
Virtual Thread's First Run
When starting a virtual thread, the internal state is changed from NEW to STARTED, then runContinuation is submitted:
void start() {
if (!compareAndSetState(NEW, STARTED)) {
throw new IllegalThreadStateException();
}
submitRunContinuation();
}
private void submitRunContinuation() {
scheduler.execute(runContinuation);
}
Some Carrier Thread takes the task from the ForkJoinPool queue and eventually calls:
virtualThread.runContinuation();
The main structure of runContinuation() is as follows:
private void runContinuation() {
int initialState = state();
if (initialState != STARTED
&& initialState != UNPARKED
&& initialState != YIELDED) {
return;
}
if (!compareAndSetState(initialState, RUNNING)) {
return;
}
mount();
try {
cont.run();
} finally {
unmount();
if (cont.isDone()) {
afterDone();
} else {
afterYield();
}
}
}
mount() records the current platform thread as the Carrier and modifies the current thread identity in the JVM:
private void mount() {
Thread carrier = Thread.currentCarrierThread();
setCarrierThread(carrier);
carrier.setCurrentThread(this);
}
Therefore, in business code running in a virtual thread, calling:
Thread.currentThread()
returns the VirtualThread object, not the underlying Carrier Thread.
Then it executes:
cont.run();
On the first call, the Continuation starts executing from the user task entry. OpenJDK's runContinuation() indeed calls cont.run() after mount() and completes unmount() and subsequent state handling in finally. (GitHub)
How Continuation.yield() Releases the Carrier
Assume the business code's call hierarchy is:
void handleRequest(Socket socket) throws IOException {
User user = loadUser();
byte[] buffer = new byte[1024];
int count = socket.getInputStream().read(buffer);
process(user, buffer, count);
}
When the virtual thread reaches the network waiting point, the logical call stack on the Carrier Thread might be:
ForkJoinPool.runWorker()
VirtualThread.runContinuation()
Continuation.run()
handleRequest()
InputStream.read()
NioSocketImpl.implRead()
Poller.poll()
LockSupport.park()
VirtualThread.park()
Continuation.yield()
After executing Continuation.yield(), the JVM freezes the stack frames belonging to the current Continuation. Local variables, object references, call relationships, and the program execution position are all preserved.
For example, the stack frame corresponding to handleRequest() might contain:
socket -> Socket@100
user -> User@200
buffer -> byte[]@300
count -> not yet assigned
This data belongs to the virtual thread's execution state and no longer depends on the current Carrier Thread's platform stack.
After successful freezing, the control flow leaves the Continuation and returns to where cont.run() was called. At this point, the deep call stack belonging to the virtual thread has been removed from the Carrier Thread, and only scheduler-related stack frames remain on the Carrier Thread:
ForkJoinPool.runWorker()
VirtualThread.runContinuation()
cont.run() then returns, and runContinuation() enters finally to execute unmount():
private void unmount() {
Thread carrier = this.carrierThread;
carrier.setCurrentThread(carrier);
setCarrierThread(null);
}
After runContinuation() returns, the Carrier Thread returns to the ForkJoinPool's work loop and fetches other tasks from the queue.
When some Carrier Thread later calls cont.run() again, the JVM restores the previously frozen stack frame, and the original Continuation.yield() starts returning, with the call chain continuing in sequence:
Continuation.yield() returns
VirtualThread.park() returns
LockSupport.park() returns
Poller.poll() returns
NioSocketImpl.implRead() continues
Here handleRequest() is not called again, nor does execution restart from the method's beginning. The resumption point is where it previously paused.
Why park() Doesn't Immediately Re-queue
Continuation.yield() only handles pausing execution; it doesn't decide when the virtual thread continues. The scheduling strategy depends on who called it.
When a virtual thread calls Thread.yield(), the current thread still has running conditions but temporarily yields the Carrier. After the execution stack is frozen, afterYield() immediately resubmits runContinuation:
RUNNING
-> YIELDING
-> YIELDED
-> resubmit
-> RUNNING
LockSupport.park() expresses that the waiting condition hasn't been met yet—for example, waiting for network data, waiting for a lock to be released, or waiting for queue elements. After the execution stack is frozen, the virtual thread cannot immediately re-enter the running queue, otherwise it would keep resuming, checking conditions, and parking again, causing CPU spin.
Its state transition is:
RUNNING
-> PARKING
-> PARKED
Only after another thread calls unpark() does it regain the ability to run:
PARKED
-> UNPARKED
-> resubmit
-> RUNNING
OpenJDK's afterYield() checks the current state. PARKING transitions to PARKED and doesn't resubmit by default; YIELDING transitions to YIELDED and immediately resubmits. (GitHub)
The corresponding code can be simplified to:
private void afterYield() {
int s = state();
if (s == PARKING) {
setState(PARKED);
if (parkPermit
&& compareAndSetState(PARKED, UNPARKED)) {
submitRunContinuation();
}
return;
}
if (s == YIELDING) {
setState(YIELDED);
submitRunContinuation();
}
}
How Socket Read Enters Poller
Now let's enter the network I/O path.
int count = socket.getInputStream().read(buffer);
Taking JDK 21's implementation on Linux as an example, JDK first attempts to read from the Socket. When the kernel receive buffer already has data, the read completes directly and there's no need to park the virtual thread.
If there's no data currently, the non-blocking read returns EAGAIN or an equivalent status. JDK then registers the file descriptor with the Poller and parks the current virtual thread. JEP 444 describes this handling: when a blocking network operation in JDK cannot complete immediately, the virtual thread unmounts; after the I/O can complete, the virtual thread is submitted back to the scheduler. (OpenJDK)
The simplified read logic is as follows:
int read(int fd, byte[] buffer) throws IOException {
while (true) {
int result = nonBlockingRead(fd, buffer);
if (result >= 0) {
return result;
}
Poller.poll(fd);
}
}
Inside Poller.poll(fd), it can be abstracted as:
void poll(int fd) {
Thread thread = Thread.currentThread();
waiters.put(fd, thread);
registerWithEpoll(fd);
LockSupport.park();
}
Since the current execution is a virtual thread, Thread.currentThread() returns the corresponding VirtualThread.
At this point, two associations exist.
Linux kernel's epoll records:
Listening for readable events on fd 37
JDK's Poller records:
fd 37 -> VirtualThread@500
The operating system only knows file descriptors, not Java virtual threads. The mapping in the Poller re-associates kernel events back to Java thread objects. JDK 21's Poller is the intermediate layer between network event notification and virtual thread wakeup. (GitHub)
After registration completes, the current virtual thread executes:
LockSupport.park();
park() enters VirtualThread.park(), sets the PARKING state, and calls Continuation.yield(). After the execution stack is frozen, the virtual thread becomes PARKED and the Carrier Thread is released.
The object relationships during waiting are:
Linux epoll
└── fd 37
Poller
└── fd 37 -> VirtualThread@500
VirtualThread@500
├── state = PARKED
├── carrierThread = null
└── Continuation
└── Saved read() and its upper call stack
ForkJoinPool
└── No resume task for this virtual thread currently
The virtual thread is not occupying a Carrier Thread at this point, nor is it in the run queue. The Poller saves its waiting relationship, and the Continuation saves its execution state.
How Rescheduling Happens After Network Readiness
When network data arrives, the Linux protocol stack places bytes into the Socket's kernel receive buffer and marks the fd as readable.
The Poller thread on Linux is typically blocked on epoll_wait(). After events return, it gets the ready file descriptors:
int[] readyFds = epollWait();
for (int fd : readyFds) {
Thread thread = waiters.remove(fd);
if (thread != null) {
LockSupport.unpark(thread);
}
}
The Poller doesn't execute the user's handleRequest(), nor does it call the original InputStream.read() in the Poller Thread. It only finds the virtual thread waiting for that fd and calls unpark().
The key logic of VirtualThread.unpark() is as follows:
void unpark() {
Thread currentThread = Thread.currentThread();
if (!getAndSetParkPermit(true)
&& currentThread != this) {
int s = state();
if (s == PARKED
&& compareAndSetState(PARKED, UNPARKED)) {
submitRunContinuation();
}
}
}
submitRunContinuation() ultimately executes:
scheduler.execute(runContinuation);
This step hands the virtual thread back to the scheduler. OpenJDK's unpark() first sets parkPermit, then uses state CAS to transition a parked virtual thread to UNPARKED, then submits its resume task. (GitHub)
Since the thread calling unpark() is typically the Poller Thread, which doesn't belong to the default scheduler's Workers, this task generally goes through ForkJoinPool's external submission path.
Some Carrier Thread later takes this task and calls again:
virtualThread.runContinuation();
The state changes from UNPARKED to RUNNING, the virtual thread mounts to the new Carrier Thread, then it executes:
cont.run();
The previously frozen call stack is restored, LockSupport.park() returns, and the Socket read logic continues.
Network Data Doesn't Enter the Scheduler Queue
The scheduler queue contains no network data, nor any object like:
new ResumeTask(virtualThread, networkData);
The Poller's responsibility is to notify that "the fd may now be readable," and the scheduler is responsible for arranging the virtual thread to continue execution. The network bytes are still stored in the Socket's kernel receive buffer.
After the virtual thread resumes, the read loop calls the operating system again:
int read(int fd, byte[] buffer) throws IOException {
while (true) {
int result = nonBlockingRead(fd, buffer);
if (result >= 0) {
return result;
}
Poller.poll(fd);
// After park returns, enter the loop again
}
}
The first nonBlockingRead() returns EAGAIN, and the virtual thread enters waiting.
After the network becomes ready, Poller calls unpark(), the virtual thread resumes, then it executes again:
nonBlockingRead(fd, buffer);
This time the kernel receive buffer already has data, the system call copies bytes into the original buffer and returns the byte count.
The reason the original buffer can still be accessed is because the Continuation's saved call stack still holds:
buffer -> byte[]@300
The data transfer path is:
Network card
-> Linux network protocol stack
-> Socket kernel receive buffer
-> Virtual thread is awakened
-> Restore read() call stack
-> Execute read() again
-> Data copied to original buffer
The scheduler queue only passes execution eligibility. The Poller only passes readiness notification. Network data is saved by the kernel Socket buffer.
parkPermit Solves Lost Wakeups
When a virtual thread is about to park, network events may arrive early.
Consider this timing:
Virtual thread discovers there's no current data
Poller registers fd
Network data arrives immediately
Poller calls unpark()
Virtual thread then executes park()
If unpark() can only wake threads already in the PARKED state, this notification would be lost. The virtual thread would then enter park() and might never resume.
parkPermit is essentially a permit with capacity 1:
private volatile boolean parkPermit;
unpark() first sets:
parkPermit = true;
park() starts by trying to consume the permit:
if (getAndSetParkPermit(false)) {
return;
}
If the network event has already arrived, park() returns directly without freezing the call stack.
Calling unpark() multiple times only saves one permit:
unpark(thread);
unpark(thread);
unpark(thread);
It still ends up as:
parkPermit = true
The next park() consumes this permit and it reverts to false.
This is also why JUC synchronizers typically use loop checking conditions:
while (!conditionSatisfied()) {
LockSupport.park();
}
park() returning only indicates the thread regained the opportunity to execute; the business condition still needs to be rechecked.
PARKING Solves Rescheduling Duplicates
RUNNING and PARKED states are not enough because freezing the call stack requires a period of execution.
The virtual thread first executes:
state = PARKING;
Continuation.yield();
After setting PARKING but before the Continuation completes freezing, Poller might have already called unpark().
At this point, the original Carrier Thread may still be executing the current virtual thread, so the resume task cannot be immediately submitted to the scheduler. Otherwise, another Carrier Thread might simultaneously take out that virtual thread.
Therefore, in the PARKING state, unpark() only sets parkPermit and doesn't submit the task yet.
After the Continuation freezes, the original Carrier executes afterYield():
state = PARKED;
if (parkPermit
&& compareAndSetState(PARKED, UNPARKED)) {
submitRunContinuation();
}
This preserves the early-arriving wake notification while avoiding two Carrier Threads running the same virtual thread simultaneously.
Additionally, runContinuation() performs a CAS on the state before resuming. Even if two resume tasks for the same virtual thread accidentally appear in the scheduler queue, only one Carrier Thread can successfully change the state to RUNNING.
How Three Threads Collaborate
Although the full implementation involves ForkJoinPool, Continuation, Poller, and the operating system, the execution process can still be broken down into three ordinary thread loops.
Carrier Thread's loop:
while (true) {
Runnable task = scheduler.takeTask();
task.run();
}
Poller Thread's loop:
while (true) {
int[] readyFds = epollWait();
for (int fd : readyFds) {
VirtualThread thread = waiters.remove(fd);
LockSupport.unpark(thread);
}
}
Socket read loop in virtual thread:
while (true) {
int count = tryRead(fd, buffer);
if (count >= 0) {
return count;
}
registerPoller(fd, Thread.currentThread());
LockSupport.park();
}
All three execution flows remain linear. They establish connections through:
ForkJoinPool WorkQueue
Saves runnable runContinuation
VirtualThread
Holds scheduler, Continuation, and thread state
Poller
Saves fd to VirtualThread waiting relationship
Socket kernel receive buffer
Saves network data
The Carrier Thread calls resume tasks, the Poller finds waiting threads by fd, the virtual thread resubmits itself through the scheduler, and the Continuation saves the pause position and local object references.
Complete Call Path
Connecting the entire process, we get the following key path:
Thread.startVirtualThread
-> VirtualThread.start
-> submitRunContinuation
-> ForkJoinPool work queue
-> Carrier takes task
-> VirtualThread.runContinuation
-> mount
-> Continuation.run
-> User code
-> Socket.read
-> Non-blocking read returns EAGAIN
-> Poller registers fd
-> LockSupport.park
-> VirtualThread.park
-> Continuation.yield
-> Call stack frozen
-> cont.run temporarily returns
-> unmount
-> VirtualThread enters PARKED
-> Carrier returns to scheduling loop
Network data arrives
-> Socket kernel receive buffer
-> epoll_wait returns fd
-> Poller finds VirtualThread
-> LockSupport.unpark
-> VirtualThread.unpark
-> submitRunContinuation
-> ForkJoinPool work queue
-> Carrier takes resume task
-> VirtualThread.runContinuation
-> mount
-> Continuation.run
-> Restore frozen call stack
-> LockSupport.park returns
-> Execute read again
-> Data copied to original buffer
-> User code continues
Understanding this path, the scheduling of virtual threads is no longer mysterious. The Carrier Thread always executes a normal task loop; Continuation allows the current task to temporarily return while preserving the call stack; the Poller associates network fds with waiting virtual threads; and unpark() resubmits the resume task after events arrive.
JDK 21 vs JDK 24 Pinning Differences
In JDK 21, if a virtual thread enters certain blocking operations while holding a synchronized Monitor, the Continuation might not be able to unmount, and the virtual thread and Carrier Thread would block together—this situation is called Pinning.
Therefore, during the JDK 21 era, it was often recommended to avoid executing network I/O in long synchronized critical sections, using ReentrantLock when necessary.
JDK 24 delivered JEP 491, which modified how JVM Monitors collaborate with virtual threads. When a virtual thread blocks in a synchronized method or block, it can now unmount and release the Carrier Thread in the vast majority of cases. After JDK 24, choosing between synchronized and java.util.concurrent.locks should be based more on semantics like interruptible acquisition, fairness, timeouts, and condition variables, rather than simply avoiding Monitor Pinning. (OpenJDK)
Executing long-running network I/O while holding a lock still requires caution. Even if the Carrier can be released, the lock itself remains held, and other threads needing to enter the same critical section will still wait.
Reference Source Code
- JEP 444: Virtual Threads. (OpenJDK)
- JDK 21 java.lang.VirtualThread. (GitHub)
- JDK 21 jdk.internal.vm.Continuation. (GitHub)
- JDK 21 sun.nio.ch.Poller. (GitHub)
- JEP 491: Synchronize Virtual Threads without Pinning. (OpenJDK)