面试被问虚拟线程和协程到底差在哪:用 JDK 27 的现状回答

「虚拟线程不就是 Java 版的协程吗」——这句话我在面试复盘里见过太多次,每次都想打断。

先把能站住的结论给出来:虚拟线程是 JDK 替你管理的线程,协程是编译器替你改写的函数。前者干的事是让「一个请求一个线程」重新变得便宜,后者干的事是让「一个函数可以在中间让出去」变成语言能力。它们在压测数字上经常被放进同一张图比较,但机制上没有血缘关系,混着答,面试官一追问第三层就会露馅。

我把这道题答过也被问过,下面按「机制 → 版本现状 → 什么时候真有用」的顺序拆开。


一、一句话答案,先背下来

  • 虚拟线程解决的是线程太贵。
  • 协程解决的是代码写不直。
  • 两者都是 M:N,都是协作式让出,都不解决 CPU 不够。
维度 虚拟线程 协程(以 Kotlin 为例)
谁创建 JDK 运行时,Thread.ofVirtual 库函数,launch / async
谁调度 JVM 内部的 ForkJoinPool 载体线程 Dispatchers 背后的线程池
栈放哪里 堆上的栈块,可增长可搬运 编译期生成的状态机对象加续体
阻塞怎么写 照旧写同步阻塞 必须落在挂起点上写 suspend
对上层是否透明 透明,签名不用改 不透明,调用链被着色
能否被抢占 不能,协作式让出 不能,协作式让出

最后一行我特意对齐了:两者都不是抢占式的。很多人以为协程是抢占式、虚拟线程是协作式,其实反过来也一样不成立,都是遇到挂起或阻塞点才换人。


二、调度:都在做 M 比 N,但谁在调度完全不同

虚拟线程的调度在 JVM 里。JEP 444 的表述很清楚:虚拟线程是由 JDK 而不是操作系统提供的轻量线程,运行时把它们挂载到载体线程上,阻塞时卸载,载体空出来给下一个虚拟线程用。这套调度器用的就是 ForkJoinPool 的工作窃取模型,载体线程数默认等于核数,可以用系统属性调。

协程的调度不在语言里,在库里。suspend 只是编译器约定的一个标记:带 suspend 的函数编译后多一个续体参数,挂起时把续体存起来返回,恢复时调 resume 继续跑。至于 resume 之后落到哪个线程,那是 Dispatchers 的事。

我的理解:虚拟线程把 M:N 藏在 JDK 内部,所以业务代码完全看不见;协程把 M:N 露在库的 API 上,所以你必须决定用哪个 Dispatcher。前者省心,后者可控,代价是协程这边一旦 Dispatcher 选错,比如把阻塞调用扔到只有核数个线程的 Dispatchers.Default 上,整条链就堵死了,而虚拟线程没有这个旋钮可拧。


三、栈怎么放:这条最能区分两者

平台线程的栈在本地内存里,先 reserve 一大块,用多少提交多少。线程数一上去,光是栈的预留和内核的调度开销就压住了天花板。

虚拟线程的栈在堆上,是一块可增长的栈块。挂载时把栈帧搬进载体线程的栈,卸载时再搬回来。能搬,意味着调用栈可以很深,也意味着栈本身就是 GC 能看见的对象。

协程没有「搬栈」这个动作。编译器把 suspend 函数切成若干个状态机,局部变量从栈帧变成状态机对象的字段。所以协程的调用栈深度其实是被状态机的形状决定的,中间隔一层普通函数还好,深递归加挂起会让状态机对象一层层叠起来。

这里我不太同意一种流行说法:「协程比虚拟线程更轻,因为协程没有栈」。字节层面没有统一裁判——协程的每个挂起帧都是一个对象,虚拟线程的栈块也是堆上的对象。真正的差别不在谁更轻,而在可以创建多少个,以及挂在阻塞调用上会不会占住载体线程。拿「轻量」当结论,面试官换个问法你就答不上来了。


四、阻塞语义与传染性:取舍都在这里

虚拟线程最大的卖点是它仍然阻塞。你写 socket.read()、Thread.sleep、connection.query,代码还是那个同步阻塞的样子,JDK 在底层把这些阻塞点改造成可卸载。这意味着已有的 Spring MVC 应用、已有的 JDBC 调用,换个 Executor 就能受益,签名一行不改。

协程刚好相反:阻塞必须显式。delay 而不是 Thread.sleep,阻塞 IO 要包进 withContext(Dispatchers.IO)。误用一次阻塞 API,协程不会让出,整个线程池被占住,而且不会有任何报错——这是协程最容易踩的坑。

传染性是同一个取向的两面:协程的 suspend 会顺着调用链一路上色,改一个底层方法,上面十层签名全要动;虚拟线程对上层完全透明,你不需要知道某个方法内部有没有阻塞。

我的判断:传染性不是缺点也不是优点,它是两种设计哲学的必然产物。Java 选了「不破坏二十年的存量代码」,所以必须做到透明,代价是阻塞语义只能靠运行时兜底;Kotlin 选了「让副作用显式化」,代价是函数着色。面试时把这一层说出来,比背「协程更轻」强得多。


五、三层调度模型长什么样

这张图想让你看见什么:不管你写的是平台线程、虚拟线程还是协程,最终都要落到 OS 线程上。三者的差别只在最上面那一段——是谁在把逻辑单元多路复用到更少的 OS 线程上。虚拟线程那一层的多路复用是 JVM 干的,协程那一层是编译期改造加库调度共同完成的,平台线程压根没有这一层。所以面试里说「虚拟线程减少了线程切换」,严格说减少的是用户态挂载卸载,不是内核上下文切换,因为载体线程数没变。


六、挂载、卸载与钉住的状态图

这张图想让你看见什么:只有「已卸载」这一条边是免费的。虚拟线程的价值全部来自运行中到已卸载这一跳,跳到「已钉住」就等于退化成了平台线程,还多付了一份挂载的成本。JDK 21 的时候「运行中 → 已钉住」主要由 synchronized 里阻塞触发;JDK 24 之后这条边基本只剩 native 方法和 FFM 回调。面试官问「虚拟线程为什么会失效」,你指着这条边答就对了。


七、JDK 27 的现状:三件事必须核清楚

第一件,虚拟线程已经不是新东西了。 JEP 444 在 Java 21 正式交付,状态是 Delivered,虚拟线程是标准库的一部分,早就不是预览特性。从 21 到 27,它已经跨过了六个大版本。

第二件,钉住问题在 JDK 24 被 JEP 491 解决了。 JEP 491 的标题就是「Synchronize Virtual Threads without Pinning」,Release 是 24。它让阻塞在 synchronized 方法或语句里的虚拟线程释放载体线程,覆盖了 monitorenter 和 Object.wait,目标是「消除几乎所有虚拟线程被钉在平台线程上的情况」。剩余场景 JEP 491 也写明了:虚拟线程调用 native 方法,或者通过 Foreign Function & Memory API 进入本地代码,而那段本地代码又回调 Java 里的阻塞操作。观测手段保留并增强——JFR 事件 jdk.VirtualThreadPinned 继续存在,还会带上钉住原因和载体线程的标识;而 jdk.tracePinnedThreads 这个系统属性在 24 之后不再需要了。

第三件,JDK 27 不是 LTS。 这条是面试里最容易被抓的硬伤。JDK 27 在 2026-09-15 达到 GA,但它是非 LTS 的特性版本。Oracle 的官方路线图写得很直白:Java SE 8、11、17、21、25 是 LTS 版本,下一个计划中的 LTS 是 2027 年 9 月的 Java 29。所以「JDK 27 是下一个 LTS」这句话是错的,说错了比不说更扣分。

还有一个很多人不知道的点:JDK 27 里 Structured Concurrency 仍然是第七次预览,也就是 JEP 533。虚拟线程早就转正了,但它「最搭的那套结构化并发 API」到 27 还没定型。这个事实特别适合用来回答「虚拟线程现在还有什么没解决」——不是虚拟线程本身没解决,是围绕它的编程模型还在打磨。

我踩过的坑:最早在 21 上做压测,把一堆老 DAO 直接塞进虚拟线程执行器,结果吞吐比固定线程池还差,排查半天才发现 synchronized 包着远程调用,每个虚拟线程都把载体钉死,JFR 里 jdk.VirtualThreadPinned 刷屏。那次之后我的结论很朴素:上虚拟线程之前先跑一遍 JFR,看有没有钉住,比看任何博客都准。


八、两段代码,看清各自阻塞点在哪

Java 这边,虚拟线程的写法几乎不需要改变习惯:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class VirtualThreadDemo {
public static void main(String[] args) {
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
int orderId = i;
pool.submit(() -> {
Thread.sleep(Duration.ofMillis(50)); // 阻塞点:看起来是同步阻塞
System.out.println("done " + orderId);
return orderId;
});
}
} // close 会等待所有任务结束
}
}

这段代码在说明什么:Thread.sleep 是阻塞调用,虚拟线程在这里会被卸载,载体线程立刻去跑下一个任务。整段代码没有任何 suspend、async、回调,业务方法签名一个字没改——这就是「对上层透明」的实际含义。一万个任务只需要几个载体线程就能撑住,因为真正占着线程的时间接近于零。另外注意这里是每个任务新建一个虚拟线程,不是从池里借——JEP 444 原话就是虚拟线程便宜且充足、因此永远不该被池化。

Kotlin 这边,同一个语义必须靠挂起点来表达:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking

suspend fun loadOrder(id: Int): Int = coroutineScope {
delay(50) // 挂起点:不占线程
println("done " + id)
id
}

fun main() = runBlocking {
val results = (1..10_000)
.map { id -> async(Dispatchers.IO) { loadOrder(id) } }
.awaitAll()
println(results.size)
}

这段代码在说明什么:delay 是挂起函数,它让出线程的方式是把续体存进状态机然后返回;如果这里手滑写成 Thread.sleep,协程根本不认识这个阻塞点,线程照占不误,而且编译期不报错。另外 loadOrder 带 suspend 标记,任何调用它的上层函数也必须 suspend,这就是着色传染的现场证据。注意 async 那里显式指定了 Dispatchers.IO,因为默认的 Dispatchers.Default 线程数约等于核数,拿它跑阻塞调用等于自己给自己挖坑。


九、我的判断:什么场景虚拟线程真省钱

场景 值不值 原因
高并发 IO 等待型,网关、爬虫、DB 代理 值 线程数从几千到几十万,内存与用户态切换都省下来
纯 CPU 密集计算 不值 载体线程数等于核数,虚拟线程只是排队,算力没多一毫核
依赖库满是 synchronized 或 native 阻塞 看版本 24 之前会被钉住,24 之后要看 FFM 回调
下游连接池只有几十个连接 不值 瓶颈在池不在线程,虚拟线程只是把排队挪了个位置
重度 ThreadLocal 缓存用户上下文 要小心 百万级虚拟线程意味着百万份副本,内存会先炸

我的判断:虚拟线程真正省钱的场景只有一个特征——等待时间远大于计算时间,并且等待发生在 JDK 认识的阻塞点上。除此之外它更像「看起来美」:CPU 密集场景下它不比平台线程快,连接池打满的时候它只是把 Tomcat 的排队搬到了连接池的排队,本质没变,但监控指标变了,很多人这时候会误判成「虚拟线程没用」。真实情况是瓶颈转移了,你要做的是重新量一次 RT 分布,而不是换回平台线程。

反过来说,协程那边也别迷信。协程解决的是表达问题,它不会让你的下游变快,也不会自动帮你把阻塞调用改成非阻塞。这一点上两者完全平等:它们都在优化「线程太贵」,没有在优化「依赖太慢」。


十、面试里这样答,比背口号容易加分

顺序是边界、机制、版本、反例。

先给边界:「虚拟线程和协程解决的不是同一个问题,前者降低线程成本,后者改变代码表达方式,所以它们不是替代关系」。

再给机制:「虚拟线程由 JVM 调度,栈在堆上可搬运,阻塞对上层透明;协程靠编译器把 suspend 函数改写成状态机,由库的 Dispatcher 调度,阻塞必须显式挂在挂起点上」。

然后给版本:「虚拟线程 21 年由 JEP 444 转正,24 年 JEP 491 解决了 synchronized 钉住,剩下的钉住场景是 native 和 FFM 回调;JDK 27 已经 GA 但不是 LTS,LTS 是 8、11、17、21、25,下一个是 2027 年 9 月的 Java 29」。

最后给反例:「所以不能说虚拟线程是协程的 Java 实现,也不能说它让 CPU 密集任务变快,更不能说 27 是 LTS」。

这四步答完,追问基本就到顶了。


落地清单

  1. 记死三个 JEP 号:444 虚拟线程、491 解除 synchronized 钉住、533 结构化并发第七次预览。
  2. 记死版本定位:LTS 是 8、11、17、21、25,下一个 LTS 是 Java 29(2027-09),JDK 27 在 2026-09-15 GA 但不是 LTS。
  3. 上线前先跑一遍 JFR,盯 jdk.VirtualThreadPinned,有钉住先改锁或升版本,别急着调参。
  4. 判断要不要上虚拟线程,只问两个数:IO 等待占比,以及下游连接池上限。
  5. 协程侧的论据只引官方文档里的 Continuation 与 suspend 语义,不编版本号。
  6. 任何「虚拟线程更快」的结论,都补一句「在什么阻塞模型、什么依赖库版本下」,否则就是口号。

参考来源