虚拟线程+Spring Boot 3.x 迁移指南:三步切换,附完整改造清单
引言
上个月给支付网关做虚拟线程迁移,团队里两种声音吵了起来:
乐观派说:"加一行 spring.threads.virtual.enabled=true,官方一键开启,完了。"
保守派说:"别天真了,我们 30 万行代码,几十个自定义线程池、一堆 synchronized、手写对象池,一键开启约等于一键爆炸。"
两边都有道理。一键开启只对"干净"的应用成立——而我们 Review 完代码库后发现:17 个自定义线程池、23 处 synchronized 包裹 IO、4 个手写对象池。直接开,就是拿生产环境赌运气。
最终我们走了"三步迁移 + 完整改造清单"的路线:先替换 @Async 线程池、再升级 HTTP 客户端、然后给数据库连接池加保护,最后才开启虚拟线程开关。灰度上线后压测数据:
| 指标 | 迁移前(传统线程池) | 迁移后(虚拟线程) | 变化 |
|---|---|---|---|
| P99 延迟(下单链路) | 412ms | 124ms | ↓70% |
| 吞吐(单实例 QPS) | 1,850 | 7,400 | ×4 |
| 峰值线程数 | 200 + 32 async + 16 scheduler | 24 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 裸奔):
| 负载 QPS | P50 | P99 | 错误率 | 线程数 |
|---|---|---|---|---|
| 1000 | 45ms | 180ms | 0% | 200 |
| 1850 | 95ms | 412ms | 0% | 200(打满) |
| 3000 | 380ms | 2.1s | 0.4% | 200(大量超时) |
迁移后(虚拟线程 + RestClient + Hikari=20 + Semaphore(16)):
| 负载 QPS | P50 | P99 | 错误率 | 载体线程 |
|---|---|---|---|---|
| 1000 | 22ms | 88ms | 0% | 8 |
| 1850 | 28ms | 110ms | 0% | 8 |
| 3000 | 35ms | 124ms | 0% | 8 |
| 7400 | 92ms | 310ms | 0.01% | 8(CPU 到瓶颈) |
5.3 三个结论
- P99 ↓70%、吞吐 ×4 的来源分解:HTTP 排干阶段不再排队(贡献最大)、风控远程调用期间载体完全让出、Semaphore 消灭了连接超时引发的失败重试雪崩
- 线程从 248 个降到 8 个载体 + 动态虚拟线程,线程栈内存节省约 250MB,GC 压力同步下降
- 拐点依然存在: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 默认 100ms | Lettuce 调大 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 或者连接池雪崩的坑?改造清单里还有什么我们漏掉的"隐形炸弹"?欢迎留言讨论!
参考资料
- JEP 444: Virtual Threads(JDK 21 正式版)
- Spring Boot 3.2 Release Notes(虚拟线程支持)
- Spring Framework RestClient 文档
- HikariCP 官方配置手册
- JDK 24 JEP 491: Synchronize Virtual Threads without Pinning
- Scoped Values(JEP 446)
- wrk2:消除 coordinated omission 的压测工具
标题:虚拟线程+Spring Boot 3.x 迁移指南:三步切换,附完整改造清单
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/08/30/1787983081548.html
公众号:服务端技术精选
- 引言
- 一、迁移前先对齐:虚拟线程改变了什么
- 1.1 三十分钟版本的前置知识
- 1.2 两个必须先记住的红线
- 二、第一步:替换 @Async 线程池为 VirtualThreadTaskExecutor
- 2.1 现状:典型的 @Async 配置长这样
- 2.2 改造:一行 Bean 替换
- 2.3 更简:Spring Boot 3.2+ 的一键开关
- 2.4 三种异步写法的迁移对照
- 三、第二步:RestTemplate → RestClient
- 3.1 为什么 RestTemplate 必须换
- 3.2 迁移对照:同一接口的三种写法
- 3.3 连接池配置:虚拟线程下反而要"大方"
- 3.4 迁移工作量评估
- 四、第三步:HikariCP + 信号量限流(最重要的一步)
- 4.1 不加保护的虚拟线程连接池是什么下场
- 4.2 信号量限流:让多余虚拟线程"优雅等待"
- 4.3 Hikari 侧的配套调整
- 4.4 一个常见误判:每个 SQL 都要包 try-with-permit 吗?
- 五、改造前后压测对比
- 5.1 压测环境与场景
- 5.2 核心数据
- 5.3 三个结论
- 六、改造清单:必须换 / 不能换 / 要加保护
- 6.1 ✅ 必须换(不换会亏)
- 6.2 ❌ 不能换 / 不该换(换了没好处或有害)
- 6.3 ⚠️ 必须加保护 / 排查(不处理会炸)
- 6.4 迁移顺序建议(我们实测最稳的路径)
- 七、常见问题
- 7.1 spring.threads.virtual.enabled=true 一行真的够吗?
- 7.2 怎么发现"载体被 pin"了?
- 7.3 synchronized 全都要换成 ReentrantLock 吗?
- 7.4 虚拟线程了还需要自定义线程池吗?
- 7.5 迁移后 ThreadLocal 还能用吗?
- 7.6 P99 降 70% 的数据可信吗?会不会是压测口径问题?
- 八、总结
- 三步切换速查卡
- 改造清单速记
- 关键数据
- 一句话
- 给团队的建议
- 参考资料
评论