面试被问虚拟线程和协程到底差在哪:用 JDK 27 的现状回答
面试被问虚拟线程和协程到底差在哪:用 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 选了「让副作用显式化」,代价是函数着色。面试时把这一层说出来,比背「协程更轻」强得多。
五、三层调度模型长什么样
flowchart LR
A[Java 业务代码] --> B[平台线程池\n一请求一线程]
A --> C[虚拟线程\n由 JDK 提供]
B --> E[OS 线程\n内核调度]
C --> F[载体线程 ForkJoinPool\nJVM 调度]
F --> E
K[Kotlin 业务代码] --> D[协程\n由库提供]
D --> G[编译器生成的状态机\n续体对象]
G --> H[Dispatchers 线程池\n库负责调度]
H --> E
E --> I[CPU 与系统调用]
这张图想让你看见什么:不管你写的是平台线程、虚拟线程还是协程,最终都要落到 OS 线程上。三者的差别只在最上面那一段——是谁在把逻辑单元多路复用到更少的 OS 线程上。虚拟线程那一层的多路复用是 JVM 干的,协程那一层是编译期改造加库调度共同完成的,平台线程压根没有这一层。所以面试里说「虚拟线程减少了线程切换」,严格说减少的是用户态挂载卸载,不是内核上下文切换,因为载体线程数没变。
六、挂载、卸载与钉住的状态图
stateDiagram-v2
[*] --> 新建
新建 --> 运行中 : 被调度器选中并挂载到载体
运行中 --> 运行中 : 纯计算 一直占着载体线程
运行中 --> 已卸载 : 遇到可卸载阻塞 例如 Socket 读
已卸载 --> 运行中 : 事件就绪 重新挂载到某个载体
运行中 --> 已钉住 : synchronized 内阻塞 或 native 回调阻塞
已钉住 --> 运行中 : 阻塞结束 载体才被释放
运行中 --> [*] : 任务结束
这张图想让你看见什么:只有「已卸载」这一条边是免费的。虚拟线程的价值全部来自运行中到已卸载这一跳,跳到「已钉住」就等于退化成了平台线程,还多付了一份挂载的成本。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 | import java.time.Duration; |
这段代码在说明什么:Thread.sleep 是阻塞调用,虚拟线程在这里会被卸载,载体线程立刻去跑下一个任务。整段代码没有任何 suspend、async、回调,业务方法签名一个字没改——这就是「对上层透明」的实际含义。一万个任务只需要几个载体线程就能撑住,因为真正占着线程的时间接近于零。另外注意这里是每个任务新建一个虚拟线程,不是从池里借——JEP 444 原话就是虚拟线程便宜且充足、因此永远不该被池化。
Kotlin 这边,同一个语义必须靠挂起点来表达:
1 | import kotlinx.coroutines.Dispatchers |
这段代码在说明什么: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」。
这四步答完,追问基本就到顶了。
落地清单
- 记死三个 JEP 号:444 虚拟线程、491 解除
synchronized钉住、533 结构化并发第七次预览。 - 记死版本定位:LTS 是 8、11、17、21、25,下一个 LTS 是 Java 29(2027-09),JDK 27 在 2026-09-15 GA 但不是 LTS。
- 上线前先跑一遍 JFR,盯
jdk.VirtualThreadPinned,有钉住先改锁或升版本,别急着调参。 - 判断要不要上虚拟线程,只问两个数:IO 等待占比,以及下游连接池上限。
- 协程侧的论据只引官方文档里的 Continuation 与 suspend 语义,不编版本号。
- 任何「虚拟线程更快」的结论,都补一句「在什么阻塞模型、什么依赖库版本下」,否则就是口号。
参考来源
- JEP 444: Virtual Threads(Java 21,Delivered)— https://openjdk.org/jeps/444
- JEP 491: Synchronize Virtual Threads without Pinning(JDK 24)— https://openjdk.org/jeps/491
- JDK 27 项目页(2026-09-15 GA,JEP 533 结构化并发第七次预览)— https://openjdk.org/projects/jdk/27/
- Oracle Java SE 支持路线图(LTS 为 8/11/17/21/25,下一个 LTS 是 Java 29,2027-09)— https://www.oracle.com/java/technologies/java-se-support-roadmap.html
- Oracle 虚拟线程官方指南(挂载卸载、钉住、JFR 事件 jdk.VirtualThreadPinned 默认阈值 20ms)— https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html
- Kotlin 协程基础(suspending functions、coroutine builders)— https://kotlinlang.org/docs/coroutines-basics.html
- Kotlin Continuation 接口文档(挂起点之后的续体)— https://kotlinlang.org/api/core/kotlin-stdlib/kotlin.coroutines/-continuation/





