返回技术文章

Java 虚拟线程为什么不等于无限并发:从等待型任务说起

用完整的 Java 21 示例理解虚拟线程、Semaphore 和下游保护,并区分 JDK 21 与 JDK 24 的 pinning 行为。

约 4 分钟读完 后端架构
Java 虚拟线程为什么不等于无限并发:从等待型任务说起 封面

一个接口要请求外部模型、查询数据库、读取远程文件,大部分时间可能都在等。为了同时处理更多请求,传统做法往往是调大线程池;但平台线程不是没有成本,线程数量也不能无限增加。

虚拟线程为这种“每个任务按顺序写代码、执行中经常阻塞等待”的场景提供了另一种选择。它在 JDK 21 成为正式特性,目标重点是提高大量等待型任务的并发承载能力,而不是让单个计算任务自动变快。1

线程轻了,真正的资源没有变多

可以把虚拟线程理解为由 JVM 调度的轻量线程。适用的阻塞操作发生时,运行它的平台线程可以释放出来执行其他虚拟线程。具体行为还取决于操作、库实现和 JDK 版本,不能把“阻塞都没有成本”当成保证。

假设数据库连接池只有 20 个连接,即使创建 10,000 个虚拟线程,数据库也不会自动变成能同时处理 10,000 个查询。等待只是从平台线程池队列转移到了获取连接的地方。如果每个任务还持有大对象,内存仍然可能耗尽。

因此需要分清三件事:线程用于承载执行;并发限制用于保护有限资源;入口限流或有界队列用于控制系统接收多少工作。只改变第一件事,后两件事仍要设计。

虚拟线程与下游资源限制
虚拟线程与下游资源限制

一个带并发上限的完整示例

下面的程序面向 Java 21 及以上,使用 Thread.sleep 模拟等待,不访问外部服务。它演示同一时间最多八个任务进入受保护区域,不是性能基准。将代码保存为 VirtualThreadBulkhead.java 即可按后面的命令运行。

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class VirtualThreadBulkhead {
    private static final Semaphore GATE = new Semaphore(8);
    private static final AtomicInteger ACTIVE = new AtomicInteger();
    private static final AtomicInteger PEAK = new AtomicInteger();

    private static String callDownstream(int id) throws InterruptedException {
        // 只在成功拿到许可后进入 finally,避免误释放许可。
        if (!GATE.tryAcquire(2, TimeUnit.SECONDS)) {
            return "REJECTED:" + id;
        }

        try {
            int active = ACTIVE.incrementAndGet();
            PEAK.accumulateAndGet(active, Math::max);
            Thread.sleep(40); // 模拟可中断的等待,不代表真实接口耗时。
            return "OK:" + id;
        } finally {
            ACTIVE.decrementAndGet();
            GATE.release();
        }
    }

    public static void main(String[] args) throws Exception {
        List<Future<String>> futures = new ArrayList<>();
        int succeeded = 0;
        int rejected = 0;

        try (ExecutorService executor =
                     Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 200; i++) {
                final int id = i;
                futures.add(executor.submit(() -> callDownstream(id)));
            }

            for (Future<String> future : futures) {
                String result = future.get();
                if (result.startsWith("OK:")) {
                    succeeded++;
                } else {
                    rejected++;
                }
            }
        }

        if (PEAK.get() > 8) {
            throw new IllegalStateException("并发上限被突破");
        }
        System.out.printf("ok=%d rejected=%d peak=%d%n",
                succeeded, rejected, PEAK.get());
    }
}

运行方式:

javac --release 21 VirtualThreadBulkhead.java
java VirtualThreadBulkhead

这里的 200 是有限的演示输入。生产系统不应先把任意多请求全部提交,再认为一个 Semaphore 已经解决过载。入口仍应设置接收上限、排队时限或拒绝策略。

tryAcquire 只限制等待许可的时间,不限制真正的远程请求时间。替换成 HTTP 或数据库调用后,还要配置调用超时、连接池获取超时,并按业务需要管理整体截止时间。Future.get() 超时也不等于远程动作一定停止;取消通常只是请求中断,底层客户端是否响应必须验证。

以上 Java 21 示例经过代码审阅,但未附编译运行验证;这里不提供运行结果或吞吐量数字。它适合用来观察任务与许可的关系,真实服务还需要在对应 JDK 和依赖环境中验证。

并发上限和每秒请求数不是一回事

八个许可意味着最多八个调用同时进行。若接口响应很快,一秒内仍可能发出很多次调用。因此,模型服务的“每分钟请求数”限制,需要速率限制器或请求预算;连接数和并发数限制,则由信号量、连接池等机制处理。

如果不同下游容量不同,最好分别控制。给数据库查询、模型推理、文件上传共用一个许可池,可能让慢上传阻塞本来可以快速完成的查询。反过来,每个请求自己创建一个新的信号量,也无法形成应用级总限制。

哪些任务不适合指望虚拟线程提速

CPU 密集型计算仍然受处理器资源限制。给同一段图像计算开更多虚拟线程,不会增加物理计算能力,还可能增加调度成本。此时更应考虑算法、数据布局和合理的计算并行度。

还要注意每任务资源。平台线程池中某个 ThreadLocal 缓存原本只创建几十份,改成每任务一个虚拟线程后,可能出现更多实例。适合共享且线程安全的资源应以受控方式共享,不能机械照搬“每线程一个大对象”。2

关于 synchronized,要写清 JDK 版本

在 JDK 21 中,虚拟线程在某些 synchronized 相关场景阻塞时可能固定住承载线程,即 pinning。JDK 24 的相关改进消除了由 Java monitor 导致的一类固定问题,但这不表示所有 native/foreign 等场景都不再有类似限制。[2]3

因此,不能在所有版本上笼统写“虚拟线程绝对不能用 synchronized”,也不能说升级之后无需再分析阻塞。应结合实际 JDK、依赖库、线程转储或 JFR 等工具查明瓶颈。

虚拟线程最有价值的地方,是保留直观的顺序代码,同时承载更多等待中的任务。上线前真正需要验证的,是下游容量、排队时间、内存和取消行为,而不是只数创建了多少线程。

参考资料

本文不报告性能实测。资料核对日期:2026-10-08。