When a virtual thread executes the following code, it looks no different from an ordinary thread:
int count = socket.getInputStream().read(buffer);
process(buffer, count);
If the socket has no data at the moment, the current virtual thread pauses. The carrier thread hosting it can then run other virtual threads. Once network data arrives, the original virtual thread is scheduled again, read() returns, and execution proceeds to process().
This raises several questions:
- A carrier thread can only execute code linearly. If the current method has not returned, how can it run another task?
- Where is a suspended virtual thread stored?
- When a network event arrives, how does the Poller locate the corresponding virtual thread?
- Does the scheduler queue hold the virtual thread, a callback, or the complete call stack?
- How does the network data make its way back to the original buffer?
- What are the respective roles of Continuation.yield(), Thread.yield(), and LockSupport.park()?
This article uses the JDK 21 implementation as its baseline and follows the call path of a Socket read to answer these questions. Virtual threads in JDK 21 use M:N scheduling: the JDK schedules a large number of virtual threads onto a small number of platform threads. The default scheduler is a dedicated ForkJoinPool. (OpenJDK)
Carrier Threads Have No Task-Switching Instruction
Set the virtual thread aside for a moment and consider an ordinary worker thread:
This thread always executes linearly. It takes a Runnable from the queue, calls run(), waits for that method to return, and then enters the next iteration.
Runnable taskA = queue.take();
taskA.run();
Runnable taskB = queue.take();
taskB.run();
Carrier threads work the same way. They do not suddenly jump to taskB.run() while taskA.run() has yet to return.
Virtual threads can switch because taskA.run() may return temporarily before the application call has completed. Before returning, the JVM saves the virtual thread's call stack. When the task is executed again, the JVM restores the original call stack and execution continues.
Thus, what actually happens on a carrier thread is:
virtualThreadAResumeTask.run(); // 保存调用栈后暂时返回
virtualThreadBResumeTask.run(); // Carrier 回到调度循环后执行
Carrier threads still follow normal method-invocation rules. The special capability resides in Continuation, which allows an unfinished call stack to suspend and resume.
What the Scheduler Queue Stores
VirtualThread in JDK 21 has several key fields. Omitting diagnostics, the thread container, and interruption handling, they can be reduced to the following structure:
Here:
- scheduler points to the virtual thread scheduler.
- cont stores execution state that can be suspended and resumed.
- runContinuation is the task submitted to the scheduler.
- state records whether the virtual thread is running, parked, or ready to run.
- parkPermit holds an unpark permit.
- carrierThread points to the platform thread currently carrying the virtual thread.
When a virtual thread is constructed, 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 an ordinary Runnable:
The core item stored in the scheduler queue is a resume task like this one. It holds a VirtualThread reference, and the VirtualThread in turn holds the Continuation.
ForkJoinPool.WorkQueue
└── ForkJoinTask
└── runContinuation
└── VirtualThread
└── Continuation
VirtualThread does not directly manipulate a particular work queue inside ForkJoinPool. It only calls:
scheduler.execute(runContinuation);
After the default scheduler receives an ordinary Runnable, it wraps it in a ForkJoinTask and places it in an internal queue. OpenJDK's comments explain that a task submitted on a scheduler Worker goes into the local queue, while a task submitted by another thread goes into the external submission queue. (GitHub)
The Virtual Thread's First Run
When a virtual thread is started, its state is changed internally from NEW to STARTED, after which runContinuation is submitted:
A carrier thread eventually removes the task from the ForkJoinPool queue and calls:
virtualThread.runContinuation();
The main structure of runContinuation() is as follows:
mount() records the current platform thread as the carrier and changes the current-thread identity in the JVM:
private void mount() {
Thread carrier = Thread.currentCarrierThread();
setCarrierThread(carrier);
carrier.setCurrentThread(this);
}
Consequently, application code in the virtual thread that calls:
Thread.currentThread()
receives the VirtualThread object, not the underlying carrier thread.
It then executes:
cont.run();
On the first invocation, the Continuation begins execution at the user task's entry point. OpenJDK's runContinuation() does indeed call cont.run() after mount(), and performs unmount() and the subsequent state handling in a finally block. (GitHub)
How Continuation.yield() Releases the Carrier
Suppose the application code has the following call relationship:
When the virtual thread reaches the network wait, the logical call stack on the carrier thread may look like this:
After Continuation.yield() executes, the JVM freezes the stack frames belonging to the current Continuation. Local variables, object references, call relationships, and the program's execution point are all preserved.
For example, the stack frame corresponding to handleRequest() may contain:
socket -> Socket@100
user -> User@200
buffer -> byte[]@300
count -> 尚未赋值
This data belongs to the virtual thread's execution state and no longer depends on the current carrier thread's platform stack.
After freezing succeeds, control flow leaves the Continuation and returns to the location that called cont.run(). At this point, the deep call stack belonging to the virtual thread has been detached from the carrier thread, leaving only scheduler-related stack frames:
ForkJoinPool.runWorker()
VirtualThread.runContinuation()
cont.run() then returns, runContinuation() enters its finally block, and unmount() executes:
private void unmount() {
Thread carrier = this.carrierThread;
carrier.setCurrentThread(carrier);
setCarrierThread(null);
}
After runContinuation() returns, the carrier thread returns to the ForkJoinPool work loop and takes other tasks from the queue.
When a carrier thread later calls cont.run() again, the JVM restores the previously frozen stack frames, the original Continuation.yield() begins to return, and the call chain continues in sequence:
Continuation.yield() 返回
VirtualThread.park() 返回
LockSupport.park() 返回
Poller.poll() 返回
NioSocketImpl.implRead() 继续
handleRequest() is not called again, and the method does not restart from its beginning. The resume point is exactly where execution previously paused.
Why park() Doesn't Immediately Requeue the Virtual Thread
Continuation.yield() is responsible only for suspending execution; it does not decide when the virtual thread should continue. The scheduling behavior depends on which operation invoked it.
When a virtual thread calls Thread.yield(), the current thread is still eligible to run; it merely yields the carrier temporarily. After the execution stack is frozen, afterYield() immediately resubmits runContinuation:
RUNNING
-> YIELDING
-> YIELDED
-> 重新提交
-> RUNNING
LockSupport.park() expresses that the awaited condition has not yet been satisfied, such as waiting for network data, a lock to be released, or an element to become available in a queue. After the execution stack is frozen, the virtual thread must not immediately reenter the run queue. Otherwise, it would repeatedly resume, check the condition, and park again, wasting CPU cycles.
Its state changes as follows:
RUNNING
-> PARKING
-> PARKED
Only after another thread calls unpark() does it become eligible to run again:
PARKED
-> UNPARKED
-> 重新提交
-> RUNNING
OpenJDK's afterYield() checks the current state. PARKING changes to PARKED and is not resubmitted by default, while YIELDING changes to YIELDED and is submitted immediately. (GitHub)
The corresponding code can be simplified to:
How a Socket Read Enters the Poller
Now let's follow the network I/O path.
int count = socket.getInputStream().read(buffer);
In the JDK 21 implementation on Linux, the JDK first attempts to read from the Socket. If the kernel receive buffer already contains data, the read completes immediately without suspending the virtual thread.
If there is no data, the nonblocking read returns EAGAIN or an equivalent status. The JDK then registers the file descriptor with the Poller and parks the current virtual thread. JEP 444 describes this behavior: when a blocking network operation in the JDK cannot complete immediately, the virtual thread is unmounted; once the I/O can complete, the virtual thread is submitted back to the scheduler. (OpenJDK)
The simplified read logic is as follows:
The internals of Poller.poll(fd) can be abstracted as follows:
void poll(int fd) {
Thread thread = Thread.currentThread();
waiters.put(fd, thread);
registerWithEpoll(fd);
LockSupport.park();
}
Because the current execution is a virtual thread, Thread.currentThread() returns the corresponding VirtualThread.
At this point, there are two associations.
The Linux kernel's epoll records:
监听 fd 37 的可读事件
The JDK's Poller records:
fd 37 -> VirtualThread@500
The operating system knows only file descriptors, not Java virtual threads. The mapping in the Poller reconnects kernel events to Java thread objects. In JDK 21, the Poller acts as the intermediary between network event notifications and virtual thread wake-ups. (GitHub)
After registration, 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 the wait are as follows:
At this point, the virtual thread occupies no carrier thread and is not in the run queue. The Poller holds its waiting relationship, while the Continuation holds its execution state.
Rescheduling After the Network Becomes Ready
When network data arrives, the Linux protocol stack places the bytes in the Socket's kernel receive buffer and marks the fd as readable.
The Poller thread on Linux normally blocks in epoll_wait(). Once an event returns, it obtains the ready file descriptors:
int[] readyFds = epollWait();
for (int fd : readyFds) {
Thread thread = waiters.remove(fd);
if (thread != null) {
LockSupport.unpark(thread);
}
}
The Poller does not 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 the fd and calls unpark().
The key logic of VirtualThread.unpark() is as follows:
submitRunContinuation() ultimately executes:
scheduler.execute(runContinuation);
This step hands the virtual thread back to the scheduler. OpenJDK's unpark() first sets parkPermit, then uses a state CAS to change the parked virtual thread to UNPARKED before submitting its resume task. (GitHub)
Because the caller of unpark() is usually the Poller thread, which is not a Worker of the default scheduler, this task generally takes ForkJoinPool's external submission path.
A carrier thread later removes the task and calls the following again:
virtualThread.runContinuation();
The state changes from UNPARKED to RUNNING, the virtual thread is mounted on a new carrier thread, and then it executes:
cont.run();
The previously frozen call stack is restored, LockSupport.park() returns, and the Socket read logic continues.
Network Data Never Enters the Scheduler Queue
The scheduler queue contains no network data, nor does it contain an object such as:
new ResumeTask(virtualThread, networkData);
The Poller's responsibility is to signal that “the fd may now be readable,” while the scheduler arranges for the virtual thread to continue. The network bytes remain in the Socket's kernel receive buffer.
After the virtual thread resumes, the read loop calls the operating system again:
The first nonBlockingRead() returns EAGAIN, and the virtual thread enters the wait.
After the network becomes ready, the Poller calls unpark(), the virtual thread resumes, and it executes the following again:
nonBlockingRead(fd, buffer);
This time, the kernel receive buffer contains data. The system call copies the bytes into the original buffer and returns the number of bytes.
The original buffer remains accessible because the call stack saved by the Continuation still holds:
buffer -> byte[]@300
The data path is:
The scheduler queue conveys only eligibility to run. The Poller conveys only a readiness notification. The network data is held in the kernel's Socket buffer.
parkPermit Prevents Lost Wake-Ups
A network event may arrive early while a virtual thread is preparing to park.
Consider the following sequence:
虚拟线程发现当前没有数据
Poller 注册 fd
网络数据立即到达
Poller 调用 unpark()
虚拟线程随后才执行 park()
If unpark() could only wake a thread that was already in the PARKED state, this notification would be lost. The virtual thread would then enter park() and might never resume.
parkPermit acts like a permit with a capacity of one:
private volatile boolean parkPermit;
unpark() first sets:
parkPermit = true;
At the start of park(), it attempts to consume the permit:
if (getAndSetParkPermit(false)) {
return;
}
If the network event has already arrived, park() returns immediately without freezing the call stack.
Multiple consecutive calls to unpark() still preserve only one permit:
unpark(thread);
unpark(thread);
unpark(thread);
The result is still simply:
parkPermit = true
The next park() consumes the permit and resets it to false.
This is also why JUC synchronizers generally check their conditions in a loop:
while (!conditionSatisfied()) {
LockSupport.park();
}
A return from park() means only that the thread has another opportunity to run; the application condition must still be checked again.
PARKING Prevents Duplicate Scheduling
The RUNNING and PARKED states alone are insufficient because freezing the call stack takes a sequence of operations.
The virtual thread first executes:
state = PARKING;
Continuation.yield();
After PARKING is set but before the Continuation finishes freezing, the Poller may already have called unpark().
At this point, the original carrier thread may still be executing the current virtual thread, so the resume task cannot be submitted to the scheduler immediately. Otherwise, another carrier thread might pick up the same virtual thread at the same time.
Therefore, in the PARKING state, unpark() only sets parkPermit and does not submit a task yet.
After the Continuation finishes freezing, the original carrier executes afterYield():
state = PARKED;
if (parkPermit
&& compareAndSetState(PARKED, UNPARKED)) {
submitRunContinuation();
}
This preserves a wake-up notification that arrived early while preventing two carrier threads from running the same virtual thread simultaneously.
In addition, runContinuation() performs a state CAS before resuming. Even if the scheduler queue unexpectedly contains two resume tasks pointing to the same virtual thread, only one carrier thread can successfully change the state to RUNNING.
How the Three Threads Coordinate
Although the complete implementation involves ForkJoinPool, Continuation, the Poller, and the operating system, its execution can still be broken down into three ordinary thread loops.
The carrier thread's loop is:
while (true) {
Runnable task = scheduler.takeTask();
task.run();
}
The Poller thread's loop is:
The Socket read loop in a virtual thread is:
All three execution flows remain linear. They establish their connections through the following objects:
The carrier thread invokes the resume task, the Poller finds the waiting thread by fd, the virtual thread resubmits itself through the scheduler, and the Continuation preserves the suspension point and references to local objects.
The Complete Call Path
Connecting the entire process yields the following key path:
Once this path is understood, virtual thread scheduling is no longer mysterious. The carrier thread always runs an ordinary task loop; the Continuation allows the current task to return temporarily while preserving its call stack; the Poller associates a network fd with the waiting virtual thread; and unpark() resubmits the resume task after an event arrives.
Pinning Differences Between JDK 21 and JDK 24
In JDK 21, if a virtual thread enters certain blocking operations while holding a synchronized monitor, the Continuation may be unable to unmount, causing the virtual thread and carrier thread to block together. This is called pinning.
Consequently, during the JDK 21 era, it was often recommended to avoid network I/O in long-running synchronized critical sections and to use ReentrantLock where necessary.
JDK 24 shipped JEP 491, which changed how JVM monitors and virtual threads cooperate. When a virtual thread blocks in a synchronized method or block, it can, in the vast majority of cases, unmount and release the carrier thread. After JDK 24, the choice between synchronized and java.util.concurrent.locks should depend more on semantics such as interruptible acquisition, fairness, timeouts, and condition variables, rather than solely on avoiding monitor pinning. (OpenJDK)
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 that need to enter the same critical section must 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)