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 密集型应用。
项目设置
- 创建一个使用 Java 21 的新 Spring Boot 项目。
- 将以下依赖项添加到你的
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 // 启用异步方法执行
public class ThreadConfig {
// 定义传统线程池执行器
@Bean(name = "traditionalExecutor")
public Executor traditionalExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10); // 核心线程数:10
executor.setMaxPoolSize(10); // 最大线程数:10 (线程数量有限)
executor.setQueueCapacity(100); // 等待队列容量:100
executor.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")
public class TestController {
private static final Logger logger = 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 密集型应用而言。通过理解传统线程与虚拟线程之间的差异,开发者可以在构建应用程序时,做出更明智的技术选型决策。