Java 21正式引入虚拟线程(Virtual Thread),Spring Boot 3.2+提供开箱即用的虚拟线程支持。虚拟线程由JVM调度而非操作系统,创建和切换成本远低于平台线程。本文通过代码实例分析虚拟线程在Spring Boot中的配置方法和高并发场景下的性能表现。
Java虚拟线程与平台线程的调度差异
传统Java线程映射到操作系统线程(1:1模型),每个线程占用约1MB栈空间,创建和上下文切换需要内核态操作。虚拟线程采用M:N调度模型——多个虚拟线程复用少量载体线程(carrier thread),由JVM在用户态完成调度切换。
// 虚拟线程基础创建方式
// 方式1:直接创建
Thread vt = Thread.ofVirtual().start(() -> {
System.out.println("Running on virtual thread: " + Thread.currentThread());
});
// 方式2:通过ExecutorService
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// 提交10000个任务,每个任务一个虚拟线程
IntStream.range(0, 10000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
});
});
} // try-with-resources自动等待所有任务完成
上面代码创建10000个虚拟线程每个休眠1秒,总执行时间约1秒。同样的任务用平台线程池(Executors.newFixedThreadPool(200))需要约50秒。虚拟线程的I/O等待不会阻塞载体线程——当虚拟线程执行阻塞操作时JVM自动卸载(unmount)该虚拟线程,释放载体线程执行其他虚拟线程任务。
Spring Boot 3虚拟线程配置与Tomcat集成
Spring Boot 3.2+只需一行配置启用虚拟线程,Tomcat将为每个HTTP请求分配一个虚拟线程而非从线程池获取:
# application.yml
spring:
threads:
virtual:
enabled: true
# Tomcat虚拟线程模式下的连接配置
tomcat:
threads:
max: 200 # 载体线程数(ForkJoinPool大小)
max-connections: 8192
accept-count: 100
# Java版本要求
# pom.xml
<properties>
<java.version>21</java.version>
</properties>
启用后验证效果——定义一个阻塞I/O的Controller:
@RestController
@RequestMapping("/api")
public class DataController {
private final ExternalApiService apiService;
private final UserRepository userRepository;
public DataController(ExternalApiService apiService,
UserRepository userRepository) {
this.apiService = apiService;
this.userRepository = userRepository;
}
@GetMapping("/users/{id}")
public UserDto getUser(@PathVariable Long id) {
// 虚拟线程模式下,这些阻塞调用不会占用载体线程
User user = userRepository.findById(id)
.orElseThrow(() -> new NotFoundException("User not found"));
// 外部API调用(阻塞300ms)
UserProfile profile = apiService.fetchProfile(user.getExternalId());
// 另一个数据库查询(阻塞50ms)
List<Order> orders = userRepository.findOrdersByUserId(id);
return new UserDto(user, profile, orders);
}
@PostMapping("/batch")
public List<UserDto> batchGetUsers(@RequestBody List<Long> ids) {
// 使用StructuredTaskScope并行获取用户数据
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
List<StructuredTaskScope.Subtask<UserDto>> subtasks = ids.stream()
.map(id -> scope.fork(() -> getUser(id)))
.toList();
scope.join(); // 等待所有子任务完成
scope.throwIfFailed(); // 任一失败则抛出异常
return subtasks.stream()
.map(StructuredTaskScope.Subtask::get)
.toList();
}
}
}
@Async异步方法与虚拟线程的协作模式
启用虚拟线程后,@Async方法也使用虚拟线程执行。配合ThreadPoolTaskExecutor的虚拟线程配置:
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("virtualThreadExecutor")
public AsyncTaskExecutor virtualThreadExecutor() {
// Spring 6.1+ 提供的虚拟线程执行器
return new TaskExecutorAdapter(
Executors.newVirtualThreadPerTaskExecutor()
);
}
}
@Service
public class NotificationService {
private final EmailSender emailSender;
private final SmsSender smsSender;
@Async("virtualThreadExecutor")
public CompletableFuture<Void> sendNotification(User user, String message) {
// 邮件和短信并行发送,各自占用独立虚拟线程
CompletableFuture<Void> emailFuture = CompletableFuture.runAsync(
() -> emailSender.send(user.getEmail(), message)
);
CompletableFuture<Void> smsFuture = CompletableFuture.runAsync(
() -> smsSender.send(user.getPhone(), message)
);
return CompletableFuture.allOf(emailFuture, smsFuture);
}
}
虚拟线程性能基准测试与踩坑指南
使用wrk对/user/{id}接口做压力测试,对比平台线程和虚拟线程表现:
# 平台线程模式(线程池200)
wrk -t8 -c2000 -d30s http://localhost:8080/api/users/1
# 结果:QPS 1,820,平均延迟 1.08s,P99 3.2s
# 虚拟线程模式
wrk -t8 -c2000 -d30s http://localhost:8080/api/users/1
# 结果:QPS 5,840,平均延迟 0.34s,P99 0.82s
2000并发连接下虚拟线程模式QPS提升3.2倍。原因是平台线程池(200个线程)在2000并发时大量请求排队,而虚拟线程为每个请求创建独立线程,I/O等待期间载体线程可处理其他请求。
虚拟线程有一个核心限制:synchronized代码块内执行的阻塞操作不会卸载虚拟线程。在synchronized块中调用阻塞I/O会导致载体线程被钉住(pinning),失去虚拟线程的并发优势。解决方案是使用ReentrantLock替代synchronized:
// 有问题的代码 - synchronized会导致线程pinning
public synchronized User getFromCache(String key) {
User cached = cacheMap.get(key);
if (cached == null) {
cached = fetchFromDatabase(key); // 阻塞操作在synchronized内 - pinning!
cacheMap.put(key, cached);
}
return cached;
}
// 修复方案 - 使用ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
public User getFromCache(String key) {
lock.lock();
try {
User cached = cacheMap.get(key);
if (cached == null) {
cached = fetchFromDatabase(key); // 阻塞操作不pinning
cacheMap.put(key, cached);
}
return cached;
} finally {
lock.unlock();
}
}
检测pinning问题在JVM启动参数加-Djdk.tracePinnedThreads=full,运行时控制台会打印pinning发生的位置和栈信息。生产环境推荐切换ReentrantLock并移除synchronized,这是迁移虚拟线程最常见的代码重构工作。ThreadLocal在虚拟线程中仍可用但需注意内存——虚拟线程数量可能达到百万级,ThreadLocal变量会随虚拟线程生命周期持有,建议使用Scoped Values替代。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/springboot3-xu-ni-xian-cheng-gao-bing-fa-chu-li-yu-xie/