虚拟线程+Spring Boot 3.x 迁移指南:三步切换,附完整改造清单

引言

上个月给支付网关做虚拟线程迁移,团队里两种声音吵了起来:

乐观派说:"加一行 spring.threads.virtual.enabled=true,官方一键开启,完了。"

保守派说:"别天真了,我们 30 万行代码,几十个自定义线程池、一堆 synchronized、手写对象池,一键开启约等于一键爆炸。"

两边都有道理。一键开启只对"干净"的应用成立——而我们 Review 完代码库后发现:17 个自定义线程池、23 处 synchronized 包裹 IO、4 个手写对象池。直接开,就是拿生产环境赌运气。

最终我们走了"三步迁移 + 完整改造清单"的路线:先替换 @Async 线程池、再升级 HTTP 客户端、然后给数据库连接池加保护,最后才开启虚拟线程开关。灰度上线后压测数据:

指标迁移前(传统线程池)迁移后(虚拟线程)变化
P99 延迟(下单链路)412ms124ms↓70%
吞吐(单实例 QPS)1,8507,400×4
峰值线程数200 + 32 async + 16 scheduler24 carrier + 万级虚拟线程内存↓30%
压测 30min 错误率0.4%(连接池超时)0.0%归零

这篇文章把三步切换的每一步代码、改造清单的判断标准、压测方法和踩过的坑全部展开。目标只有一个:让你在迁移前就知道哪些要换、哪些不能换、哪些必须加保护,而不是在生产环境里用事故学。


一、迁移前先对齐:虚拟线程改变了什么

1.1 三十分钟版本的前置知识

平台线程虚拟线程
映射关系1:1 对应 OS 线程M:N 映射到少量载体线程(carrier)
创建成本毫秒级 + 1MB 栈微秒级,栈懒分配
合理数量数百数十万甚至百万
阻塞时占住 OS 线程干等unmount 让出载体,唤醒后续跑
调度者OS 内核JVM 调度器(ForkJoinPool)

核心变化就一句:"线程 ≈ 请求"且数量受限 → "请求 = 虚拟线程"且数量几乎无限

你的整个技术栈——线程池大小、连接池大小、对象池、ThreadLocal——全是按"线程数几百"设计的。迁移的本质不是"换个 Thread 实现",而是把所有按稀缺线程资源设计的组件重新审视一遍

1.2 两个必须先记住的红线

红线一:synchronized 块内阻塞 = 载体线程被钉死(pinned)。虚拟线程遇到 synchronized 想让出载体却让不出,等于退化成平台线程还多了调度开销。JDK 21 有此限制(JDK 24 的 JEP 491 已修复,但你要升级 JDK 24+),在 JDK 21 上迁移前必须排查 synchronized

红线二:连接池是真正的稀缺资源。虚拟线程可以百万,数据库连接不可能百万。以前 200 线程配 20 连接刚好,现在 1 万请求配 20 连接,9980 个请求挤在 Hikari 的等待队列里超时——必须加限流保护(第三步细讲)。


二、第一步:替换 @Async 线程池为 VirtualThreadTaskExecutor

2.1 现状:典型的 @Async 配置长这样

// ❌ 迁移前:传统线程池异步
@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean("notifyExecutor")
    public ThreadPoolTaskExecutor notifyExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(8);          // 核心线程
        executor.setMaxPoolSize(16);          // 最大线程
        executor.setQueueCapacity(1000);      // 队列
        executor.setThreadNamePrefix("notify-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        return executor;
    }
}

// 业务侧
@Async("notifyExecutor")
public void sendSmsNotification(Order order) {
    smsClient.send(order.getPhone(), buildContent(order));   // IO 阻塞
}

这套配置的问题在虚拟线程视角下暴露无遗:16 个线程干的是"发短信等 200ms 网络 IO"的活,98% 的时间在阻塞等待;队列 1000 满了走 CallerRunsPolicy,异步变同步拖慢主链路。

2.2 改造:一行 Bean 替换

// ✅ 迁移后:虚拟线程执行器
@Configuration
@EnableAsync
public class AsyncConfig {

    /** 自定义命名的虚拟线程执行器(推荐:日志排查时线程名可读) */
    @Bean("notifyExecutor")
    public AsyncTaskExecutor notifyExecutor() {
        return new TaskExecutorAdapter(
                Executors.newThreadPerTaskExecutor(
                        Thread.ofVirtual().name("notify-vt-", 0).factory()));
    }
}

Executors.newThreadPerTaskExecutor(virtualFactory) 的语义是"每提交一个任务就新建一个虚拟线程"——没有池、没有队列、没有拒绝策略,这三个概念在虚拟线程世界里都不需要了。业务代码 @Async("notifyExecutor") 一个字不用改。

2.3 更简:Spring Boot 3.2+ 的一键开关

如果不想逐个改线程池,Boot 3.2+ 提供全局开关:

spring:
  threads:
    virtual:
      enabled: true    # 自动替换:Tomcat 工作线程、@Async 默认执行器、@Scheduled 调度器

它做了三件事:内嵌 Tomcat 用虚拟线程处理请求、applicationTaskExecutor 自动变成虚拟线程执行器(@Async 未指定名字时走它)、任务调度器也切换。但它替换不了你手写的那些 ThreadPoolTaskExecutor Bean——这正是必须人工过清单的原因(第七章)。

2.4 三种异步写法的迁移对照

// 写法1:@Async 注解 —— Bean 换掉即可,业务代码不动 ✅
@Async("notifyExecutor")
public void sendSms(Order order) { ... }

// 写法2:手动 submit —— 注入新执行器即可 ✅
@Autowired
@Qualifier("notifyExecutor")
private AsyncTaskExecutor notifyExecutor;

notifyExecutor.submit(() -> syncErp(order));

// 写法3:CompletableFuture.supplyAsync(默认 ForkJoinPool) —— 要注意 ⚠️
// ForkJoinPool.commonPool 是平台线程池,虚拟线程项目里建议显式指定:
CompletableFuture.supplyAsync(() -> queryRisk(userId), virtualExecutor);
// 或者直接用虚拟线程原生写法:
Thread.startVirtualThread(() -> queryRisk(userId));

@Async 的坑提示@Async 方法返回 Future/CompletableFuture 时,虚拟线程下依然可用,但要注意 AsyncResult 已废弃,改用 CompletableFuture.completedFuture()


三、第二步:RestTemplate → RestClient

3.1 为什么 RestTemplate 必须换

两个理由,一个技术一个政治:

技术理由:RestTemplate 本身能在虚拟线程上跑(底层 Socket 阻塞会正常 unmount),但它不支持 Servlet 阻塞式编程之外的现代特性——连接池配置陈旧、无流式 API、错误处理别扭。既然要动,一步到位。

政治理由(真实的):Spring 官方从 5.0 起就把 RestTemplate 标记为维护模式,新项目推荐 WebClient,而 WebClient 的响应式编程模型和虚拟线程"阻塞但便宜"的哲学相反。Spring Boot 3.2 给出的答案是 RestClient:同步阻塞 API 的现代化写法,虚拟线程绝配。

3.2 迁移对照:同一接口的三种写法

// ❌ 迁移前:RestTemplate
RestTemplate rest = new RestTemplate();
ResponseEntity<RiskResult> resp = rest.exchange(
        "https://risk-api/risk/check?userId={uid}",
        HttpMethod.GET, null, RiskResult.class, userId);
RiskResult risk = resp.getBody();
if (resp.getStatusCode() != HttpStatus.OK || risk == null) {
    throw new RiskApiException();
}
// ✅ 迁移后:RestClient(Spring Boot 3.2+)
@Bean
RestClient riskRestClient(RestClient.Builder builder) {
    return builder
            .baseUrl("https://risk-api")
            // 底层用 JDK HttpClient,虚拟线程下阻塞自动 unmount
            .requestFactory(new JdkClientHttpRequestFactory())
            .defaultStatusHandler(HttpStatusCode::isError,
                    (req, resp) -> { throw new RiskApiException(resp.getStatusCode()); })
            .build();
}

// 调用处:链式 API,干净利落
RiskResult risk = riskRestClient.get()
        .uri("/risk/check?userId={uid}", userId)
        .retrieve()
        .body(RiskResult.class);

3.3 连接池配置:虚拟线程下反而要"大方"

传统线程池时代,HTTP 连接池要精打细算(200 线程 × 30 连接 = 排队)。虚拟线程时代请求随便来,连接池太小反而成为新瓶颈

// 虚拟线程友好的 HTTP 连接池:并发高时连接数要跟得上
@Bean
RestClient.Builder restClientBuilder() {
    PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
    cm.setMaxTotal(500);              // 以前 50,现在放开
    cm.setDefaultMaxPerRoute(200);    // 单路由上限按下游承受力定
    var factory = new HttpComponentsClientHttpRequestFactory(
            HttpClients.custom().setConnectionManager(cm).build());
    return new RestClient.Builder().requestFactory(factory);
}

经验公式:MaxPerRoute ≈ 下游接口能承受的 QPS × 该接口 P99 RT(秒)。下游 risk-api 扛 1000 QPS、RT 100ms → 200 连接足够且不压垮对方。

3.4 迁移工作量评估

客户端迁移动作工作量
RestTemplate换 RestClient,链式重写调用处每个调用点 5~10 分钟
WebClient可保留(响应式与虚拟线程可共存),或换 RestClient 统一风格可不动
Feign升级到 Spring Cloud 2023.x,底层连接池配大即可仅配置
OkHttp / HttpClient 直用可保留,虚拟线程兼容不动

我们 23 个 RestTemplate 调用点,实际迁移花了一天,全部换成 RestClient 后顺手统一了超时和错误处理——迁移过程也是还技术债的过程


四、第三步:HikariCP + 信号量限流(最重要的一步)

4.1 不加保护的虚拟线程连接池是什么下场

复现一次(压测 3000 并发、Hikari maximum-pool-size=20):

18:42:01 HikariPool-1 - Connection is not available, request timed out after 3000ms.
18:42:01 HikariPool-1 - Connection is not available, request timed out after 3000ms.
...(每秒 400+ 条)
18:42:05 [http-nio-8080-exec] ERROR 订单创建失败:connection timeout

原因链条:3000 个虚拟线程同时进入"查库存"→ 只有 20 个拿到连接 → 其余 2980 个在 Hikari 等待队列里干等 3 秒 → 超时异常雪崩。连接池大小没变,但抢连接的"人数"涨了 150 倍。

4.2 信号量限流:让多余虚拟线程"优雅等待"

思路:在进入需要数据库的代码段之前用 Semaphore 把关,超出的虚拟线程在信号量上 unmount 挂起(不占载体、不占连接、不堆积在 Hikari 队列里):

/**
 * 数据库并发保护器:虚拟线程场景必配
 * permits ≈ Hikari max × 0.8(留余量给管理查询、事务边缘场景)
 */
@Component
public class DbConcurrencyGuard {

    private final Semaphore permit = new Semaphore(16);   // Hikari max=20

    public <T> T runWithPermit(Supplier<T> dbWork) {
        try {
            permit.acquire();          // 拿不到就 unmount 挂起,零成本等待
            return dbWork.get();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new CancellationException("等待数据库许可时被中断");
        } finally {
            permit.release();
        }
    }
}

// 业务侧使用
public OrderVO createOrder(OrderRequest req) {
    Order order = orderAssembler.toEntity(req);
    return dbGuard.runWithPermit(() -> {
        orderMapper.insert(order);           // 此时最多 16 个线程在真正用连接
        stockMapper.deduct(order.getSku(), order.getQty());
        return orderAssembler.toVO(order);
    });
}

为什么不用 Hikari 自带的 maxPoolSize 硬扛? 对比一下两种等待方式的本质区别:

直接怼 Hikari(max=20)Semaphore(16) 先行限流
等待位置Hikari 内部队列(JUC 队列自旋)Semaphore 队列(虚拟线程 unmount)
载体线程占用等待期间占着等待期间让出
3 秒超时行为抛异常,请求失败继续排队直到拿到,失败率≈0
队列积压上限Hikari 无界堆积可控(信号量公平性 + 排队长度自观察)

压测同场景(3000 并发):直怼 Hikari 错误率 13.6%,Semaphore 版 0%,P99 反而更低(没有超时重试的雪崩放大)。

4.3 Hikari 侧的配套调整

spring:
  datasource:
    hikari:
      maximum-pool-size: 20        # 按 DB 承受力定,不是按线程数定!
      minimum-idle: 20             # 虚拟线程场景建议 min=max,避免动态扩缩抖动
      connection-timeout: 2000     # 有信号量保护后,这里只兜底极端情况
      max-lifetime: 1800000
      pool-name: vt-order-pool

核心认知纠偏:连接池大小永远由数据库的承受能力决定(经验值 核心数 × 2 + 磁盘数,一般 16~50),虚拟线程不改变这个数字,改变的是"谁来排队"。

4.4 一个常见误判:每个 SQL 都要包 try-with-permit 吗?

不用。两种落地策略:

  • 粗粒度(推荐起步):在 Web 过滤器层对整个请求限流,Semaphore(并发上限) 覆盖全链路,一处配置全局生效。简单、无侵入,缺点是"读多写少"接口会互相挤占
  • 细粒度(精准):只在真正的高危写操作(下单、扣库存)处包 runWithPermit。结合第七章清单,把"DB 访问密集"的接口列出来逐个加

五、改造前后压测对比

5.1 压测环境与场景

项目配置
应用订单服务(Spring Boot 3.3 / JDK 21 / 2C4G × 1 实例压测)
数据库MySQL 8.0(Hikari max=20)
压测工具wrk2(恒定速率,避免 coordinated omission)
场景模拟下单链路:查库存 → 风控 HTTP 调用 → 扣减 → 落单
目标负载3000 QPS 持续 30 分钟

5.2 核心数据

迁移前(Tomcat 200 平台线程 + RestTemplate + Hikari 裸奔):

负载 QPSP50P99错误率线程数
100045ms180ms0%200
185095ms412ms0%200(打满)
3000380ms2.1s0.4%200(大量超时)

迁移后(虚拟线程 + RestClient + Hikari=20 + Semaphore(16)):

负载 QPSP50P99错误率载体线程
100022ms88ms0%8
185028ms110ms0%8
300035ms124ms0%8
740092ms310ms0.01%8(CPU 到瓶颈)

5.3 三个结论

  1. P99 ↓70%、吞吐 ×4 的来源分解:HTTP 排干阶段不再排队(贡献最大)、风控远程调用期间载体完全让出、Semaphore 消灭了连接超时引发的失败重试雪崩
  2. 线程从 248 个降到 8 个载体 + 动态虚拟线程,线程栈内存节省约 250MB,GC 压力同步下降
  3. 拐点依然存在:7400 QPS 时 CPU 打满——虚拟线程解决的是"阻塞等待浪费",不解决 CPU 瓶颈。CPU 密集型服务迁移收益有限,别抱不切实际的预期

六、改造清单:必须换 / 不能换 / 要加保护

这是全文最值钱的部分。逐类梳理,可直接当 Code Review Checklist 用。

6.1 ✅ 必须换(不换会亏)

迁移动作优先级
ThreadPoolTaskExecutor(@Async 用)VirtualThreadTaskExecutor / TaskExecutorAdapter 包装虚拟工厂P0
Tomcat 工作线程spring.threads.virtual.enabled=true 自动处理P0
RestTemplate 调用点换 RestClient(顺带统一超时/错误处理)P1
Executors.newFixedThreadPool 处理 IO 的场景newVirtualThreadPerTaskExecutor()P1
手写线程池跑阻塞任务(MQ 消费、定时任务)评估后换虚拟执行器,注意下游保护P1

6.2 ❌ 不能换 / 不该换(换了没好处或有害)

原因建议
CPU 密集型线程池(压缩、加解密、计算)虚拟线程不省 CPU,unmount 无收益保留平台线程池,核数大小
ForkJoinPool 的并行流(parallelStream)内部自己的调度,换不了也不需要保留
Netty EventLoop 线程事件驱动模型本身不阻塞别动
synchronized 包裹的纯内存操作无阻塞则 pin 无影响,且 synchronized 比锁快保留(别盲目全替换!)
调度类 FixedRate 定时任务虚拟线程无调度器语义保留 ScheduledExecutor

6.3 ⚠️ 必须加保护 / 排查(不处理会炸)

风险保护动作
synchronized 包 IO/阻塞调用载体钉死,吞吐暴跌JFR 搜 VirtualThreadPinned 事件 → 换 ReentrantLock
数据库连接池万级请求抢 20 连接,超时雪崩Semaphore 限流(第四章方案)
Redis 连接池同上,Lettuce shutdown 默认 100msLettuce 调大 shutdown-timeout,高并发接口加信号量
ThreadLocal + 海量虚拟线程百万线程 = 百万副本 → OOM上下文类换 ScopedValue,大对象禁止 TL
手写对象池(非线程安全容器)并发借还损坏(IndexOOB/重复借出)换无锁队列或加锁;能不池化就不池化
限流/熔断组件按"线程数=并发"假设语义错位信号量改为按业务并发数,配置重估
TLS 初始化 pin(JDK 21 已知)握手期间 pin 载体保持 JDK 21 最新补丁;JDK 24 已修复
ThreadLocalRandom/SimpleDateFormat 等隐含 TL海量虚拟线程放大SimpleDateFormat 换线程安全实现

6.4 迁移顺序建议(我们实测最稳的路径)

第 0 周   排查:JFR 采 pin 事件 + grep synchronized/ThreadLocal/手写池
          产出《风险点清单》,synchronized 包 IO 的先换 ReentrantLock
第 1 周   第一步:@Async 线程池 → 虚拟执行器(不开全局开关)
          灰度 1 实例观察异步任务成功率、RT
第 2 周   第二步:RestTemplate → RestClient;第三步:Semaphore 上线
          Hikari 连接池参数复核(min=max)
第 3 周   开启 spring.threads.virtual.enabled=true,灰度 1 实例
          观察:载体数、pin 事件、连接池等待、P99/吞吐
第 4 周   全量发布 + 复压测,固化改造清单进团队规范

七、常见问题

7.1 spring.threads.virtual.enabled=true 一行真的够吗?

对"干净"应用够:没用自定义线程池、没用 RestTemplate、没有 synchronized 包 IO。我们 30 万行的现实是:一键开启只能处理 Tomcat 和 @Async 默认执行器,17 个自定义池要手动换,23 处 synchronized 要排查。本文三步 + 清单就是把"手动部分"补齐。

7.2 怎么发现"载体被 pin"了?

JFR 一条命令:

java -XX:StartFlightRecording=filename=vt.jfr \
     -XX:+UnlockDiagnosticVMOptions -XX:+PrintVThreadPinnedEvents \
     -jar app.jar

JDK Mission Control 打开搜 VirtualThreadPinned,事件 stackTrace 直接定位到 synchronized 的行号,逐个换 ReentrantLock。上线后建议常驻低开销 JFR 持续采集,pin 事件进告警。

7.3 synchronized 全都要换成 ReentrantLock 吗?

不要! 只有"synchronized 块内包含阻塞操作(IO/sleep/等锁外的资源)"的才需要换;纯内存临界区(计数器、缓存 map 操作)保留 synchronized,性能更好。判断标准一句话:锁的括号里会不会发生阻塞

7.4 虚拟线程了还需要自定义线程池吗?

分场景:

  • IO 密集任务:不需要,newVirtualThreadPerTaskExecutor 即是池
  • CPU 密集任务:需要,保留平台线程池(大小=核数),别让 CPU 任务占满载体
  • 需要隔离的业务(重要任务和普通任务不互相饿死):建议保留两个虚拟执行器 Bean,靠"不同 executor"隔离,而不是靠池大小

7.5 迁移后 ThreadLocal 还能用吗?

能用但要克制。200 线程时代 200 份副本无感,10 万虚拟线程就是 10 万份——用户上下文这类"每请求一份"的换 ScopedValue;真·线程私有缓存对象重新评估是否池化。历史代码里 remove() 缺失的 ThreadLocal 是迁移后的 OOM 高发点,排查优先。

7.6 P99 降 70% 的数据可信吗?会不会是压测口径问题?

三个口径控制:wrk2 恒定速率发压(避免 closed-loop 的 coordinated omission 高估)、迁移前后同一压测机同一网络路径、错误率同时展示(只看 P99 不看错误率会被"超时快速失败"骗)。我们 412→124ms 的对比中,迁移前 1850 QPS 已经是拐点,迁移后拐点移到 7400——收益主要来自"拐点右移"而非"同点变快",这是更诚实的表述。


八、总结

三步切换速查卡

┌────────────────────────────────────────────────────────────┐
│ 第 0 步(前置)  排查风险点:JFR pin 事件 + synchronized 包 IO 换锁 │
│ 第 1 步         @Async 线程池 → VirtualThreadTaskExecutor        │
│                 (或 spring.threads.virtual.enabled=true)        │
│ 第 2 步         RestTemplate → RestClient(连接池同步配大)        │
│ 第 3 步         HikariCP 保持 DB 承受力大小 + Semaphore 限流       │
└────────────────────────────────────────────────────────────┘

改造清单速记

  • 必须换:@Async 线程池、Tomcat(开关)、RestTemplate
  • 不能换:CPU 密集池、ForkJoinPool/并行流、Netty EventLoop、纯内存 synchronized
  • 要保护:synchronized 包 IO(换锁)、DB/Redis 连接池(信号量)、ThreadLocal(ScopedValue)、手写对象池(无锁化)

关键数据

  • P99:412ms → 124ms(↓70%),吞吐 1850 → 7400 QPS(×4),拐点右移 4 倍
  • 线程:248 个平台线程 → 8 载体 + 万级虚拟线程,内存 ↓30%
  • 连接池裸奔错误率 13.6% → Semaphore 后 0%
  • 限流等待方式从"占载体自旋"变成"unmount 挂起",失败率归零

一句话

虚拟线程迁移不是"换个开关",而是把按"线程稀缺"设计的组件(线程池/HTTP 客户端/连接池/ThreadLocal/对象池)逐一重新审视——三步切换 + 一份清单,才能既拿到 4 倍吞吐,又不把生产环境变成事故现场。

给团队的建议

项目状态建议
新项目(Boot 3.2+)直接虚拟线程姿势开发,从第一天就用 RestClient/ScopedValue
存量 Boot 3.x、结构干净开开关 + 清单自查,一周搞定
存量老代码多(synchronized/手写池多)按 6.4 的四周路径走,先修风险再灰度
CPU 密集型服务收益有限,不迁移也不亏
JDK 还在 8/11先升 JDK 21(升级本身就是大工程,本文清单留档备用)

互动话题:你们团队上虚拟线程了吗?一键开启后有没有踩过 pin 或者连接池雪崩的坑?改造清单里还有什么我们漏掉的"隐形炸弹"?欢迎留言讨论!


参考资料


标题:虚拟线程+Spring Boot 3.x 迁移指南:三步切换,附完整改造清单
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/08/30/1787983081548.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消