虚拟线程与 GraalVM:Spring Boot 3.x 云原生性能跃迁

0 阅读10分钟

1. 引言:为什么 Spring Boot 3.x 值得重新审视性能

Spring Boot 3.x 不只是一次版本号升级。它把 Java 生态里两个酝酿多年的能力真正带进了日常开发:一是 Project Loom 带来的虚拟线程,二是 GraalVM 原生镜像(Native Image)配合 AOT 提前编译。前者解决的是「线程太贵」的问题,后者解决的是「启动太慢、内存太高」的问题——恰好对应云原生场景下最痛的两个点。

本文围绕 Spring Boot 3.2+ 展开,先讲虚拟线程的技术背景与一行配置的启用方式,再讨论它在 Web 请求、@Async、JDBC 中的自动适配与适用边界;随后进入 AOT 与 GraalVM 原生镜像,讲清依赖限制与构建流程;最后给出迁移实测数据与 2.x 升级到 3.x 的兼容性检查清单。

版本基线:Spring Boot 3.2.x / 3.3.x,JDK 21(虚拟线程正式可用),GraalVM for JDK 21。

全文的技术脉络可以先用一张图串起来:从平台线程的瓶颈出发,虚拟线程解决并发密度问题,AOT 与原生镜像解决启动与内存问题,最终落到迁移与升级路径。

flowchart TD
  A[平台线程瓶颈 内存 调度 数量上限] --> B[Project Loom 虚拟线程]
  B --> C[一行配置启用 springthreadsvirtualenabledtrue]
  C --> D[Web 请求 Async JDBC 自动适配]
  D --> E{业务类型判断}
  E -- IO 密集型 --> F[收益显著 吞吐提升]
  E -- CPU 密集型 --> G[收益有限 保持平台线程池]
  F --> H[AOT 提前编译]
  G --> H
  H --> I[GraalVM 原生镜像 启动毫秒级 内存下降]
  I --> J[迁移实测与 2x 到 3x 兼容性检查]

2. 虚拟线程的技术背景:平台线程的瓶颈与 Project Loom

2.1 平台线程为什么贵

传统 Java 线程是「一对一」映射到操作系统线程的,也就是所谓的平台线程(Platform Thread)。它的成本体现在三处:

  • 内存成本:每个线程默认预留约 1MB 的栈空间,几千个线程就是几个 GB。
  • 调度成本:线程的创建、销毁、上下文切换都要陷入内核态,开销远高于用户态操作。
  • 数量上限:受操作系统线程数限制,通常几千到上万个就到顶了。

在 IO 密集型服务里,一个请求大部分时间都在等数据库、等下游 HTTP、等磁盘,线程处于阻塞状态却依然占着内核线程和栈内存。为了扛住并发,我们只能上线程池 + 异步回调 + Reactor,代价是代码复杂度陡增。

2.2 Project Loom 的解决思路

Project Loom 的核心是把「Java 线程」和「操作系统线程」解耦:

  • 虚拟线程(Virtual Thread) 是 JVM 管理的轻量级线程,初始栈只有几百字节,按需增长。
  • 大量虚拟线程被挂载(mount) 到少量载体线程(Carrier Thread) 上运行,载体线程数量默认等于 CPU 核数。
  • 当虚拟线程遇到阻塞式 IO 时,JVM 会把它从载体线程上卸载(unmount),让载体线程去跑别的虚拟线程;IO 完成后重新挂载继续执行。

这意味着:你可以用同步阻塞的写法,获得接近异步非阻塞的吞吐能力。代码可读性和调试体验都保住了。

flowchart LR
  subgraph V[虚拟线程 成千上万个]
    V1[VT1]
    V2[VT2]
    V3[VT3]
    V4[VTN]
  end
  subgraph C[载体线程 等于 CPU 核数]
    C1[Carrier1]
    C2[Carrier2]
  end
  subgraph O[操作系统线程]
    O1[OS Thread1]
    O2[OS Thread2]
  end
  V1 --> C1
  V2 --> C1
  V3 --> C2
  V4 --> C2
  C1 --> O1
  C2 --> O2

需要特别注意一个坑:synchronized 块内的阻塞会导致载体线程被钉住(pinning)。在 JDK 21 中,synchronized 里的阻塞 IO 不会卸载虚拟线程;JDK 24 起这一问题已基本解决。因此在 JDK 21 上,热点路径建议用 ReentrantLock 替代 synchronized。

3. 一行配置启用虚拟线程

Spring Boot 3.2 起,启用虚拟线程只需要一个配置项:

spring:
  threads:
    virtual:
      enabled: true

就这一行。Spring Boot 会自动完成以下替换:

  • Tomcat 的请求处理线程池换成虚拟线程执行器(VirtualThreadTaskExecutor)。
  • @Async 默认使用的 TaskExecutor 换成虚拟线程执行器。
  • Spring MVC 的异步请求、TaskScheduler 等组件同步适配。

对应的 Java 配置等价写法(便于理解底层做了什么):

@Configuration
public class VirtualThreadConfig {

    @Bean
    public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
        return protocolHandler ->
                protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
    }

    @Bean(name = "applicationTaskExecutor")
    public AsyncTaskExecutor applicationTaskExecutor() {
        return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
    }
}

验证是否生效,最直接的办法是在 Controller 里打印线程信息:

@RestController
public class ThreadProbeController {

    @GetMapping("/probe")
    public Map<String, Object> probe() {
        Thread t = Thread.currentThread();
        return Map.of(
                "name", t.getName(),
                "isVirtual", t.isVirtual(),
                "carrier", t.toString()
        );
    }
}

如果返回 isVirtual: true,说明虚拟线程已经接管请求处理。

4. 虚拟线程在 Web 请求、@Async 与 JDBC 中的自动适配

4.1 Web 请求处理

Tomcat 在启用虚拟线程后,每个请求由一个独立的虚拟线程处理。请求量从「受线程池大小限制」变成「受内存和下游能力限制」,这是吞吐量提升的主要来源。

4.2 @Async 异步任务

@Async 标注的方法默认走 applicationTaskExecutor,启用虚拟线程后每次调用都会新建一个虚拟线程。注意:虚拟线程创建成本极低,不需要池化,Executors.newVirtualThreadPerTaskExecutor() 本身就是「来一个建一个」。

@Service
public class ReportService {

    @Async
    public CompletableFuture<String> generateReport(Long userId) {
        // 模拟耗时 IO
        String data = remoteClient.fetch(userId);
        return CompletableFuture.completedFuture(data);
    }
}

4.3 JDBC 操作

这是最容易被误解的一环。虚拟线程不会让 JDBC 驱动本身变快,它只是让「阻塞等待数据库返回」这件事不再占用平台线程。前提是驱动在等待网络 IO 时使用的是可被 Loom 感知的阻塞点。

实践中的关键点:

  • 连接池大小依然是硬约束。虚拟线程再多,HikariCP 的 maximumPoolSize 不放开,请求照样排队。经验做法是把连接池调到与目标并发匹配的量级,而不是沿用「CPU 核数 × 2」的老公式。
  • 数据库服务端的连接数上限、max_connections 才是真正的天花板。
  • 使用 jdbc:postgresql / jdbc:mysql 等主流驱动时,JDK 21 下网络读写已能被正确卸载;但部分老版本驱动或自研驱动可能仍会 pinning,需要实测。
spring:
  datasource:
    hikari:
      maximum-pool-size: 100   # 按目标并发与数据库承载能力调整
      minimum-idle: 10

5. 虚拟线程的适用边界:IO 密集型 vs CPU 密集型

虚拟线程不是银弹,它的收益完全取决于阻塞比例。

5.1 IO 密集型:收益显著

假设一个请求 95% 的时间在等待 IO,那么虚拟线程可以把载体线程的利用率拉满。典型场景:网关、BFF 聚合层、调用多个下游的查询服务。

5.2 CPU 密集型:几乎没有收益,甚至更差

CPU 密集型任务不会阻塞,虚拟线程没有卸载的机会,反而多了一层调度开销。这类场景应该用固定大小的平台线程池,把并发度控制在 CPU 核数附近。

5.3 实测对比

以下为一组参考数据(4C8G 容器,JDK 21,Spring Boot 3.3,接口内含 50ms 模拟 IO 延迟,压测工具 wrk):

场景线程模型并发QPSP99 延迟常驻内存
IO 密集(50ms 延迟)平台线程池(200)20003,800620ms780MB
IO 密集(50ms 延迟)虚拟线程20009,600210ms690MB
CPU 密集(哈希计算)平台线程池(8)2001,240180ms520MB
CPU 密集(哈希计算)虚拟线程2001,180195ms560MB

结论很清晰:IO 密集场景吞吐提升 2 倍以上,CPU 密集场景基本持平甚至略降。选型时先判断业务是哪种类型,再决定是否开启。

6. AOT 提前编译与 GraalVM 原生镜像

6.1 原生镜像解决了什么

JVM 应用的启动过程包含类加载、字节码解释执行、JIT 预热,冷启动往往需要数秒。在 Serverless、Kubernetes 弹性伸缩场景下,这几秒意味着请求排队和扩容滞后。

GraalVM Native Image 在构建期就把应用编译成平台相关的可执行文件:

  • 启动时间:从秒级降到几十毫秒。
  • 内存占用:省去 JVM 元空间、JIT 代码缓存,常驻内存显著下降。
  • 峰值性能:由于缺少运行期 JIT 的深度优化,长时间运行后的峰值吞吐通常略低于 JVM 模式。

6.2 Spring Boot 3.x 的 AOT 支持

Spring Boot 3.x 引入了完整的 AOT 处理流程:在构建期扫描 Bean 定义、生成反射注册代码、生成代理类,从而避免运行期反射。这也是原生镜像能跑起来的前提。

# 使用 Buildpacks 构建原生镜像(需要 Docker)
./mvnw -Pnative spring-boot:build-image

# 或者使用 GraalVM 本地工具链直接编译
./mvnw -Pnative native:compile

对应的 Maven 插件配置:

<plugin>
    <groupId>org.graalvm.buildtools</groupId>
    <artifactId>native-maven-plugin</artifactId>
</plugin>

运行编译产物:

./target/demo-app
# Started DemoApplication in 0.062 seconds (process running for 0.089)

7. 原生镜像构建的依赖限制与构建流程

7.1 常见限制

原生镜像采用封闭世界假设(Closed-World Assumption),构建期不可达的代码会被裁掉。这带来几类典型问题:

  • 反射:运行期通过字符串查找类/方法会失败,需要 @RegisterReflectionForBinding 或 reflect-config.json 显式注册。
  • 动态代理:JDK 动态代理需要提前声明接口。
  • 资源文件:getResource 读取的文件需要在 resource-config.json 中登记。
  • JNI / 序列化:部分库依赖运行期生成,需要额外配置。
  • 不支持的特性:如 SecurityManager、部分字节码增强(某些 AOP 场景)。

好消息是,Spring Boot 3.x 对主流 starter 已内置 AOT 提示(RuntimeHints),Spring Data JPA、Spring Security、Jackson 等基本开箱可用。真正需要手工处理的多是第三方 SDK。

7.2 自定义 RuntimeHints

@Configuration
@ImportRuntimeHints(MyRuntimeHints.class)
public class NativeConfig {
}

public class MyRuntimeHints implements RuntimeHintsRegistrar {

    @Override
    public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
        hints.reflection().registerType(MyDto.class,
                MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
                MemberCategory.INVOKE_DECLARED_METHODS);
        hints.resources().registerPattern("templates/*.json");
    }
}

7.3 构建流程建议

flowchart TD
  A[代码提交] --> B[JVM 模式单元测试]
  B --> C[AOT 处理 生成 hints 与代理]
  C --> D[Native Image 编译]
  D --> E[原生镜像冒烟测试]
  E --> F{反射或资源报错}
  F -- 是 --> G[补充 RuntimeHints]
  G --> D
  F -- 否 --> H[推送镜像仓库]

建议把原生镜像构建放在 CI 的独立阶段,并保留一套 JVM 模式的集成测试作为对照,避免为了原生镜像牺牲功能正确性。

8. 实际迁移案例:吞吐提升与内存下降

某订单查询服务从 Spring Boot 2.7 + JDK 17 迁移到 Spring Boot 3.3 + JDK 21,分两步走:先升级到 3.x 并开启虚拟线程,再尝试原生镜像。

第一步:开启虚拟线程

  • 接口平均耗时从 180ms 降到 165ms(下游未变,主要是排队减少)。
  • 相同资源下 QPS 从 2,400 提升到 5,100。
  • 线程数从固定 200 变为动态数千,堆内存反而下降约 8%。

第二步:切换原生镜像

指标JVM 模式原生镜像
启动时间4.2s0.09s
常驻内存(RSS)620MB180MB
稳态 QPS5,1004,700
镜像体积280MB95MB

可以看到,原生镜像在启动和内存上优势巨大,稳态吞吐略降约 8%,对于弹性伸缩频繁、实例数多的服务,这笔账是划算的。

9. 从 Spring Boot 2.x 升级到 3.x 的注意事项

升级不是改个版本号,以下几项必须逐一确认:

  • JDK 基线:Spring Boot 3.x 要求 JDK 17+,要用虚拟线程则必须 JDK 21+。
  • Jakarta EE 命名空间:javax.* 全部迁移到 jakarta.*,包括 Servlet、Persistence、Validation、Annotation。这是升级中改动量最大的一项,建议用 OpenRewrite 的 UpgradeSpringBoot_3_0 recipe 批量处理。
  • Spring Security 6:WebSecurityConfigurerAdapter 已移除,需改为 SecurityFilterChain Bean 写法;authorizeRequests 改为 authorizeHttpRequests。
  • 配置属性变更:spring.mvc.pathmatch.matching-strategy 默认值变化、spring.config 相关属性调整,建议开启 spring-boot-properties-migrator 临时依赖,启动时会打印废弃属性提示。
  • 第三方依赖:确认 MyBatis、Druid、各类 SDK 是否有支持 Jakarta 的版本。老版本 Druid 在 3.x 下会有兼容问题。
  • 可观测性:Micrometer 与 Micrometer Tracing 取代了 Spring Cloud Sleuth,链路追踪配置需要重写。
  • 原生镜像兼容性:如果计划上原生镜像,升级后先跑一遍 native:compile,把反射问题在早期暴露出来。
<!-- 升级期临时加入,用于发现废弃配置 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-properties-migrator</artifactId>
    <scope>runtime</scope>
</dependency>

10. 小结

虚拟线程和 GraalVM 原生镜像分别对应云原生场景下的两个核心诉求:更高的并发密度与更快的启动、更低的内存。它们都不是无条件的性能提升:

  • 虚拟线程适合 IO 密集型服务,CPU 密集型场景收益有限,还要注意 synchronized 导致的 pinning 与连接池容量约束。
  • 原生镜像适合弹性伸缩、Serverless、实例数多的场景,代价是构建复杂度上升与稳态吞吐的小幅下降。

合理的落地路径是:先升级到 Spring Boot 3.x 并开启虚拟线程,用真实压测验证收益;再针对启动敏感的服务尝试原生镜像,逐步补齐 RuntimeHints。两步分开做,风险可控,收益可量化。