Java 虚拟线程:实践指南
1. 总体概述
虚拟线程是 Java 21(JEP 444)的官方功能,它让你能够编写同步阻塞式的代码,同时仍然能获得与非阻塞 I/O 相当的高吞吐量。这篇文章从基础概念、如何创建虚拟线程、与平台线程的性能对比,到如何集成到 Spring Boot 进行讲解。看完后,你将知道何时应该使用虚拟线程,何时不应该使用。
2. 传统平台线程的问题
Java 中的每个平台线程都与一个操作系统线程 1:1 映射。操作系统线程耗费内存(通常 1MB 的栈),创建和上下文切换都有很高的开销。因此,处理数万个并发请求(例如等待 I/O 的服务器)的应用通常会受到可创建的线程池大小的限制。
ExecutorService executor = Executors.newFixedThreadPool(200);
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
try {
Thread.sleep(1000); // 模拟 I/O 调用
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
executor.shutdown();
对于有 200 个线程的线程池,10,000 个任务必须排队等待。由于每个线程一次只能处理一个任务,完成时间就会被拉长。更糟的是,大部分时间是等待(阻塞),而不是计算。
3. 虚拟线程是什么
虚拟线程是由 JVM 管理的线程(不是操作系统线程),只在执行代码时才挂载到一个平台线程(称为载体线程)上。当虚拟线程调用阻塞操作(I/O、Thread.sleep、锁…)时,JVM 会自动将其从载体线程上“卸载”,释放载体线程去处理其他虚拟线程。
结果是:你可以创建数百万个虚拟线程而无需担心资源耗尽,同时代码仍然以熟悉的阻塞式风格编写——不需要 CompletableFuture,也不需要响应式编程。
3.1. 创建简单的虚拟线程
Thread vThread = Thread.ofVirtual()
.name("order-processor")
.start(() -> {
System.out.println("运行在:" + Thread.currentThread());
});
vThread.join();
Thread.ofVirtual() 返回一个构建器来配置虚拟线程。.start() 创建并立即启动它。打印出来的线程会是这样的形式:
运行在:VirtualThread[#21,order-processor]/runnable@ForkJoinPool-1-worker-1
注意 @ForkJoinPool-... 部分——那是“载着”这个虚拟线程的载体线程。
4. 为虚拟线程设计的执行器
在实际应用中使用虚拟线程最常见的方式是通过 ExecutorService。
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
int id = i;
futures.add(executor.submit(() -> {
Thread.sleep(1000); // 模拟 I/O 调用(数据库、HTTP…)
return "任务 " + id + " 完成";
}));
}
for (Future<String> f : futures) {
f.get();
}
}
newVirtualThreadPerTaskExecutor() 为每个任务创建一个新的虚拟线程,而不像传统线程池那样重用线程。由于创建虚拟线程的成本非常低(几百字节,而不是 1MB),这完全是合理的。try-with-resources 块确保执行器被正确关闭,在退出块时等待所有任务完成。
与第 2 节的例子(200 个平台线程的线程池)相比,这段代码在大约 1 秒内完成全部 10,000 个任务(因为没有线程池限制),而不是要排队多个轮次。
5. 通过实际测试对比性能
class VirtualThreadBenchmarkTest {
@Test
void virtualThreadsShouldHandleManyBlockingTasksFast() throws InterruptedException {
int taskCount = 10_000;
CountDownLatch latch = new CountDownLatch(taskCount);
Instant start = Instant.now();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < taskCount; i++) {
executor.submit(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
});
}
}
latch.await();
Duration elapsed = Duration.between(start, Instant.now());
assertThat(elapsed).isLessThan(Duration.ofSeconds(5));
}
}
这个测试创建了 10,000 个任务,每个任务睡眠 100 毫秒。如果按顺序运行将耗时 1000 秒,但使用虚拟线程,所有任务可以在几秒内完成,因为任务真正并行运行。CountDownLatch 用来等待所有任务完成后再进行断言。
**注意:**真实的基准测试应该使用 JFR(Java Flight Recorder)额外测量 CPU 和内存,而不仅仅是计时,因为时间还取决于运行测试的机器。
6. 陷阱:使用 synchronized 时的钉扎
如果虚拟线程在 synchronized 块内,它将不会从载体线程卸载——这种现象称为钉扎(pinning)。在这种情况下,synchronized 内的阻塞 I/O 将完全占用载体线程,失去虚拟线程的优势。
public class InventoryService {
private final Object lock = new Object();
public void reserveStock(String sku) {
synchronized (lock) {
callExternalInventoryApi(sku); // synchronized 内的阻塞 I/O -> 钉扎!
}
}
}
synchronized (lock) 这一行在整个调用 callExternalInventoryApi 的期间都会占用载体线程,即使这是一个 I/O 操作。如果数千个虚拟线程同时钉扎,载体线程池(默认为 CPU 核心数)将很快被耗尽。
解决方案:用 java.util.concurrent.locks.ReentrantLock 替代 synchronized,它与虚拟线程兼容(不会导致钉扎)。
public class InventoryService {
private final ReentrantLock lock = new ReentrantLock();
public void reserveStock(String sku) {
lock.lock();
try {
callExternalInventoryApi(sku);
} finally {
lock.unlock();
}
}
}
ReentrantLock 允许虚拟线程在遇到阻塞 I/O 时正常卸载,保持虚拟线程的“轻量级”特性。
7. 将虚拟线程集成到 Spring Boot
从 Spring Boot 3.2 开始,你只需启用一个属性,Spring Web(Tomcat)就会为每个请求使用虚拟线程。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
spring.threads.virtual.enabled=true
这个属性为 Tomcat(以及 Spring Boot 中其他支持的执行器)启用 virtualThreadExecutor。每个传入的 HTTP 请求都将在单独的虚拟线程上处理,而不是共享固定的平台线程池(Tomcat 的默认值是 200 个线程)。
@RestController
@RequestMapping("/orders")
class OrderController {
private final OrderService orderService;
OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
return orderService.fetchOrderWithDetails(id); // 调用数据库和其他 API,同步阻塞
}
}
控制器代码无需任何更改——你仍然像以前一样编写阻塞式代码。当使用 ab 或 k6 等工具进行负载测试时,对于有高 I/O 延迟的端点(数据库调用、调用其他服务),你会看到吞吐量显著增加,因为 Tomcat 不再受到默认 200 个线程的限制。
8. 何时不应该使用虚拟线程
- CPU 密集型任务:如果你的工作主要是计算(没有 I/O 等待),虚拟线程没有任何帮助——载体线程数仍然受 CPU 核心数限制。改用
ForkJoinPool或parallelStream()更合适。 - 大量使用
ThreadLocal的代码:虚拟线程与ThreadLocal兼容,但如果创建数百万个虚拟线程,每个都持有大量的ThreadLocal数据,会消耗大量内存——考虑改用ScopedValue(Java 21 之后的预览功能)。 - 使用
synchronized较多的老库:如第 6 节所述,钉扎可能会无声地降低性能,而你可能一开始没有意识到。 - 连接池受限:虚拟线程帮助你创建许多并行请求,但如果数据库连接池只有 20 个连接,你仍然会在那里受到瓶颈限制——需要相应增加池大小或使用支持高并发的连接池。
注意:虚拟线程解决的是数量问题(线程数量),而不是速度问题(每个任务运行有多快)。不要期望它能让 CPU 密集型代码运行更快。
9. 总结
虚拟线程帮助你编写简单的阻塞式代码,同时仍能很好地扩展到处理数千个并发请求,特别适合 I/O 密集型应用,如调用许多 API/数据库的 Web 服务和微服务。你只需要更改执行器或在 Spring Boot 中启用一个属性,无需用响应式风格重写逻辑。不过要注意 synchronized 导致的钉扎陷阱,并记住虚拟线程对 CPU 密集型任务无济于事。