Java 虚拟线程 — 快速指南
Source: Dev.to
请提供您希望翻译的正文内容,我将把它完整地翻译成简体中文并保留原有的 Markdown 格式。
Source:
01 · 什么是虚拟线程
在 Project Loom 之前,Java 只有一种线程:平台线程(与操作系统内核线程 1:1 映射)。
Project Loom 引入了 虚拟线程,作为第二种轻量级线程类型。
- 平台线程 – 由操作系统调度,1:1 对应内核线程。
- 虚拟线程 – 由 JVM 在用户模式下调度,M:N 映射到内核线程。
两者都由 java.lang.Thread 表示。
关键特性
- 与平台(操作系统)线程相比极其轻量。
- 可以安全地创建数百万个虚拟线程。
- 让开发者能够编写简单的阻塞式代码,同时保持高度可伸缩性。
思维模型
| 虚拟线程 | 承载(OS)线程 |
|---|---|
| JVM 管理的轻量级线程(约 1 KB) | 真正的操作系统线程 |
| 可以创建数百万个 | 小而固定的线程池(≈ CPU 核心数) |
| 阻塞成本低且安全 | 真正稀缺的资源 |
阻塞对比
OS 线程阻塞
RestTemplate blocks an OS thread
- Thread is idle during I/O
- Under load → thread exhaustion
虚拟线程阻塞
- JVM suspends the virtual thread
- Carrier thread is released immediately
- Scales safely under high concurrency
02 · 在 Spring Boot 中启用
一个属性。无需代码更改。
# application.yml
server:
servlet:
threads:
virtual-threads-enabled: true
要求
- Java 21+(正式版,不是预览版)
- Spring Boot 3.2+
有哪些变化
- 每个 HTTP 请求在一个全新的虚拟线程上运行。
- 控制器、服务、
RestTemplate等保持不变。
virtual-threads-enabled: true 实际做了什么
它用 每请求一个虚拟线程的执行器 替换了 Tomcat 整个 servlet 线程池。每个进入的 HTTP 请求都会立即分配一个新的虚拟线程。没有 固定的池大小——Tomcat 不会对线程数设上限,线程由 JVM 管理。
后果
- 整个请求生命周期(从
DispatcherServlet进入到响应写回)都在虚拟线程上运行。 - 链路中的每个阻塞调用(
RestTemplate、JDBC、Thread.sleep())都很轻量,因为它们已经在虚拟线程上执行。
手动卸载方案
Tomcat 的默认 OS 线程池负责 accept 和 dispatch,然后显式地将工作交给虚拟线程执行器(例如通过 CompletableFuture.supplyAsync)。接受请求的 OS 线程会立即被释放。
Spring MVC 知道如何处理返回的 CompletableFuture:
- 它 挂起 servlet 处理。
- 当 future 完成时 恢复 处理。
关键点: Spring MVC 在等待 future 完成时 不会阻塞 servlet 线程;它在内部注册回调并立即释放线程。
在两种方案中,服务层和客户端层都保持不变。
Source:
03 · Spring Boot 中的虚拟线程采用策略
背景
现有的 Spring Boot 微服务:
- 使用 Spring MVC(Servlet 栈)处理传入的 HTTP 请求。
- 使用阻塞客户端(
RestTemplate、JDBC)调用多个下游服务。
约束条件
- 当前实现 不能更改或重写。
- 代码库中包含
synchronized块/方法,并且大量依赖ThreadLocal(SecurityContext、MDC、请求属性)。 - 服务执行 I/O 密集型聚合,跨多个下游服务。
- 由于线程阻塞,在负载下出现可扩展性问题。
目标: 在不破坏现有行为或引入细微运行时风险的前提下,使用 Java 虚拟线程 提升并发性和可扩展性。
Spring Boot 提供两种可选方案:
- 基于属性的虚拟线程(
spring.threads.virtual.enabled=true)。 - 手动卸载 到使用
CompletableFuture的虚拟线程执行器。
方案 1:基于属性的虚拟线程(全局启用)
描述
启用
spring.threads.virtual.enabled=true
会用 每请求一个虚拟线程 的执行器替换 Tomcat 的 servlet 线程池。
每个 HTTP 请求:
- 分配一个全新的虚拟线程。
- 完全在该虚拟线程上运行(过滤器 → 控制器 → 服务 → 响应)。
- 以低成本执行阻塞调用(
RestTemplate、JDBC、Thread.sleep)。
好处
- 无需代码改动。
- 整个应用行为统一。
- 对阻塞 I/O 自动实现可扩展性。
风险与限制
钉住风险
synchronized 块会 钉住载体线程。钉住是不可见且全局的;并发访问可能耗尽有限的载体线程池。
[Virtual Thread] → synchronized block ← carrier pinned → blocking I/O
如果有 N 个并发请求进入钉住区段,则需要 N 个载体线程。由于载体线程数量仅约等于 CPU 核数,应用可能会卡死。
ThreadLocal 假设失效
- 虚拟线程生命周期短,且不会在请求之间复用。
ThreadLocal中的数据 不会 超出单个请求的范围。
依赖线程复用(例如在 ThreadLocal 中缓存数据)的代码可能出现异常。
缺乏控制
- 没有隔离边界——无法有选择地排除特定端点或代码路径。
- 解决问题可能需要全局重启应用,因为更改是全局性的。
方案 2:手动卸载到虚拟线程执行器
描述
保留 Tomcat 的 OS 线程池用于接受/分发请求,但显式将阻塞工作卸载到虚拟线程执行器,例如:
CompletableFuture.supplyAsync(() -> {
// 阻塞 I/O、JDBC、RestTemplate 等
}, Executors.newVirtualThreadPerTaskExecutor());
Spring MVC 已经能够处理返回的 CompletableFuture:
- 它会挂起 servlet 处理。
- 注册回调,在 future 完成时恢复处理。
好处
- 细粒度控制——仅将真正需要的部分卸载。
- 依赖
synchronized或ThreadLocal的关键代码仍然在 OS 线程上运行,避免钉住和 ThreadLocal 问题。
缺点
- 需要在阻塞发生的边界处进行代码修改。
- 相比基于属性的方案更具侵入性;必须审计并包装所有阻塞调用。
选择采纳路径
| Criteria | Property‑Based (Global) | Manual Offload |
|---|---|---|
| Code changes required | None | Yes (wrap blocking calls) |
| Control over which code runs on virtual threads | None (all requests) | Selective |
Impact on existing synchronized / ThreadLocal usage | Potentially risky (pinning, loss of ThreadLocal state) | Can keep critical sections on OS threads |
| Operational simplicity | Very simple (single property) | More complex (executor management) |
| Risk of carrier‑thread exhaustion | Higher (global pinning) | Lower (only offloaded parts) |
Recommendation
- 如果代码库中
synchronized块很少且不大量依赖ThreadLocal状态,基于属性的方法提供了最快的可扩展性路径。 - 如果应用程序大量使用
synchronized区段、基于 ThreadLocal 的安全上下文或其他线程亲和性模式,手动卸载策略更安全,能够将虚拟线程的使用隔离到真正的阻塞 I/O 路径。
快速参考
# Enable virtual threads globally (Spring Boot 3.2+)
server:
servlet:
threads:
virtual-threads-enabled: true
// Manual offload example
Executor vtExecutor = Executors.newVirtualThreadPerTaskExecutor();
CompletableFuture<String> result = CompletableFuture.supplyAsync(() -> {
// Blocking call (e.g., RestTemplate, JDBC)
return restTemplate.getForObject(url, String.class);
}, vtExecutor);
要点: 虚拟线程可以显著提升阻塞密集型 Spring Boot 服务的可扩展性,但你必须权衡全局启用的简便性与承载线程固定(carrier‑thread pinning)以及 ThreadLocal 误用的潜在风险。请选择与代码库的并发特性和运营约束相匹配的策略。
Source: …
UI‑Res 重写同步和 ThreadLocal‑依赖代码
选项 2:手动卸载到虚拟线程(选择性采用)
描述
- Tomcat 继续使用其默认的 OS‑thread servlet pool。
- 现有代码在 OS 线程上保持不变运行。
- 对 I/O 密集型逻辑显式使用
CompletableFuture.supplyAsync(task, virtualThreadExecutor);
进行卸载。
- Spring MVC
- 原生支持
CompletableFuture返回类型。 - 挂起请求处理,立即释放 servlet 线程,并在 future 完成时恢复。
- 原生支持
ThreadLocal 考虑
卸载会产生一个 硬线程边界。诸如以下上下文:
SecurityContext- MDC 跟踪数据
不会自动传播,必须手动捕获并恢复。此边界是显式且受控的。
好处
- 保持现有假设(
synchronized、ThreadLocal)。 - 避免在旧代码中出现承载线程固定(carrier‑thread pinning)。
- 仅在有益的地方有针对性地使用虚拟线程。
- 支持增量迁移。
- OS 线程与虚拟线程执行之间的隔离清晰。
权衡
- 需要稍多的样板代码。
- 必须显式进行上下文传播。
- 虚拟线程的使用必须有意识地进行。
结果
| 正面 | 负面 |
|---|---|
| 对 I/O 密集型端点的可扩展性提升。 | 上下文传播的额外样板代码。 |
| 无需重构现有的同步代码。 | 需要遵守卸载边界的纪律。 |
| 运行时行为可预测。 | |
| 迁移路径清晰。 |
最终结论
基于属性的虚拟线程方法仅适用于已经对虚拟线程友好的代码库。
对于本系统,手动卸载是最安全、最有效的策略,它在保留正确性和运营稳定性的同时,提供了虚拟线程的优势。
Source: …
03 · 陷阱(生产前必读)
synchronized 将载体线程钉住
问题
- 虚拟线程会被载体线程黏住。
9个并发请求 +8个载体 → 死锁。
解决方案:使用 ReentrantLock
// Pins carrier
public synchronized Product fetch(String id) {
return restTemplate.getForObject("/p/{id}", Product.class, id);
}
// Safe
private final ReentrantLock lock = new ReentrantLock();
public Product fetch(String id) {
lock.lock();
try {
return restTemplate.getForObject("/p/{id}", Product.class, id);
} finally {
lock.unlock();
}
}
手动 offload 时 ThreadLocal 上下文丢失
会失效的内容
- MDC
SecurityContextRequestAttributes
解决方案:捕获并恢复上下文
Map<String, String> mdc = MDC.getCopyOfContextMap();
SecurityContext sec = SecurityContextHolder.getContext();
return CompletableFuture.supplyAsync(() -> {
if (mdc != null) MDC.setContextMap(mdc);
SecurityContextHolder.setContext(sec);
try {
return service.doWork();
} finally {
MDC.clear();
SecurityContextHolder.clearContext();
}
}, ioExecutor);
💡 当全局使用 virtual-threads-enabled: true 时,此问题 不存在。
使用池化执行器导致 ThreadLocal 泄漏
问题
- 将执行器换成固定线程池会导致 ThreadLocal 在请求之间泄漏。
解决方案:强制使用正确的执行器
@Bean
public Executor ioExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
本地(JNI)调用会悄悄钉住线程
示例
- 某些 JDBC 驱动
- 加密库
限制影响范围
private static final ExecutorService NATIVE_POOL =
Executors.newFixedThreadPool(10);
public Future<String> callNative(String input) {
return NATIVE_POOL.submit(() -> nativeLib.process(input));
}
启用线程钉住日志(仅开发环境)
-XX:+PrintVirtualThreadPinning
MVC 与 WebFlux 同时使用
如果两个 starter 都存在,Spring 会选择 MVC,且不会给出警告。
规则
- 使用虚拟线程 → 保留
spring-boot-starter-web。 - 移除
starter-webflux。
在虚拟线程上进行 CPU 密集型工作
反模式
- 重度计算
- 图像处理
- 加密循环
正确的划分方式
// I/O work
CompletableFuture.supplyAsync(
() -> restTemplate.getForObject(...),
Executors.newVirtualThreadPerTaskExecutor());
// CPU work
CompletableFuture.supplyAsync(
() -> heavyComputation(data),
ForkJoinPool.commonPool());
关键结论
虚拟线程是 阻塞的、I/O‑密集型且无法重写的 Spring Boot 服务 的最佳选择。它们为您提供可扩展性、简洁性和生产安全性——无需响应式的复杂性。