代表性面试主题

Java 并发面试:虚拟线程什么时候有用,什么时候会失效?

编程题困难
Offer.cc 编辑团队发布 更新

题干

一个 Java 21 服务每个请求会并行调用三个下游 API,传统线程池在高并发时排队严重。团队想把线程池全部换成虚拟线程。请说明虚拟线程解决了什么、不会解决什么,以及如何限制数据库连接和定位 pinning。

题干与适用场景

这道编程与并发题适合 Java 后端、平台和性能工程岗位。题目要求你比较 platform thread、virtual thread 和异步回调模型,并把并发数量、CPU、阻塞 I/O、下游连接池和诊断工具放进同一个推理链。重点是可复用的选择规则,而非背诵 API 名称。

面试官考察点

  • 能否说清虚拟线程提升的是可扩展并发和吞吐,不是单个任务的执行速度。
  • 能否识别 synchronized 或 native 调用造成的 pinning,以及 CPU 密集任务的上限。
  • 能否用每任务一个虚拟线程表达 fan-out,并用 Semaphore 或数据库连接池限制下游资源。
  • 能否设计 JFR、线程转储、延迟、carrier 利用率和下游池等待的验证方法。

回答前需要澄清的问题

先问请求主要等待网络、数据库还是执行 CPU 计算;三个下游调用是否可以并行;下游每个服务的并发额度和数据库连接数是多少。还要确认框架与驱动是否支持阻塞 API、是否存在长时间 synchronized 或 native 区段、目标是降低 p99 还是提高吞吐,以及是否允许在灰度期间保留旧执行器。

30 秒回答框架

虚拟线程让每个任务保留同步、阻塞 I/O 的写法,等待时释放 carrier,因此适合大量 I/O 等待的请求;它不会让 CPU 代码变快,也不会增加数据库连接或下游配额。我会使用每任务一个虚拟线程做 fan-out,用 Semaphore 或连接池限制稀缺资源,检查锁和 native 调用造成的 pinning。最后通过 JFR、线程转储、下游等待和 p99/吞吐对比灰度验证,而不是假设替换线程池就一定更快。

分步骤深入解答

1. 把收益定义成等待期间的并发能力

platform thread 长时间等待 I/O 时仍占用 OS 线程;virtual thread 在阻塞 I/O 时可被挂起,carrier 继续运行其他虚拟线程。它适合请求大部分时间等待网络或数据库的服务。CPU 密集代码仍受核心数和调度并行度限制,虚拟线程不会降低算法复杂度或单请求 CPU 时间。

2. 每个任务创建虚拟线程,不要池化虚拟线程

虚拟线程是廉价的任务表示,应用可使用 Executors.newVirtualThreadPerTaskExecutor() 为每个提交任务创建一个新线程。共享固定虚拟线程池会重新引入排队语义,却没有必要复用稀缺 carrier;需要限制的是外部资源,不是虚拟线程数量本身。

java
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
  Future<Profile> profile = executor.submit(() -> profileClient.fetch(id));
  Future<Orders> orders = executor.submit(() -> orderClient.fetch(id));
  Future<Quota> quota = executor.submit(() -> quotaClient.fetch(id));
  return merge(profile.get(), orders.get(), quota.get());
}

3. 用专门的信号量限制下游并发

虚拟线程数量可以很多,但数据库连接、供应商 QPS 和文件句柄仍然有限。用 Semaphore 包住受限调用,或直接依赖已有数据库连接池作为并发边界;不要用“固定线程池大小”同时承担线程复用和资源限流。信号量必须在 finally 中释放,并为等待设置超时和取消策略。

4. 识别 pinning 和不可卸载场景

虚拟线程在 synchronized 区段执行阻塞操作,或调用 native/foreign 函数时可能固定在 carrier 上。短小的内存锁通常无碍,长时间 I/O 锁会占住 carrier,导致吞吐下降。把热路径中包住阻塞调用的监视器改为合适的 ReentrantLock,并通过 JFR 的 pinned 事件或诊断选项定位,而不是全局替换所有 synchronized

5. 处理取消、超时和异常传播

fan-out 的三个调用必须有请求总截止时间;任一关键调用失败时取消仍在等待的任务,避免客户端已经超时后下游继续消耗配额。虚拟线程只改变线程承载方式,不会自动取消 Future、关闭响应体或释放连接。把 InterruptedException、超时、下游错误映射到明确的降级结果,并在 finally 清理资源。

6. 用指标和对照实验证明收益

灰度比较相同流量下的吞吐、p50/p99、CPU、carrier 并行度、虚拟线程数、下游连接池等待、信号量等待和错误率。用 JFR 记录 jdk.VirtualThreadPinned、启动结束事件,并用 jcmd 线程转储观察堆栈。分别跑 I/O 等待、CPU 密集、下游限流和锁竞争场景;只有 I/O 场景改善而 CPU 场景不改善,才符合预期。

高质量示范回答

我不会把虚拟线程当成更快的线程池。它适合每请求包含大量阻塞 I/O 的服务,因为等待时虚拟线程可挂起并释放 carrier;CPU 密集计算仍受核心数限制。对于三个并行下游调用,我会用每任务虚拟线程的 executor,使用总截止时间和取消处理;数据库连接和供应商额度用连接池或 Semaphore 限制。先检查驱动、锁和 native 调用,避免长 I/O 位于 synchronized 区段造成 pinning。灰度时比较吞吐、p99、carrier 利用率、下游等待、JFR pinned 事件和线程转储,再决定是否扩大迁移。

常见错误

  • 说虚拟线程让 CPU 代码更快 → 它主要提高等待型任务的并发,不增加 CPU 核心 → 把吞吐、延迟和 CPU 计算分开测量。
  • 创建固定大小的虚拟线程池 → 把线程池误当成资源限流器 → 每任务创建虚拟线程,用信号量或连接池限制下游。
  • 看到 synchronized 就全部替换 → 短内存临界区不一定 pinning,盲目替换会增加复杂度 → 定位阻塞锁路径和 JFR 证据后再改。
  • 忽略下游连接数 → 虚拟线程能同时发起更多请求,却不能增加连接或 QPS 配额 → 明确预算、超时和降级。
  • 只做高并发压测 → 可能掩盖 pinning、取消泄漏或 CPU 饱和 → 分别测试 I/O、CPU、锁、限流和异常恢复。

追问及应对

虚拟线程与 reactive 有什么取舍?

虚拟线程保留顺序、阻塞式代码和传统调试工具,适合大量 I/O 等待且调用栈需要易读的服务。Reactive 在事件流组合、极高连接数或已有非阻塞生态中可能更合适。选择依据是驱动支持、团队维护成本、延迟目标和观测能力,而非“新技术一定更快”。

如何限制一个供应商最多十个并发请求?

为该供应商创建容量为十的 Semaphore,获取许可后调用并在 finally 释放;等待设置截止时间,超时返回降级或排队结果。若已有连接池且每个请求必须持有连接,连接池本身就是更准确的边界,避免重复加两层限制。

为什么仍然会出现 carrier 饥饿?

长时间 synchronized 阻塞、native 调用、CPU 密集任务或未受控的外部资源等待都可能占住 carrier 或耗尽并行度。查看 JFR pinned 事件、线程转储、CPU 火焰图和下游等待时间,区分 pinning 与正常的任务积压。

虚拟线程能取代数据库连接池吗?

不能。虚拟线程是执行任务的承载,数据库连接是有限外部资源。仍需连接池、超时、事务边界和池等待指标;让大量虚拟线程无限等待连接只会把压力转移到内存和请求截止时间。

如何证明迁移没有让延迟变差?

固定输入、下游响应分布和错误率,比较旧执行器与虚拟线程灰度的 p50/p99、吞吐、CPU、carrier 利用率、池等待、取消完成率和 pinned 事件。覆盖稳态、突发、下游慢、连接池耗尽和进程重启,设置回滚阈值后再扩大流量。

公开来源

同类题目

相关面试工具

用 Screenshot 处理算法题

截图题目后,按顺序看约束、解法、代码、边界条件和复杂度。

查看工具