虚拟线程不是让单个请求执行得更快,而是让大量阻塞式请求以更低的线程成本并发执行。本文通过一个可运行的 Spring Boot 示例,说明虚拟线程适合什么场景、如何启用、怎样压测,以及为什么 synchronized、数据库连接池和错误的性能预期会让优化失效。

本文示例基于 Java 21 + Spring Boot 3.5.x


一、虚拟线程解决的到底是什么问题?

传统 Spring MVC 通常采用“一个请求对应一个平台线程”的模型。请求等待数据库、Redis 或远程 HTTP 响应时,线程虽然没有计算,却仍占用操作系统线程资源。并发升高后,线程数量、栈内存和上下文切换都会成为成本。

虚拟线程仍采用易于理解的同步编程方式,但由 JVM 调度,大量虚拟线程可以复用少量平台线程:

HTTP 请求 → 虚拟线程 → 执行业务代码
                         ↓ 遇到可挂起的阻塞操作
                    JVM 卸载虚拟线程
                         ↓
                 平台线程执行其他任务

因此它更适合:

  • 请求主要等待 JDBC、HTTP、文件等阻塞式 I/O;
  • 并发量较高,同时希望保留同步代码风格;
  • 线程池已经成为吞吐量瓶颈。

它不擅长纯 CPU 密集型任务。视频编码、复杂加密或大规模计算的上限仍由 CPU 核数决定。


二、在 Spring Boot 中启用虚拟线程

项目至少需要 Java 21:

<properties>
    <java.version>21</java.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
</dependencies>

application.yml 中开启:

spring:
  threads:
    virtual:
      enabled: true
  main:
    keep-alive: true

keep-alive 值得保留。虚拟线程是守护线程,如果应用只剩守护线程,JVM 可以直接退出,这对依赖 @Scheduled 维持运行的程序尤其重要。

编写一个模拟下游 I/O 的接口:

@RestController
@RequestMapping("/virtual-thread")
public class VirtualThreadController {

    @GetMapping("/io")
    public Map<String, Object> io() throws InterruptedException {
        Thread.sleep(Duration.ofMillis(200)); // 模拟数据库或 HTTP 等待
        Thread thread = Thread.currentThread();
        return Map.of(
                "thread", thread.toString(),
                "virtual", thread.isVirtual()
        );
    }
}

访问接口后,virtual 应为 true。不要只根据线程名称判断,Thread.isVirtual() 更可靠。


三、虚拟线程为什么能提升吞吐量?

假设每个请求只计算 5 毫秒,却等待下游 195 毫秒。传统线程在 195 毫秒等待期内无法处理其他请求;虚拟线程阻塞时通常会被卸载,承载它的平台线程可以继续运行别的虚拟线程。

可以用 JDK 自带 API 做一个简化实验:

public class ThreadBenchmark {
    private static final int TASKS = 10_000;

    public static void main(String[] args) throws Exception {
        run(Executors.newFixedThreadPool(200), "platform-200");
        run(Executors.newVirtualThreadPerTaskExecutor(), "virtual");
    }

    private static void run(ExecutorService executor, String name)
            throws Exception {
        long start = System.nanoTime();
        try (executor) {
            List<Future<?>> futures = IntStream.range(0, TASKS)
                    .mapToObj(i -> executor.submit(() -> {
                        try {
                            Thread.sleep(200);
                        } catch (InterruptedException e) {
                            Thread.currentThread().interrupt();
                        }
                    }))
                    .toList();
            for (Future<?> future : futures) {
                future.get();
            }
        }
        long millis = Duration.ofNanos(System.nanoTime() - start).toMillis();
        System.out.printf("%s: %d ms%n", name, millis);
    }
}

这个实验只证明“等待型任务能容纳更多并发”,不能代替业务压测。真实系统还受数据库连接数、下游限流、内存和网络带宽约束。


四、不要用线程池限制虚拟线程数量

平台线程昂贵,因此过去常用固定线程池控制数量。虚拟线程本身很轻量,再把它们放入固定大小的线程池,往往等于主动取消其优势。

真正需要限制的是稀缺资源。例如下游接口最多允许 100 个并发,可以使用信号量:

@Component
public class LimitedClient {
    private final Semaphore permits = new Semaphore(100);

    public String call() throws InterruptedException {
        permits.acquire();
        try {
            return invokeRemoteService();
        } finally {
            permits.release();
        }
    }

    private String invokeRemoteService() throws InterruptedException {
        Thread.sleep(100);
        return "ok";
    }
}

这表达的是“保护下游资源”,而不是“因为线程太贵而复用线程”。数据库同样受连接池约束:即使创建十万个虚拟线程,只有 50 个数据库连接时,也只能同时执行约 50 个 SQL。


五、最危险的陷阱:Pinned Virtual Thread

虚拟线程执行阻塞操作时,理想情况是从载体线程上卸载。如果它在某些不能安全卸载的代码区域发生阻塞,就可能固定在载体线程上。载体线程无法服务其他虚拟线程,吞吐量会下降。

典型反例是在 synchronized 代码块内执行慢 I/O:

public class BadOrderService {
    private final Object lock = new Object();

    public String queryOrder() throws InterruptedException {
        synchronized (lock) {
            Thread.sleep(500); // 用慢调用模拟远程 I/O
            return "order";
        }
    }
}

改进原则是缩小锁的范围,不要持锁执行网络或数据库操作:

public String queryOrder() throws InterruptedException {
    String result = callRemoteOrderService();
    synchronized (lock) {
        updateLocalState(result);
    }
    return result;
}

如果确实需要在可中断等待期间加锁,可以评估 ReentrantLock。但不能机械地替换全部 synchronized;先通过监控找到真实热点。


六、用 JFR 和 jcmd 定位线程固定

生产排障时可以开启 Java Flight Recorder:

java -XX:StartFlightRecording=filename=app.jfr,duration=60s \
     -jar app.jar

也可以对运行中的 JVM 发起录制:

jcmd <pid> JFR.start name=virtual-thread settings=profile duration=60s filename=app.jfr

使用 JDK Mission Control 打开文件,重点查看虚拟线程固定事件、事件堆栈及持续时间。排查顺序建议如下:

  1. 找到持续时间最长、出现频率最高的固定事件;
  2. 沿堆栈定位业务锁、第三方驱动或本地方法;
  3. 判断阻塞是否发生在锁内部;
  4. 修改后用相同流量模型复测 P95、P99 和吞吐量。

仅看到一次极短事件不代表系统存在问题。性能优化应以业务指标为判断依据。


七、上线前必须回答的五个问题

  1. 当前瓶颈真的是平台线程,而不是数据库连接池吗?
  2. 请求以阻塞式 I/O 为主,还是以 CPU 计算为主?
  3. 下游服务允许多大并发,是否设置超时和限流?
  4. 依赖库在虚拟线程下是否存在长时间固定问题?
  5. 是否用相同机器、数据和流量模型完成前后对比?

建议至少记录吞吐量、P50/P95/P99、错误率、CPU、堆内存、数据库连接池等待时间和下游并发数。只比较平均响应时间,很容易得出错误结论。


八、总结

虚拟线程的核心价值不是“神奇加速”,而是降低阻塞式并发模型的线程成本。Spring Boot 中开启它只需要一项配置,真正困难的是资源治理:数据库连接依然有限,下游仍需限流,持锁阻塞仍可能固定载体线程。

正确做法是先定位瓶颈,再启用虚拟线程,通过 JFR 排查固定问题,并用真实压测验证收益。如果业务主要是高并发阻塞式 I/O,又希望继续使用清晰的同步代码,虚拟线程通常值得优先评估。

更多推荐