Java 21 中引入的虚拟线程 (Virtual threads),代表了 Java 并发模型的一次重大进步。本指南将通过一个 Spring Boot 应用,演示传统线程与虚拟线程之间的实际差异,展示虚拟线程如何在不受物理线程限制的情况下,轻松处理成千上万的并发请求。
先决条件
-
• Java 21 或更高版本
-
• Maven 或 Gradle
-
• 对 Spring Boot 有基本了解
-
• 对并发编程有基本了解
核心概念
传统线程 (Traditional Threads / 平台线程)
-
• 每个线程都直接映射到一个操作系统 (OS) 线程。
-
• 数量受限于可用的 CPU 核心数和操作系统限制。
-
• 内存开销高 (通常每个线程约 1MB)。
-
• 阻塞操作会占用并“卡住”整个线程,使其无法执行其他任务。
虚拟线程 (Virtual Threads)
-
• 由 JVM 管理的轻量级线程。
-
• 数量不受限于 CPU 核心数,可以创建数百万个。
-
• 内存开销极小 (通常每个线程仅几 KB)。
-
• 能高效处理阻塞操作,线程在阻塞时不会占用 OS 线程,可以去执行其他任务。
-
• 非常适合 I/O 密集型应用。
项目设置
-
1. 创建一个使用 Java 21 的新 Spring Boot 项目。
- 2. 将以下依赖项添加到你的
pom.xml
文件中 (或者你也可以使用 Gradle):<dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency> </dependencies>
实现步骤
1. 线程配置 (ThreadConfig.java
)
创建一个配置类来分别配置传统线程池和虚拟线程的执行器 (Executor)。
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;import java.util.concurrent.Executor;
import java.util.concurrent.Executors;@Configuration
@EnableAsync// 启用异步方法执行
publicclassThreadConfig {// 定义传统线程池执行器@Bean(name = "traditionalExecutor")public Executor traditionalExecutor() {ThreadPoolTaskExecutorexecutor=newThreadPoolTaskExecutor();executor.setCorePoolSize(10); // 核心线程数:10executor.setMaxPoolSize(10); // 最大线程数:10 (线程数量有限)executor.setQueueCapacity(100); // 等待队列容量:100executor.setThreadNamePrefix("traditional-"); // 线程名前缀executor.initialize();return executor;}// 定义虚拟线程执行器@Bean(name = "virtualExecutor")public Executor virtualExecutor() {// 每次任务都创建一个新的虚拟线程return Executors.newVirtualThreadPerTaskExecutor();}
}
2. 测试控制器 (TestController.java
)
创建一个控制器,包含两个端点,分别用于演示两种线程类型的行为。
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;@RestController
@RequestMapping("/api/test")
publicclassTestController {privatestaticfinalLoggerlogger= LoggerFactory.getLogger(TestController.class);// 使用传统线程池异步执行@Async("traditionalExecutor")@GetMapping("/traditional")public CompletableFuture<String> traditionalThread() {logger.info("开始传统线程请求。线程: {}", Thread.currentThread().getName());try {TimeUnit.SECONDS.sleep(1); // 模拟耗时的I/O操作} catch (InterruptedException e) {Thread.currentThread().interrupt();return CompletableFuture.completedFuture("任务被中断");}return CompletableFuture.completedFuture("传统线程任务完成。线程: " + Thread.currentThread().getName());}// 使用虚拟线程执行器异步执行@Async("virtualExecutor")@GetMapping("/virtual")public CompletableFuture<String> virtualThread() {// 注意:日志中打印 Thread.currentThread() 会显示虚拟线程的详细信息logger.info("开始虚拟线程请求。线程: {}", Thread.currentThread());try {TimeUnit.SECONDS.sleep(1); // 模拟耗时的I/O操作} catch (InterruptedException e) {Thread.currentThread().interrupt();return CompletableFuture.completedFuture("任务被中断");}return CompletableFuture.completedFuture("虚拟线程任务完成。线程: " + Thread.currentThread());}
}
测试实现
1. 启动应用程序
./mvnw spring-boot:run
2. 测试传统线程
使用下面的命令模拟 200 个并发请求。由于线程池限制,你应该会看到一些请求失败。
# 测试200个并发请求(应该会看到一些失败或拒绝)
seq 1 200 | xargs -n1 -P200 curl -s "http://localhost:8080/api/test/traditional"
-
• 对上述命令的解释:
-
•
seq 1 200
: 生成从 1 到 200 的数字序列。 -
•
|
: 管道符,将前一个命令的输出作为后一个命令的输入。 -
•
xargs
: 将输入转换为命令行参数。 -
•
-n1
: 一次处理一个输入项。 -
•
-P200
: 最多并行运行 200 个进程。 -
•
curl
: 发起 HTTP 请求的命令。 -
•
-s
: 静默模式(不显示进度条或错误信息)。 -
•
"http://.../traditional"
: 要测试的目标 URL。
-
3. 测试虚拟线程
使用下面的命令模拟 5000 个并发请求。所有请求应该都会成功。
# 测试5000个并发请求(应该都能成功)
seq 1 5000 | xargs -n1 -P5000 curl -s "http://localhost:8080/api/test/virtual"
预期结果
-
• 传统线程
-
• 最多同时处理 10 个并发请求,外加 100 个在队列中等待的请求。
-
• 超出 110 个的并发请求将会失败或超时。
-
• 日志中看到的线程名将是 "traditional-1" 到 "traditional-10"。
-
• 在大量并发请求下,内存使用率会显著增高。
-
-
• 虚拟线程
-
• 可以轻松处理数千个并发请求。
-
• 没有失败或超时。
-
• 日志中看到的线程信息将类似于
VirtualThread[#ID]/runnable@ForkJoinPool-1-worker-1
。 -
• 内存开销极小。
-
理解结果
-
• 为什么传统线程会失败?
-
• 每个传统线程都消耗大量内存。
-
• 受到操作系统线程数量的限制。
-
• 阻塞操作会“霸占”整个线程,使其无法处理其他任务。
-
• 等待队列的容量也限制了能处理的并发请求总数。
-
-
• 为什么虚拟线程能成功?
-
• 它是 JVM 管理的轻量级实现。
-
• 能高效地处理阻塞操作:当虚拟线程遇到阻塞(如
sleep
或网络 I/O)时,JVM 会自动挂起它,并让底层的 OS 线程去执行其他任务,而不是空等。 -
• 几乎不受物理资源的限制,可以大量创建。
-
• 非常适合 I/O 密集型应用。
-
最佳实践
-
• 建议使用虚拟线程的场景:
-
• I/O 密集型应用 (例如,调用外部 API、数据库查询、文件读写)。
-
• 需要处理高并发请求的场景。
-
• 微服务架构中,需要同时处理大量并发请求的服务。
-
• 应用中包含大量阻塞操作的。
-
-
• 建议使用传统线程的场景:
-
• CPU 密集型任务 (例如,复杂的数学计算、数据加密/解密)。因为虚拟线程并不会提高 CPU 密集型任务的性能,这类任务需要与 CPU 核心数匹配的线程数才能最高效。
-
• 需要使用线程本地存储 (
ThreadLocal
) 并且依赖其与平台线程绑定的特性的场景 (虚拟线程对ThreadLocal
的支持有限,且可能在不同 OS 线程上恢复执行)。 -
• 与不支持虚拟线程的遗留代码或原生库 (JNI) 交互时。
-
常见陷阱 (Common Pitfalls)
-
• 未使用 Java 21 或更高版本:虚拟线程是 Java 21 的正式功能。
-
• 在不理解其影响的情况下混合使用虚拟线程和传统线程:例如,在虚拟线程中调用一个
synchronized
同步块可能会“钉住 (pin)”虚拟线程,使其无法被挂起,从而降低性能优势。 -
• 未正确处理线程中断:无论是哪种线程,都应妥善处理
InterruptedException
。 -
• 未监控线程使用情况和性能:应使用适当的工具来监控应用的线程行为,以确保其按预期工作。
结论
虚拟线程是 Java 并发模型的一次重大进步,尤其对于 I/O 密集型应用而言。通过理解传统线程与虚拟线程之间的差异,开发者可以在构建应用程序时,做出更明智的技术选型决策。