Java 虚拟线程 — 快速指南

发布: (2026年2月1日 GMT+8 20:12)
13 min read
原文: Dev.to

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

  1. 挂起 servlet 处理。
  2. 当 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 提供两种可选方案:

  1. 基于属性的虚拟线程spring.threads.virtual.enabled=true)。
  2. 手动卸载 到使用 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 完成时恢复处理。

好处

  • 细粒度控制——仅将真正需要的部分卸载。
  • 依赖 synchronizedThreadLocal 的关键代码仍然在 OS 线程上运行,避免钉住和 ThreadLocal 问题。

缺点

  • 需要在阻塞发生的边界处进行代码修改。
  • 相比基于属性的方案更具侵入性;必须审计并包装所有阻塞调用。

选择采纳路径

CriteriaProperty‑Based (Global)Manual Offload
Code changes requiredNoneYes (wrap blocking calls)
Control over which code runs on virtual threadsNone (all requests)Selective
Impact on existing synchronized / ThreadLocal usagePotentially risky (pinning, loss of ThreadLocal state)Can keep critical sections on OS threads
Operational simplicityVery simple (single property)More complex (executor management)
Risk of carrier‑thread exhaustionHigher (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 跟踪数据

不会自动传播,必须手动捕获并恢复。此边界是显式且受控的。

好处

  • 保持现有假设(synchronizedThreadLocal)。
  • 避免在旧代码中出现承载线程固定(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
  • SecurityContext
  • RequestAttributes

解决方案:捕获并恢复上下文

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 服务 的最佳选择。它们为您提供可扩展性、简洁性和生产安全性——无需响应式的复杂性。

Back to Blog

相关文章

阅读更多 »

Spring Boot 异常处理

Java 与 Spring Boot 异常处理笔记 1. 什么是 Exception? Exception = 打破程序正常流程的不期望情况。 异常处理的目标……

开启 RUST

我的 Java 到 Rust 的转变之路:更换 technology stack 你好,我叫 Garik,今天我想与大家分享我决定更换 technology stack 的故事。