首页/文章/Java / Spring
Java / Spring

Java 21 虚拟线程原理与应用

从线程与请求的映射关系出发,理解虚拟线程的调度、阻塞 I/O、资源限流和迁移边界。

虚拟线程(Virtual Thread)不是“更快的线程”,而是让阻塞式代码在大量等待 I/O 时不必长期占用同等数量的操作系统线程。它改变的是并发任务的承载成本,不会让 CPU 变多、数据库连接变多,也不会让一个慢下游突然变快。理解这个边界,比把线程池参数替换成 newVirtualThreadPerTaskExecutor() 更重要。

线程与请求的映射

传统平台线程通常由 OS 线程执行,一个平台线程在阻塞 I/O 时仍被占用。虚拟线程由 JVM 调度到少量 carrier platform threads 上运行;遇到支持的阻塞操作时,虚拟线程可以挂起并释放 carrier,让其他虚拟线程继续运行。

flowchart TB
  subgraph requests[大量请求任务]
    v1[虚拟线程 A 等待 HTTP]
    v2[虚拟线程 B 处理返回值]
    v3[虚拟线程 C 等待 JDBC]
    v4[虚拟线程 D 执行代码]
  end
  scheduler[JVM 虚拟线程调度器]
  carriers[少量平台线程 / Carrier]
  cpu[CPU 核心]
  v1 --> scheduler
  v2 --> scheduler
  v3 --> scheduler
  v4 --> scheduler
  scheduler --> carriers --> cpu
  v1 -.阻塞 I/O 时卸载.-> scheduler

用一句话概括:虚拟线程让“每个请求一个线程”的编程模型能承载更多 I/O 并发,但它仍然共享 CPU、连接池、文件描述符、下游配额等有限资源。

从任务开始使用

JDK 21 中可以使用 Executors.newVirtualThreadPerTaskExecutor(),每个提交任务创建一个虚拟线程。它不是线程池,不应该用来复用线程:虚拟线程本来就轻量,重复利用会让任务的生命周期和取消边界更难理解。

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<Customer> customer = executor.submit(() -> customerClient.fetch(id));
    Future<List<Order>> orders = executor.submit(() -> orderClient.fetchByCustomer(id));

    return new CustomerDashboard(
            customer.get(800, TimeUnit.MILLISECONDS),
            orders.get(800, TimeUnit.MILLISECONDS));
}

在 Spring Boot 中可以用虚拟线程承载 @Async 等任务:

spring:
  threads:
    virtual:
      enabled: true

启用前要核对 Spring Boot 版本、任务执行器 bean、Web 容器线程策略和项目里的自定义 TaskExecutor。如果项目自己声明了 executor,自动配置可能退让;不要因为配置开关成功启动,就假设每条异步路径都改成了虚拟线程。

用信号量限制真正稀缺的资源

虚拟线程不会替代数据库连接池。假如数据库最多支持 80 个业务连接,应用若接受几千个同时执行 JDBC 的请求,线程可能不再是瓶颈,连接池排队和超时反而会成为放大器。限流要落在数据库、HTTP 下游或租户配额等资源边界。

final class PartnerGateway {
    private final Semaphore permits = new Semaphore(64);
    private final HttpClient httpClient;

    PartnerGateway(HttpClient httpClient) {
        this.httpClient = httpClient;
    }

    PartnerResult call(URI uri) throws Exception {
        if (!permits.tryAcquire(150, TimeUnit.MILLISECONDS)) {
            throw new RejectedExecutionException("partner concurrency limit reached");
        }
        try {
            HttpRequest request = HttpRequest.newBuilder(uri)
                    .timeout(Duration.ofSeconds(2))
                    .GET().build();
            return httpClient.send(request, HttpResponse.BodyHandlers.ofString())
                    .statusCode() == 200
                    ? PartnerResult.ok()
                    : PartnerResult.failed();
        } finally {
            permits.release();
        }
    }
}

生产代码还要区分超时、网络错误、业务拒绝和 5xx,设置重试预算与熔断,并确保中断不会被吞掉。对外部请求,不要在限流超时之后无界排队等待。

哪些任务收益明显

  • 阻塞式 I/O 服务:大量请求等待数据库、HTTP、文件等操作,且调用栈和 ThreadLocal 传统代码较多。
  • 代码简化:原本为了省线程而层层改造成回调/响应式链路,迁移后可以恢复顺序化的任务代码。
  • 吞吐提升但不保证延迟下降:减少线程占用有机会提高整体并发吞吐;单个请求的网络、SQL 和 CPU 时间没有自动缩短。

虚拟线程不适合用作 CPU 密集计算的无界并行池。大量压缩、加密或图像运算仍然受 CPU 核心数量限制,应使用有界 CPU executor,并用压测确定并发度。

常见踩坑

  1. 把虚拟线程当作资源池:线程便宜不代表连接、内存和下游容量无限。对每个受限资源建立明确的并发上限。
  2. 对虚拟线程做固定大小池化:每个任务新建虚拟线程,由下游 semaphore 或连接池执行资源限流;固定线程池会重复引入排队瓶颈。
  3. Java 21 中长时间在 synchronized 内阻塞:虚拟线程被 monitor pin 住时 carrier 无法释放。缩短临界区,避免持锁执行网络或数据库 I/O;可用 JFR jdk.VirtualThreadPinned 事件定位。
  4. 误以为所有阻塞都能卸载:native 调用、某些文件 I/O 和版本相关的 monitor 行为需要实测;升级 JDK 后也要重新看实际行为。
  5. ThreadLocal 持有大对象:虚拟线程数量上来后,每线程副本会造成明显内存压力。检查 MDC、安全上下文、请求缓存和事务上下文的生命周期。
  6. 迁移后忘记观测:记录活跃请求、连接池等待、下游并发、超时率、carrier 利用率和 pinned 事件;线程数变化不能单独说明成功。

迁移可以先从一个阻塞 I/O 比例高、边界清楚的服务开始。用同一组流量与依赖条件对照吞吐、P95/P99、堆占用、下游连接数与错误率,再决定是否扩大范围。

参考资料

文章反馈

如果这篇文章对你有帮助,欢迎发邮件交流。

通过邮箱联系 ↗继续阅读 →