Java 21 虚拟线程实战:Spring Boot 并发性能、线程固定与排障指南
虚拟线程不是让单个请求执行得更快,而是让大量阻塞式请求以更低的线程成本并发执行。本文通过一个可运行的 Spring Boot 示例,说明虚拟线程适合什么场景、如何启用、怎样压测,以及为什么
synchronized、数据库连接池和错误的性能预期会让优化失效。
本文示例基于 Java 21 + Spring Boot 3.5.x。
一、虚拟线程解决的到底是什么问题?
传统 Spring MVC 通常采用“一个请求对应一个平台线程”的模型。请求等待数据库、Redis 或远程 HTTP 响应时,线程虽然没有计算,却仍占用操作系统线程资源。并发升高后,线程数量、栈内存和上下文切换都会成为成本。
虚拟线程仍采用易于理解的同步编程方式,但由 JVM 调度,大量虚拟线程可以复用少量平台线程:
HTTP 请求 → 虚拟线程 → 执行业务代码
↓ 遇到可挂起的阻塞操作
JVM 卸载虚拟线程
↓
平台线程执行其他任务
因此它更适合:
- 请求主要等待 JDBC、HTTP、文件等阻塞式 I/O;
- 并发量较高,同时希望保留同步代码风格;
- 线程池已经成为吞吐量瓶颈。
它不擅长纯 CPU 密集型任务。视频编码、复杂加密或大规模计算的上限仍由 CPU 核数决定。
二、在 Spring Boot 中启用虚拟线程
项目至少需要 Java 21:
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
在 application.yml 中开启:
spring:
threads:
virtual:
enabled: true
main:
keep-alive: true
keep-alive 值得保留。虚拟线程是守护线程,如果应用只剩守护线程,JVM 可以直接退出,这对依赖 @Scheduled 维持运行的程序尤其重要。
编写一个模拟下游 I/O 的接口:
@RestController
@RequestMapping("/virtual-thread")
public class VirtualThreadController {
@GetMapping("/io")
public Map<String, Object> io() throws InterruptedException {
Thread.sleep(Duration.ofMillis(200)); // 模拟数据库或 HTTP 等待
Thread thread = Thread.currentThread();
return Map.of(
"thread", thread.toString(),
"virtual", thread.isVirtual()
);
}
}
访问接口后,virtual 应为 true。不要只根据线程名称判断,Thread.isVirtual() 更可靠。
三、虚拟线程为什么能提升吞吐量?
假设每个请求只计算 5 毫秒,却等待下游 195 毫秒。传统线程在 195 毫秒等待期内无法处理其他请求;虚拟线程阻塞时通常会被卸载,承载它的平台线程可以继续运行别的虚拟线程。
可以用 JDK 自带 API 做一个简化实验:
public class ThreadBenchmark {
private static final int TASKS = 10_000;
public static void main(String[] args) throws Exception {
run(Executors.newFixedThreadPool(200), "platform-200");
run(Executors.newVirtualThreadPerTaskExecutor(), "virtual");
}
private static void run(ExecutorService executor, String name)
throws Exception {
long start = System.nanoTime();
try (executor) {
List<Future<?>> futures = IntStream.range(0, TASKS)
.mapToObj(i -> executor.submit(() -> {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}))
.toList();
for (Future<?> future : futures) {
future.get();
}
}
long millis = Duration.ofNanos(System.nanoTime() - start).toMillis();
System.out.printf("%s: %d ms%n", name, millis);
}
}
这个实验只证明“等待型任务能容纳更多并发”,不能代替业务压测。真实系统还受数据库连接数、下游限流、内存和网络带宽约束。
四、不要用线程池限制虚拟线程数量
平台线程昂贵,因此过去常用固定线程池控制数量。虚拟线程本身很轻量,再把它们放入固定大小的线程池,往往等于主动取消其优势。
真正需要限制的是稀缺资源。例如下游接口最多允许 100 个并发,可以使用信号量:
@Component
public class LimitedClient {
private final Semaphore permits = new Semaphore(100);
public String call() throws InterruptedException {
permits.acquire();
try {
return invokeRemoteService();
} finally {
permits.release();
}
}
private String invokeRemoteService() throws InterruptedException {
Thread.sleep(100);
return "ok";
}
}
这表达的是“保护下游资源”,而不是“因为线程太贵而复用线程”。数据库同样受连接池约束:即使创建十万个虚拟线程,只有 50 个数据库连接时,也只能同时执行约 50 个 SQL。
五、最危险的陷阱:Pinned Virtual Thread
虚拟线程执行阻塞操作时,理想情况是从载体线程上卸载。如果它在某些不能安全卸载的代码区域发生阻塞,就可能固定在载体线程上。载体线程无法服务其他虚拟线程,吞吐量会下降。
典型反例是在 synchronized 代码块内执行慢 I/O:
public class BadOrderService {
private final Object lock = new Object();
public String queryOrder() throws InterruptedException {
synchronized (lock) {
Thread.sleep(500); // 用慢调用模拟远程 I/O
return "order";
}
}
}
改进原则是缩小锁的范围,不要持锁执行网络或数据库操作:
public String queryOrder() throws InterruptedException {
String result = callRemoteOrderService();
synchronized (lock) {
updateLocalState(result);
}
return result;
}
如果确实需要在可中断等待期间加锁,可以评估 ReentrantLock。但不能机械地替换全部 synchronized;先通过监控找到真实热点。
六、用 JFR 和 jcmd 定位线程固定
生产排障时可以开启 Java Flight Recorder:
java -XX:StartFlightRecording=filename=app.jfr,duration=60s \
-jar app.jar
也可以对运行中的 JVM 发起录制:
jcmd <pid> JFR.start name=virtual-thread settings=profile duration=60s filename=app.jfr
使用 JDK Mission Control 打开文件,重点查看虚拟线程固定事件、事件堆栈及持续时间。排查顺序建议如下:
- 找到持续时间最长、出现频率最高的固定事件;
- 沿堆栈定位业务锁、第三方驱动或本地方法;
- 判断阻塞是否发生在锁内部;
- 修改后用相同流量模型复测 P95、P99 和吞吐量。
仅看到一次极短事件不代表系统存在问题。性能优化应以业务指标为判断依据。
七、上线前必须回答的五个问题
- 当前瓶颈真的是平台线程,而不是数据库连接池吗?
- 请求以阻塞式 I/O 为主,还是以 CPU 计算为主?
- 下游服务允许多大并发,是否设置超时和限流?
- 依赖库在虚拟线程下是否存在长时间固定问题?
- 是否用相同机器、数据和流量模型完成前后对比?
建议至少记录吞吐量、P50/P95/P99、错误率、CPU、堆内存、数据库连接池等待时间和下游并发数。只比较平均响应时间,很容易得出错误结论。
八、总结
虚拟线程的核心价值不是“神奇加速”,而是降低阻塞式并发模型的线程成本。Spring Boot 中开启它只需要一项配置,真正困难的是资源治理:数据库连接依然有限,下游仍需限流,持锁阻塞仍可能固定载体线程。
正确做法是先定位瓶颈,再启用虚拟线程,通过 JFR 排查固定问题,并用真实压测验证收益。如果业务主要是高并发阻塞式 I/O,又希望继续使用清晰的同步代码,虚拟线程通常值得优先评估。
更多推荐


所有评论(0)