为什么 2026 年的 Java 面试绕不开虚拟线程
JDK21 已经正式发布快三年了,Spring Boot 4.0 也已经落地,企业级的 Java 技术栈正在经历一轮实质性的换代。对于面试官来说,光问”HashMap 原理”和”线程池参数”已经不够了,他们更想知道你有没有真正用过高版本的新特性,遇到问题能不能自己排查。
从面试趋势来看,虚拟线程、结构化并发、Spring Boot 4 的虚拟线程集成这些话题,正在从”加分项”变成”必考项”。但很多候选人的回答还停留在概念层面——”虚拟线程是轻量级线程,可以创建很多个”——这种回答面试官听了三年了,已经不够用了。真正能拉开差距的,是你能不能讲清楚调度机制、pinning 问题的根因、ThreadLocal 在虚拟线程下的行为变化,以及 Spring Boot 4 到底做了哪些适配。
这篇文章把这几个高频考点的底层逻辑和实践经验拆开讲,不只是告诉你”是什么”,更帮你理解”为什么”,这样不管面试官怎么追问,你都能接得住。
虚拟线程的调度机制:M:N 模型到底怎么运转
面试中被问到”虚拟线程的调度原理”,很多人的回答是”JVM 管理的轻量级线程”。这个回答没错,但太浅了。面试官想听的是你对 M:N 调度模型的理解深度。
虚拟线程不直接映射操作系统线程,而是挂载在”载体线程”(Carrier Thread)上执行。载体线程本身是平台线程,通常由一个 ForkJoinPool 来管理,默认大小是 CPU 核心数。当虚拟线程执行到 I/O 阻塞操作时,JVM 会把它从载体线程上卸载(unmount),载体线程就可以去执行其他虚拟线程。等 I/O 完成后,虚拟线程会被重新调度到某个载体线程上继续执行。
这里面有个关键点容易被忽略:虚拟线程的栈是存储在堆上的,初始大小只有几百字节,按需扩展。这就是为什么你能轻松创建几百万个虚拟线程——它们不像平台线程那样预分配 1MB 栈空间。但这也意味着,如果你的虚拟线程栈增长得很大(比如方法调用链很深),堆内存的压力会随之上升。
面试中如果被追问”虚拟线程挂载和卸载的时机是什么”,核心答案是:当虚拟线程遇到可阻塞操作时(比如网络 I/O、LockSupport.park()、j.u.c 下的锁等待),JVM 会尝试卸载它。但有一种情况例外——如果虚拟线程被”钉住”(pinned)在载体线程上,JVM 就无法卸载它,这是虚拟线程最大的性能陷阱。
pinned 线程:面试中最容易翻车的考点
说到 pinning,很多候选人的理解是:”用了 synchronized 就会导致虚拟线程被钉住。”这个说法在 JDK21 的早期版本里基本成立,但从 JDK21 后期的更新以及 JDK24/25 的优化来看,情况比这个复杂得多。
实际触发 pinning 的核心场景有两种:
- 虚拟线程在
synchronized块内发生阻塞(比如调用了Object.wait()或在锁竞争中等待) - 虚拟线程执行了 JNI 调用或本地方法,JVM 为了保证 native 层的线程身份一致性,无法迁移虚拟线程
第一种情况是最常见的踩坑点。想象一个典型场景:你的服务用 Spring Boot 4 开启了虚拟线程,但某个核心业务方法里用 synchronized 保护了一段包含数据库查询的代码。高并发下,多个虚拟线程在这个锁上排队等待,而持锁的虚拟线程在做数据库 I/O 时被阻塞——这时候它无法被卸载,载体线程被占住不释放。结果就是,虽然你开了虚拟线程,但实际行为退化成了传统线程的 1:1 调度,吞吐量不升反降。
解决方案是把 synchronized 替换为 ReentrantLock。ReentrantLock 基于 AQS 实现,在虚拟线程下不会触发 pinning,因为它的阻塞是通过 LockSupport.park() 完成的,JVM 可以正常卸载和恢复虚拟线程。
// 有 pinning 风险的写法
public synchronized Order processOrder(String orderId) {
Order order = orderRepository.findById(orderId); // 数据库 I/O
return enrichOrder(order);
}
// 推荐写法:使用 ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
public Order processOrder(String orderId) {
lock.lock();
try {
Order order = orderRepository.findById(orderId);
return enrichOrder(order);
} finally {
lock.unlock();
}
}
面试中如果被问到”怎么排查 pinning 问题”,你可以提 JDK21 提供的诊断参数:-Djdk.tracePinnedThreads=full 或 -Djdk.tracePinnedThreads=short,开启后 JVM 会在虚拟线程被 pin 时打印线程栈,帮你快速定位是哪段代码出了问题。这个细节在很多面试中被当作”有没有真正用过”的判断依据。
虚拟线程与 ThreadLocal:被忽视的内存陷阱
另一个高频面试点是虚拟线程场景下的 ThreadLocal 行为。很多候选人知道 ThreadLocal 是线程本地变量,但没有意识到虚拟线程数量可能达到百万级,这会直接放大 ThreadLocal 的内存问题。
平台线程时代,一个应用通常只有几百到几千个线程,每个 ThreadLocal 变量占用的总内存是可控的。但虚拟线程的场景下,如果每个虚拟线程都通过 ThreadLocal 携带一些上下文信息(比如用户身份、链路 traceId),百万虚拟线程乘以每个 ThreadLocal entry 的内存开销,可能直接把堆撑爆。
更隐蔽的问题是继承。如果你用 InheritableThreadLocal,在虚拟线程场景下,每次创建虚拟线程时都会拷贝父线程的所有 InheritableThreadLocal 变量。如果父线程持有大量 ThreadLocal 数据,这个拷贝开销是非常可观的。这也是为什么 JDK21 引入了 ScopedValue(预览特性),它设计上更适合虚拟线程场景——不可变、作用域受限、不会在线程间无限传递。
实践建议:如果你的服务已经迁移到虚拟线程,应该逐步排查 ThreadLocal 的使用。能用方法参数传递的上下文,尽量不用 ThreadLocal;必须用的场景,考虑迁移到 ScopedValue 或使用框架提供的上下文传递机制(如 Spring 的 RequestContextHolder 在虚拟线程下已有适配)。
Spring Boot 4 的虚拟线程集成:不只是加一行配置
Spring Boot 从 3.2 开始支持虚拟线程,到 4.0 已经做了更深度的集成。面试中常问的一个问题是”Spring Boot 怎么开启虚拟线程”,基础回答是配置 spring.threads.virtual.enabled=true,但只答到这里是不够的。
面试官更想听到的是:开启这个配置后,Spring 做了哪些事情。具体来说,Tomcat 的请求处理线程池会被替换为虚拟线程执行器,每个 HTTP 请求都在一个独立的虚拟线程中处理;@Async 注解的方法默认也会使用虚拟线程;@Scheduled 定时任务同样如此。这意味着你不需要改业务代码,框架层面的并发模型就已经从”线程池排队”变成了”一请求一虚拟线程”。
但这里有个容易被忽略的细节:Spring Boot 4 开启虚拟线程后,Tomcat 的 maxThreads 配置就不再生效了,因为不再有传统意义上的线程池上限。虚拟线程的并发度实际上受限于载体线程池的大小(默认 CPU 核心数)和下游资源的承受能力。如果你的数据库连接池只有 20 个连接,即使你开了 10 万个虚拟线程,它们最终也会在数据库连接这里排队。所以面试中如果被问”开了虚拟线程就不用调线程池了吗”,正确答案是:业务线程池确实不用调了,但数据库连接池、HTTP 客户端连接池这些下游资源池的配置反而更重要了。
| 配置项 | 平台线程模式 | 虚拟线程模式 | 注意事项 |
|---|---|---|---|
| Tomcat 线程池 | 需配置 maxThreads、minSpareThreads | 自动替换为虚拟线程,maxThreads 失效 | 并发度不再受线程数限制 |
| 数据库连接池(HikariCP) | 通常 10-20 即可 | 可能需要适当调大,但不能过大 | 连接池仍是瓶颈,虚拟线程无法绕过 |
| @Async 线程池 | 需自定义 ThreadPoolTaskExecutor | 自动使用虚拟线程 | 注意异常处理和上下文传递 |
| ThreadLocal 上下文 | 正常使用 | 需评估内存影响 | 考虑迁移到 ScopedValue |
声明式 HTTP 客户端:Spring Boot 4 的面试新宠
Spring Boot 4 正式内置了声明式 HTTP 客户端,这在面试中正在变成一个新话题。以前调用第三方接口,要么用 RestTemplate 手写拼接,要么用 WebClient 写响应式代码,两者都有不少模板代码。Spring Boot 4 提供了基于接口注解的声明式方式,写法类似 Spring Data JPA 的 Repository——定义一个接口,加上注解,框架自动生成实现。
// 声明式 HTTP 客户端示例
@HttpExchange(url = "https://api.example.com")
public interface OrderClient {
@PostExchange("/orders")
OrderResponse createOrder(@RequestBody OrderRequest request);
@GetExchange("/orders/{id}")
OrderResponse getOrder(@PathVariable("id") String orderId);
}
// 使用时直接注入即可
@Service
public class OrderService {
private final OrderClient orderClient;
public OrderService(OrderClient orderClient) {
this.orderClient = orderClient;
}
}
面试中如果被问到”声明式 HTTP 客户端和 RestTemplate 有什么区别”,核心区别在于:RestTemplate 是命令式的,你需要手动构造请求、处理响应、管理异常;声明式客户端把 HTTP 调用抽象成了接口方法,底层默认使用 WebClient(响应式)或 RestClient(同步),在虚拟线程模式下,同步阻塞调用不会占用载体线程,因为虚拟线程在 I/O 阻塞时会被自动卸载。
这里有个工程判断需要强调:声明式 HTTP 客户端在虚拟线程场景下推荐使用同步模式(RestClient 底层),而不是响应式模式。原因很简单——虚拟线程的设计初衷就是让你用同步编程风格获得异步 I/O 的性能,如果你在虚拟线程上再套一层响应式代码,等于绕了一圈又回到复杂的异步编程模式,失去了虚拟线程”零侵入”的核心价值。
结构化并发:不只是面试题,是真实的编程模型变革
结构化并发(Structured Concurrency)在 JDK21 中作为预览特性引入,在后续版本逐步稳定。面试中常问的概念是”什么是结构化并发”,但更深层的考点是”它解决了什么问题”。
传统并发编程中,你启动一个子线程去做任务,主线程继续执行,两者的生命周期是独立的。如果子线程出了异常或长时间不返回,主线程可能已经结束了,异常就丢失了,或者资源泄漏了。结构化并发把父子任务的生命周期绑定在一起——父任务必须等所有子任务完成后才能结束,任何子任务的异常都会传播给父任务。
// 结构化并发示例(JDK21+ 预览 API)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// 并行获取用户信息和订单信息
var userTask = scope.fork(() -> userService.findById(userId));
var orderTask = scope.fork(() -> orderService.findByUserId(userId));
scope.join(); // 等待所有子任务完成
scope.throwIfFailed(); // 如果有子任务失败,抛出异常
// 两个任务都成功才会执行到这里
return new UserProfile(userTask.get(), orderTask.get());
}
这段代码的价值在于:如果 orderService 抛了异常,ShutdownOnFailure 策略会自动取消其他还在运行的子任务(这里是 userTask),避免资源浪费。在传统写法中,你得手动管理 Future 的取消逻辑,很容易遗漏。
面试官如果追问”结构化并发和 CompletableFuture 有什么区别”,关键差异在于:CompletableFuture 是以 Future 为中心的编排模型,链式调用写起来灵活但调试困难,异常处理也比较绕;结构化并发是以作用域为中心的模型,强调”任务树”的概念,生命周期管理和异常传播更自然。更重要的是,结构化并发天然支持虚拟线程——每个 fork 出来的子任务都运行在独立的虚拟线程上,开销极低。
面试中常见的认知误区
在面试场景中,有几个反复出现的误区值得特别注意,因为面试官经常用这些来测试你有没有真正理解:
误区一:虚拟线程能提升所有场景的并发性能。 实际上虚拟线程只对 I/O 密集型场景有效。如果你的任务主要是 CPU 计算(比如加密、压缩、大序列化),虚拟线程不会带来性能提升,甚至可能因为调度开销略有下降。原因是 CPU 密集型任务不会触发 I/O 阻塞,虚拟线程不会被卸载,载体线程一直被占用,此时虚拟线程和平台线程没有本质区别。
误区二:开了虚拟线程就不需要连接池了。 这个理解是错的。虚拟线程解决的是”线程数量”的问题,不是”连接数量”的问题。数据库连接是有限的资源,即使你有百万个虚拟线程,HikariCP 的连接池大小仍然是吞吐瓶颈。正确的做法是评估你的下游系统承受能力,适当调整连接池大小,但不要盲目放大。
误区三:虚拟线程可以完全替代线程池。 对于需要资源隔离的场景(比如批量任务不能影响在线请求),线程池仍然是更好的选择。虚拟线程没有内置的并发度控制机制,你可以通过 Semaphore 来限制并发数,但这不如线程池的队列模型直观。在 Spring Boot 4 中,@Async 默认切到虚拟线程,但如果你需要隔离不同类型的异步任务,仍然建议自定义执行器。
实战建议:从面试理解到项目落地
最后,如果你在面试中被问到”你们项目是怎么用虚拟线程的”,或者你准备在项目中引入虚拟线程,以下几点经验可以参考:
- 先验证再推广:开启
-Djdk.tracePinnedThreads=short做一轮全链路压测,确认没有大量 pinning 告警后再推到生产环境。很多团队上线后发现性能没提升,排查半天才发现是 synchronized 导致的 pinning。 - 分批替换 synchronized:不需要一次性把所有 synchronized 都改成 ReentrantLock。先用诊断工具定位高频阻塞的 synchronized 块,优先替换那些在请求链路上的热路径代码。JDK 后续版本已经在优化 synchronized 在虚拟线程下的行为,未来这个问题可能会被框架层面解决。
- 关注 ThreadLocal 的使用量:如果你的项目大量依赖 ThreadLocal 传递上下文(比如链路追踪、租户信息),在迁移到虚拟线程之前先做一次 ThreadLocal 使用排查。必要时引入 ScopedValue 或使用框架适配方案。
- 数据库连接池要重新调参:从平台线程切到虚拟线程后,并发模型变了,原来基于线程池大小估算的连接池配置需要重新评估。建议从压测开始,逐步找到吞吐量和资源消耗的平衡点。
虚拟线程不是一个”开关”——打开就有性能提升。它是一个需要理解调度机制、识别阻塞点、评估资源瓶颈的系统工程。面试官问这些问题,本质上是在考察你有没有从”会用”到”理解”的深度。把这些原理和踩坑经验讲清楚,比背十道八股文要有说服力得多。
原创文章,作者:,如若转载,请注明出处:https://fudengji.cn/article/352/