Java 虚拟线程 — 快速指南

发布: (2026年2月1日 GMT+8 20:12)
13 分钟阅读
原文: Dev.to

看起来您只提供了来源链接,而没有贴出需要翻译的正文内容。请把要翻译的文本(包括标题、段落、列表等)粘贴在这里,我会按照要求保留源链接和原始的 Markdown 格式进行简体中文翻译。谢谢!

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

为什么我们需要虚拟线程?

Source:

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() 等)都很轻量,因为它们已经在虚拟线程上运行。

手动卸载(Offload)方式

Tomcat 默认的 OS 线程池负责 accept 和 dispatch,然后显式地将工作交给虚拟线程执行器(例如通过 CompletableFuture.supplyAsync)。接受请求的 OS 线程会立即被释放。

Spring MVC 知道如何处理返回的 CompletableFuture

  1. 挂起 servlet 处理。
  2. 当 future 完成时 恢复 处理。

关键点: Spring MVC 不会阻塞 servlet 线程等待 future 完成;它在内部注册回调并立即释放线程。

在这两种方式下,服务层和客户端层都保持不变。

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. 手动 offload 到虚拟线程执行器,使用 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:手动 offload 到虚拟线程执行器

描述

保留 Tomcat 的 OS 线程池用于接受/分发请求,但显式将阻塞工作 offload 到虚拟线程执行器,例如:

CompletableFuture.supplyAsync(() -> {
    // 阻塞 I/O、JDBC、RestTemplate 等
}, Executors.newVirtualThreadPerTaskExecutor());

Spring MVC 已经能够处理返回的 CompletableFuture

  • 它会挂起 servlet 处理。
  • 注册回调,在 future 完成时恢复执行。

好处

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

缺点

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

选择采用路径

标准基于属性(全局)手动卸载
所需代码更改有(包装阻塞调用)
对哪些代码在虚拟线程上运行的控制无(所有请求)可选择性
对现有 synchronized / ThreadLocal 使用的影响可能有风险(固定、丢失 ThreadLocal 状态)可以在 OS 线程上保留关键区段
操作简易性非常简单(单一属性)更复杂(执行器管理)
载体线程耗尽的风险较高(全局固定)较低(仅卸载的部分)

建议

  • 如果代码库中的 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);

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 锁定。
  • 只在有益的地方有针对性地使用虚拟线程。
  • 支持增量迁移。
  • OS‑线程与虚拟线程执行之间的隔离清晰。

权衡

  • 需要稍多的样板代码。
  • 必须显式进行上下文传播。
  • 虚拟线程的使用必须有意识地进行。

后果

正面负面
提升 I/O 密集型端点的可扩展性。为上下文传播增加额外的样板代码。
无需重构现有的同步代码。需要严格遵守卸载边界的纪律。
运行时行为可预测。
迁移路径清晰。

最终结论

基于属性的虚拟线程方案仅适用于已经对虚拟线程友好的代码库。
对于本系统,手动卸载是最安全、最有效的策略,它在保留正确性和运营稳定性的同时,提供了虚拟线程的优势。

04 · 陷阱(生产前阅读)

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();
    }
}

手动卸载期间 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());

关键要点

Virtual Threads 是 阻塞的、I/O‑密集的、无法重写的 Spring Boot 服务 的最佳选择。
它们为您提供可扩展性、简洁性和生产安全性——无需响应式的复杂性。

Back to Blog

相关文章

阅读更多 »

Spring Boot 异常处理

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

开启 RUST

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