去年我们接手了一个老电商项目,商品详情页的接口 P99 一直是 800 毫秒左右,遇到大促高峰能飘到 2 秒开外。这个接口做的事情很典型:查商品主信息、查 SKU 列表、查店铺信息、查几条热门评论,四件事并行发出去,然后组装成一个 VO 返回。上线之初就用线程池 + CompletableFuture 写着,五年了也没出过什么大事故,就是慢。
JDK 21 正式发布之后,团队里关于虚拟线程的讨论就没停过。我一开始挺乐观的——一个纯 IO 密集的聚合接口,几乎是为虚拟线程量身定做的场景。真正改下来,一周时间上线,性能数字确实变好看了,但中间踩的坑比预期多不少,有几个还差点让我放弃。
这篇记录就是把这整个过程写清楚。JDK 版本是 21.0.5,Spring Boot 3.3,MySQL 8.0.35,HikariCP 5.1.0。
原来那套写法的问题在哪
老代码大概长这样(简化版本):
private final ExecutorService pool = new ThreadPoolExecutor(
20, 200, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(500),
new ThreadFactoryBuilder().setNameFormat("detail-pool-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
public ProductDetailVO getDetail(Long productId) {
CompletableFuture<Product> productFuture = CompletableFuture.supplyAsync(
() -> productService.findById(productId), pool);
CompletableFuture<List<Sku>> skuFuture = CompletableFuture.supplyAsync(
() -> skuService.listByProduct(productId), pool);
CompletableFuture<Shop> shopFuture = CompletableFuture.supplyAsync(
() -> shopService.findByProduct(productId), pool);
CompletableFuture<List<Comment>> commentFuture = CompletableFuture.supplyAsync(
() -> commentService.topComment(productId), pool);
try {
CompletableFuture.allOf(productFuture, skuFuture, shopFuture, commentFuture)
.get(800, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
// 超时之后怎么办?这里其实什么也没做
} catch (Exception e) {
throw new BizException("查询商品失败");
}
ProductDetailVO vo = new ProductDetailVO();
// 一堆 getNow / join 组装
return vo;
}
这段代码有几个很隐蔽的问题,正确性上勉强过得去,但延迟表现很难做好。
第一个问题是线程池大小和 Tomcat 线程数不匹配。Tomcat 默认最大 200 个线程,而我们的业务线程池核心只有 20,最大 200,队列 500。高峰期 Tomcat 层 200 个请求一起进来,每个请求都要往业务池投 4 个任务,一共 800 个任务。业务池线程数撑到 200 就暂停扩展了,剩下 600 个任务全堵在队列里。请求响应时间就被这个队列长度拖长。
第二个问题是 CallerRunsPolicy。队列满了之后,Tomcat 线程会自己跑任务而不是拒绝。看着挺安全,实际上是把 Tomcat 线程也拖进了业务池的节奏里,并发能力进一步恶化。这个策略当年选的时候就是图省事,能起来就行。
第三个问题是超时处理形同虚设。800 毫秒超时之后我们只 catch 了异常,四个 future 还在继续跑,日志里也看不出是谁慢,只能靠 APM 猜。
虚拟线程版的改写
JDK 21 里虚拟线程已经是正式特性了,不需要加 preview 参数。写法上有两种:直接用 Executors.newVirtualThreadPerTaskExecutor() 提交任务,或者用 StructuredTaskScope 做结构化并发。
我最初写的是简单版本:
private final ExecutorService vThreadPool =
Executors.newVirtualThreadPerTaskExecutor();
public ProductDetailVO getDetail(Long productId) {
Future<Product> productFuture =
vThreadPool.submit(() -> productService.findById(productId));
Future<List<Sku>> skuFuture =
vThreadPool.submit(() -> skuService.listByProduct(productId));
Future<Shop> shopFuture =
vThreadPool.submit(() -> shopService.findByProduct(productId));
Future<List<Comment>> commentFuture =
vThreadPool.submit(() -> commentService.topComment(productId));
try {
Product p = productFuture.get(800, TimeUnit.MILLISECONDS);
// ...
} catch (Exception e) {
throw new BizException("查询商品失败");
}
}
代码是简单了,但超时之后四个 Future 不会被取消。Future.cancel(true) 对虚拟线程也不像对面线程那么可靠——如果任务已经跑进 JDBC 阻塞调用里,interrupt 是收不到的。这时候需要的是结构化并发。
StructuredTaskScope 在 JDK 21 到 JDK 24 之间一直是 preview,需要加 --enable-preview 编译运行。到了 JDK 25 这个 API 才正式转正(JEP 505)。我们生产环境用的是 JDK 21,所以加了 preview 参数,这点要注意,preview 特性在跨大版本升级时会有破坏性变更。
用 StructuredTaskScope.ShutdownOnFailure 重写:
public ProductDetailVO getDetail(Long productId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<Product> productTask =
scope.fork(() -> productService.findById(productId));
Subtask<List<Sku>> skuTask =
scope.fork(() -> skuService.listByProduct(productId));
Subtask<Shop> shopTask =
scope.fork(() -> shopService.findByProduct(productId));
Subtask<List<Comment>> commentTask =
scope.fork(() -> commentService.topComment(productId));
scope.joinUntil(Instant.now().plusMillis(800));
scope.throwIfFailed();
ProductDetailVO vo = new ProductDetailVO();
vo.setProduct(productTask.get());
vo.setSkus(skuTask.get());
vo.setShop(shopTask.get());
vo.setTopComments(commentTask.get());
return vo;
}
}
这里有几个细节值得说。
ShutdownOnFailure 的语义是:任意一个子任务抛异常,其它还没完成的子任务会被取消。joinUntil 是带超时的 join,超时之后返回,throwIfFailed 会把子任务里的异常重新抛出来,如果超时则抛 TimeoutException。
try-with-resources 是必须的。StructuredTaskScope 实现了 AutoCloseable,close() 时会确保所有子任务已经被处理完(要么正常结束,要么被取消),并且会等它们真正退出。这是结构化并发的核心承诺——离开这个方法时,不会再有它派生出去的线程还在跑。
还有一点,fork 出去的子任务是可以继承作用域的本地变量的(继承 StructuredTaskScope),但网络请求、数据库连接这些资源是共享的,这里要单独考虑。
坑一:连接池立刻变成新瓶颈
改完代码之后先本地压测。500 并发打进来,Tomcat 层面不改色,虚拟线程瞬间创建了 500 个,任务全部进入业务逻辑。但等我盯着 HikariCP 的监控看,问题就来了——DB 查询开始出现大量等待。
我们生产库的 HikariCP 配置如下:
spring.datasource.hikari.maximum-pool-size=30
spring.datasource.hikari.connection-timeout=30000
spring.datasource.hikari.minimum-idle=10
30 个物理连接。500 个虚拟线程里,同时能拿到连接的只有 30 个,剩下 470 个全部卡在 getConnection 上,最长要等 30 秒才会抛连接超时异常。之前用线程池的时候为什么没暴露这个问题?因为线程池本身的核心线程只有 20,最多扩到 200 还得排队,实际上同一时刻最多只有 200 个任务在抢连接,压力没那么大。虚拟线程把这个限流打爆了。
更隐蔽的是,连接池等待发生在 JDBC 层,是阻塞调用。虚拟线程在这个阻塞里会被卸载,CPU 占用很低,看起来一切正常。唯一能看出异常的是接口 P99 从 800 毫秒飘到了 1900 毫秒,而 CPU 使用率不到 30%。
处理方式我们试过三种。
第一种是直接调大 maximumPoolSize,从 30 加到了 100。有用,但需要注意 MySQL 的 max_connections 是共用的,你这边调到 100,别的服务那边就少了。而且 HikariCP 官方文档一直强调 pool size 不是越大越好,物理连接过多会让数据库侧的上下文切换和锁竞争变严重。
第二种是加信号量限流。在进入业务逻辑之前就用 Semaphore 限住并发数,保证同时在业务逻辑里的虚拟线程不超过物理连接能承载的数量:
private final Semaphore dbGate = new Semaphore(30);
public ProductDetailVO getDetail(Long productId) throws Exception {
dbGate.acquire();
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// ...
} finally {
dbGate.release();
}
}
这个方案的缺点是整个请求期间都占着一个许可,而实际上四个子查询每个只需要一段很短的时间。并发粒度不够细。
第三种方案最朴实——维持 maximumPoolSize = 30,把 connectionTimeout 从 30 秒降到 2 秒。让等不到连接的请求尽快失败,触发上层的重试或降级逻辑。这个方案对可用性的影响最可控。我们最后用的是这个,同时把业务池的并发控制在 200 左右。
有一点需要澄清:网上有种说法是”虚拟线程可以让连接池变小”,这完全是反过来的。虚拟线程能做的是让 IO 等待不再占用宝贵的 OS 线程,但物理连接的数量是数据库侧的硬约束,一个都省不了。真正的变化是——压力的瓶颈从应用线程池挪到了连接池,以前你可能注意不到连接池,现在它会跑到最前面。
坑二:synchronized 引发的 pinning
这个坑差点让我们把整个改造回滚掉。
压测跑到一半,同事说接口偶发性卡顿,有时一个请求会挂到 5 秒以上才返回,没有任何错误日志。我去看 JFR 的虚拟线程事件,发现一堆 jdk.VirtualThreadPinned 的告警。
虚拟线程虽然轻量,但它仍然需要一个载体线程(carrier thread,本质是 OS 线程)来运行。当虚拟线程执行到某些”无法卸载”的操作时,它会 pin 住载体线程,让载体线程跟着一起阻塞。最典型的就是 synchronized 块。如果这个 synchronized 块里还有 IO 操作,那就等于回到了老时代——一个线程干一件事。
检测方法:启动加 -Djdk.tracePinnedThreads=full。这个参数在 JDK 21 和 22 里能用,输出会告诉你哪一行代码把虚拟线程 pin 住了,堆栈打印得很清楚。
我们排查下来发现两个地方在犯这个问题。
第一个是 slf4j 的老版本 logback 里有个同步的 Appender 内部实现。每次业务逻辑打日志,虚拟线程都会在 synchronized 块里短暂阻塞。这个本身时间很短,但高频调用下累积效果很明显。
第二个是我们自己写的某个缓存组件里,用了 synchronized 保护本地缓存的 get-or-load 逻辑。这个逻辑包括一次 Redis 调用。synchronized 里套网络 IO,典型的灾难写法,之前用老线程池的时候问题不明显,因为并发不高的时候 synchronized 争抢很快就过去了。
修起来其实不难。第一种情况升级 logback 到最新版就能缓解。第二种情况把 synchronized 换成 ReentrantLock,或者更彻底地改用 Caffeine 缓存,它有内建的原子加载语义,不需要手写锁。
JDK 24 之后,JEP 491 生效,synchronized 不再 pin 住载体线程了,这个问题从根本上被解决。我们当时还是 JDK 21,只能靠代码侧绕开。升级 JDK 完全是个更省事的办法,但生产环境的升级往往没那么自由。
坑三:ThreadLocal 开始变得危险
虚拟线程数量可以轻松达到几万个,甚至几十万。如果每个虚拟线程都持有 ThreadLocal,内存使用量是线性上升的。
我们在项目里用 ThreadLocal 干了三件事:请求级用户信息缓存、traceId 上下文、以及一个 MyBatis 的 SQL 会话上下文。第三个是框架自己管理的,问题不大。前两个是我们自己写的 ThreadLocal 存储,在没有显式 remove 的情况下,虚拟线程结束之后这些数据会成为垃圾,但问题是——虚拟线程在 JDK 21 里不会被池化,每次请求都是新建的。所以你需要确保的是在请求结束的时候主动 remove,而不是依赖线程复用。
更需要注意的一个场景:StructuredTaskScope 里 fork 出去的子任务会继承父虚拟线程的 inheritableThreadLocals。如果父线程的 ThreadLocal 里存了一个很大的对象,这个对象会被复制到每个 fork 出去的子任务里,内存直接翻好几倍。
我们在 traceId 那部分没出问题,因为 traceId 字符串本身很小,复制也无所谓。用户信息那部分就翻车了——我们缓存的是一个完整 User 对象,含有权限列表、菜单树,光 JSON 序列化出来就几十 KB。500 并发 4 个子任务,就是 500 * 4 * 几十 KB,内存瞬间过百兆。
处理方式是把用户信息从 ThreadLocal 换成请求作用域的外部缓存(用 Redis 或者一次请求内共享的对象),只把 user id 放进 ThreadLocal,需要的时候再去查。
另外 JDK 21 引入的 ScopedValue 是专门为这个问题设计的(也是 preview 状态,JDK 25 转正),它在虚拟线程下比 ThreadLocal 更合适。不过我们当时没敢用 preview 特性,改用普通对象传递的方式绕过了。
坑四:监控指标集体失灵
改造上线的第二天早上,运维找我,问我是不是把 Tomcat 的线程数调小了。因为他看到监控里的 jvm.threads.live 从 200 多降到了 20 多。
其实是虚拟线程不占 OS 线程,所以 JVM 线程数自然就降下来了。原来的线程池监控、Tomcat 并发监控,全都不再能反映真实压力。
替代方案有几个方向。Tomcat 层面要看的是 request 处理时间,这个 Spring Boot 3.2+ 的 Micrometer 已经能采到。运行中的虚拟线程数可以通过 ManagementFactory.getThreadMXBean() 的 getVirtualThreadCount 方法拿到(JDK 21 加了相关 API),采样之后上报就行。业务层的并发我们用的是 Resilience4j 的信号量指标,改造之前就在,现在反而成了最有用的观测点。
还有一点要注意:虚拟线程的默认载体线程池大小是 CPU 核心数(即 jdk.virtualThreadScheduler.parallelism),但最大会扩展到 256(jdk.virtualThreadScheduler.maxPoolSize)。如果你的应用里有大量的计算密集操作,可能会导致载体线程池被占满,其它虚拟线程即使能跑也要等。这个在监控上要看 ForkJoinPool 的队列长度。我们是纯 IO 场景,没遇到,但同部门另一个做图片处理的团队撞上了。
Tomcat 线程池怎么换
补充一下服务层的配置。Spring Boot 3.2 之后,加一行 yml 就能让 Tomcat 用虚拟线程处理请求:
spring:
threads:
virtual:
enabled: true
这个配置会自动把 Tomcat 的 ProtocolHandler 换成基于虚拟线程的 Executor。如果你不用 Spring Boot 3.2+ 或者想更精细的控制,也可以手动配:
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerCustomizer() {
return protocolHandler -> protocolHandler.setExecutor(
Executors.newVirtualThreadPerTaskExecutor()
);
}
换完之后 Tomcat 那层的 maxThreads 配置就失效了,因为不再有固定线程数这个概念。真正的限流点要放在连接池、信号量或者网关层。
最终的数据
改造前后我们做了同一套压测,参数是 500 并发,持续 10 分钟,机型是 4 核 8G 的阿里云 Serverless 集群(数据库是 RDS MySQL 8.0)。
改造前:P50 是 210 毫秒,P95 是 550 毫秒,P99 是 810 毫秒,QPS 峰值约 220,业务线程池在高峰期基本满负荷,Tomcat 线程数稳定在 200。
改造后,连接池保持 30,加 200 并发信号量限流:P50 是 105 毫秒,P95 是 240 毫秒,P99 是 380 毫秒,QPS 峰值约 480。CPU 使用率从 45% 降到 38%。
数字看起来没那么惊艳,是因为限流卡在了 200。如果不加限流,QPS 能到 800 左右,但 P99 会飘到 1.5 秒。我们最终选择的是前者——在稳定的延迟下拿到最大的吞吐,比盲目的峰值 QPS 有价值得多。
有一点意外收获:改造之后 Tomcat 的请求处理拒绝率从之前的 0.3% 降到了 0。之前高峰期隔三差五会有几个请求在 Tomcat 层被拒绝,现在没有这个现象了,因为 Tomcat 那层不再有”线程池满”这个概念。
哪些接口不适合改
项目里我们也顺手把几个 CPU 密集的接口一起改了,结果发现虚拟线程并不是万能的。
一个是商品搜索的排序接口。它做的是一次大数据量的内存排序,纯 CPU 运算,每个请求要跑 200 毫秒左右。用虚拟线程之后没有任何变化,因为虚拟线程的调度依赖 ForkJoinPool,CPU 密集任务的载体线程数上限就是 CPU 核数,跟直接用固定线程池跑没区别。
另一个是图片处理接口。它调的是第三方 SDK,内部有大量的 synchronized,pinning 问题严重,改完之后 P99 反而变差了。我们最后回滚了这个接口,保持原来的写法。
结论很简单:虚拟线程真的是为 IO 阻塞设计的。CPU 密集型任务、或者内部有大量 synchronized 的老库,改造收益可能是负的。评估的时候要具体看接口的等待时间占比,等待时间超过总时间的 70% 才值得改。
另外就是改造过程中一定要盯紧连接池、关注 pinning、处理好 ThreadLocal。这四件事里任何一个没做对,虚拟线程带来的收益都会被吃掉。
最后一句话:虚拟线程是个很好的工具,但它解决的是”线程不够用”的问题,不是”性能不够好”的问题。如果瓶颈本身就在数据库或者网络带宽,那再多线程也没用。

