Zademy

Java 中的虚拟线程:可扩展性与复杂性平衡

Java; 虚拟线程; ProjectLoom; 并发; JDK25
words 字

传统并发的问题

Java 传统的并发靠平台线程(Platform Threads),每个线程直接映射到一个操作系统线程,1:1 的关系。每个线程大约吃掉 1-2 MB 内存,外加操作系统级别的资源开销。创建几千个线程就开始捉襟见肘了,再多就 OutOfMemoryError 或者上下文切换把 CPU 拖垮。

Web 应用里最常见的"每个请求一个线程"模型,写起来直观,但到了高并发场景(比如超过 10k 并发请求)就力不从心。响应式编程——Project Reactor、RxJava 那些——确实能提高吞吐量,代价是代码变得难写、难读、难调试。

什么是虚拟线程(VT)?

Project Loom 搞出来的虚拟线程,在 JDK 21(JEP 444)正式稳定。思路很简单:JVM 自己管理轻量级线程,每个大约只占 1 KB 内存,你可以轻松开几百万个不会撑爆内存。

虚拟线程是 java.lang.Thread 的实例,现有代码几乎不用改就能用。它针对的是 I/O 绑定场景——HTTP 调用、数据库查询、文件读写这类会阻塞的操作,不是 CPU 密集型的银弹。概念上类似 Go 的 goroutines。从 JDK 21 开始稳定,22 到 25 持续在改进。

内部工作原理:M:N 调度

M:N 调度,M 个虚拟线程映射到 N 个平台线程上。平台线程充当载体(carrier),虚拟线程挂载到载体上执行。当虚拟线程遇到 I/O 阻塞——sleep()、套接字操作、数据库查询——它会从载体上卸载,载体就可以去跑别的虚拟线程。

I/O 完成后,虚拟线程回到调度器队列,等任何空闲载体重新挂载它继续执行。载体线程几乎不会因为等 I/O 而闲着,CPU 利用率被拉满。

最近的改进值得说一下。JDK 24(JEP 491)解决了 synchronized 块里的线程固定问题,虚拟线程在 synchronized 代码块中遇到阻塞也能正确卸载了。JDK 25(JEP 506)引入了作用域值(Scoped Values)作为 ThreadLocal 的不可变替代品,防止虚拟线程场景下的内存泄漏。

实际示例

虚拟线程最适合 I/O 绑定的场景——HTTP 请求、数据库访问、文件操作。

示例 1:简单虚拟线程

Thread.startVirtualThread(() -> {
    System.out.println("来自 VT 的问候!" + Thread.currentThread());
});

示例 2:ExecutorService(高并发)

import java.util.concurrent.Executors;
import java.time.Duration;

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    // 10k 并发任务
    IntStream.range(0, 10_000).forEach(i ->
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1)); // 模拟 I/O
            // 业务逻辑
        })
    );
} // 自动等待完成

newVirtualThreadPerTaskExecutor() 给每个任务分配一个虚拟线程。一万个并发任务每个睡一秒,传统线程池早 OOM 了,虚拟线程毫无压力。

示例 3:Spring Boot + VT(真实基准测试)

最近的基准测试(JDK 24 + Spring Boot + Postgres),虚拟线程在 10k req/s 时延迟比平台线程低了大约 50%。

@RestController
public class ApiController {
    @GetMapping("/data")
    String getData() {
        // VT 自动处理请求(Tomcat/Undertow 配置)
        return fetchFromDb(); // 阻塞但可扩展
    }
}

Spring Boot 3.2+ 配置虚拟线程很简单,Tomcat 或 Undertow 的线程池直接换成虚拟线程就行。你写的还是同步阻塞代码,底层已经是虚拟线程在跑了。

高级考虑和最佳实践

CPU 绑定 vs I/O 绑定

虚拟线程不是万能的。I/O 绑定的任务——数据库、HTTP 调用——用虚拟线程加 newVirtualThreadPerTaskExecutor(),大规模扩展性非常好。但 CPU 绑定的任务,比如加密运算或机器学习推理,用虚拟线程反而有害。CPU 密集型任务不会让出载体线程,会导致载体饥饿:少数载体被占满,其他虚拟线程排不上队。这类场景还是老老实实用平台线程池,线程数设成 CPU 核心数。

问题和解决方案

synchronized 块的固定问题在 JDK 24 已经解决了,但 JNI 或原生代码还是可能遇到固定,能避开就避开。

ThreadLocal 跟虚拟线程配合很糟糕,几百万个虚拟线程各自持有一份 ThreadLocal 数据,内存直接爆炸。JDK 25 的作用域值是官方推荐的替代方案。

监控方面,Thread.currentThread().isVirtual() 可以判断当前线程是不是虚拟线程。JFR 从 JDK 21 开始就支持虚拟线程的录制和分析。框架支持也很成熟了,Spring Boot 3.2+、Quarkus、Helidon 都原生支持。

最佳实践(2025):

  1. Web 服务器默认开虚拟线程,最简单的收益。
  2. 用 JDK Mission Control 或 JFR 做分析,检测有没有线程固定。
  3. ThreadLocal 能迁就迁到作用域值。
  4. CPU 密集型用固定大小的线程池。
  5. 压测用 wrk 或 ab,跑到 100k 并发看实际表现。

最终总结

如果你刚开始接触并发,直接上虚拟线程。Thread.startVirtualThread() 一个方法调用搞定,不用操心线程池配置。如果你是有经验的开发者,I/O 绑定场景用虚拟线程,CPU 密集型还是平台线程池,注意监控线程固定。

打个比方:平台线程像是给每个人配一部专用电梯,很舒服但成本爆炸。虚拟线程像气动管道系统,几千个轻量胶囊在少数物理管道里穿梭。

来源:OpenJDK JEPs、Spring Boot 基准测试(Reddit/Java 24)、RockTheJVM/RabinaNPatra 指南(2025)。