虚拟线程是JDK 19引入并在JDK 21正式转正的轻量级并发方案。它最吸引人的特性之一就是:当代码执行到阻塞IO时,虚拟线程会自动挂起自己,把承载它的carrier thread让出来去运行其他虚拟线程。这意味着你可以放心地写出阻塞式代码,却获得接近异步编程的吞吐量。要理解这个魔法是怎么发生的,需要深入到Continuation、调度器和JDK类库改造这三个层面。

一、挂起的基石:Continuation的栈冻结与恢复
虚拟线程之所以能够挂起后再恢复,核心依赖是HotSpot内部实现的Continuation机制。每个虚拟线程在Java堆上保存着自己的执行栈(默认大小由虚拟线程自身决定,不再受平台线程栈的限制),当虚拟线程被挂起时,JVM会执行一次「栈冻结」操作:把当前线程栈上的所有栈帧从线程本地栈拷贝到堆上的Continuation对象中,随后carrier thread的栈被清空,可以继续执行其他任务。
当这个虚拟线程被重新调度执行时,JVM执行反向操作,把堆中的栈帧「解冻」并拷回carrier thread的栈上,从中断的那条指令处继续执行。整个冻结和解冻过程对应用代码完全透明,你在调用socket.read()时根本感知不到栈被搬来搬去。这种机制本质上实现了「有栈协程」,与Kotlin协程的无栈方案(通过状态机改造方法体)形成了鲜明对比。
栈拷贝是有成本的。如果虚拟线程的调用栈很深(比如经过多层框架封装后调用链达到上百个栈帧),每次挂起和恢复都需要拷贝更多数据。这也是官方建议虚拟线程配合短小任务、避免过深调用链的原因之一。可以通过JFR事件jdk.VirtualThreadPinned和jdk.VirtualThreadSubmitFailed来观测挂起行为是否异常频繁。
二、阻塞点的改造:从java.net到java.nio的全面接管
Continuation只提供了「能挂起」的能力,而「什么时候该挂起」则依赖JDK类库的全面改造。在早期Loom原型中,开发团队改造了大量会发生阻塞的JDK API,包括java.net.Socket、java.net.ServerSocket、java.io.FileInputStream等传统的阻塞式IO类。当虚拟线程调用这些API进入阻塞时,类库不再让底层的carrier thread傻等,而是把IO操作注册到内部的轮询器(Poller)上,然后调用VirtualThread.parkNanos()主动挂起。
以一次HTTP调用为例,虚拟线程在发出请求后会经历这样的链路:JDK内部把连接的读写事件注册到一个基于epoll或kqueue实现的Poller线程上,随后虚拟线程挂起,carrier thread立即回到ForkJoinPool调度器去领取下一个可运行的虚拟线程。当远端数据到达、Poller检测到fd就绪时,它会调用unpark()把对应的虚拟线程重新加入调度队列,等待被某个carrier thread捡起来继续执行。整个过程没有任何线程真正阻塞在IO上。
文件IO则稍有不同,因为操作系统的文件读写通常不支持epoll风格的事件通知。JDK的兜底方案是使用一个补偿性的线程池:当虚拟线程执行文件IO时,操作会被转交到专门的COMPENSATOR线程池中执行,虚拟线程照样挂起,carrier thread照样被释放,只是IO本身还是由真实线程完成的。这解释了为什么大量小文件读写场景下虚拟线程的收益不如网络IO那么显著。
三、park、yield与pinning:三种让出场景的区别
除了IO阻塞,虚拟线程还有几种让出carrier thread的途径,它们的触发条件和底层行为并不相同。Thread.sleep()、BlockingQueue.take()、LockSupport.park()都会触发挂起,本质上是走到Continuation.yield()这个入口。而Thread.yield()或Thread.onSpinWait()则相对轻量,只是提示调度器重新排队。
最需要警惕的是pinning问题。当虚拟线程在执行synchronized修饰的方法或代码块期间发生阻塞,或者在挂起时栈帧中存在本地方法调用,JVM无法完成栈冻结,此时虚拟线程会被「钉」在carrier thread上,真正阻塞的是底层平台线程。这在高并发下会导致carrier thread被大量占用,吞吐量不升反降。JDK 24已经对synchronized的pinning问题做了优化,但如果你使用的是JDK 21,就应该把热点路径上的同步块改造成ReentrantLock。可以通过启动参数-Djdk.tracePinnedThreads=full来定位pinning发生的堆栈位置。
排查示例代码如下:
// 启动参数加 -Djdk.tracePinnedThreads=full 可打印钉住位置
var vt = Thread.ofVirtual().start(() -> {
synchronized (lock) {
try {
// 在synchronized内阻塞,JDK 21会pin住carrier thread
socket.getInputStream().read(buffer);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
});
// 推荐改造方式:使用ReentrantLock替代synchronized
private final ReentrantLock lock = new ReentrantLock();
public void handle(Socket socket) throws IOException {
lock.lock();
try {
socket.getInputStream().read(buffer); // 可正常挂起
} finally {
lock.unlock();
}
}四、调度器如何衔接:ForkJoinPool的两级队列模型
挂起之后的故事由调度器接手。虚拟线程默认使用一个共享的ForkJoinPool(可通过jdk.virtualThreadScheduler.parallelism调整并行度,默认等于CPU核数),它工作在FIFO模式下。每个carrier thread维护一个本地队列,同时还有一个全局的提交队列。被unpark()唤醒的虚拟线程会被放入调度队列,等待任意空闲的carrier thread来执行,虚拟线程与carrier thread之间是多对多的动态绑定关系,不保证恢复后跑在原来那个carrier上。
这种设计带来一个重要推论:任何依赖ThreadLocal传递上下文的代码在虚拟线程上都要格外小心。虽然ThreadLocal在虚拟线程上是可用的,但由于实例数量可能达到百万级,缓存类ThreadLocal会造成严重的内存压力。JDK提供了ScopedValue作为更合适的不可变上下文传递方案,配合结构化并发使用效果更好。
总结一下完整的挂起链路:应用代码触发阻塞点,改造后的JDK API检测到当前是虚拟线程,将IO事件注册到Poller或补偿线程池,随后调用park触发Continuation栈冻结,carrier thread归还调度器执行其他任务;IO就绪后Poller调用unpark,虚拟线程重新入队,被某个carrier thread恢复执行。理解了这条链路,你就能解释虚拟线程的大部分行为特征,也能在出现性能问题时快速定位是pinning、栈拷贝开销还是调度饥饿导致的。
虚拟线程挂起机制carrier thread修改时间:2026-09-05 17:44:54