Java 21发布已经有一段时间了,虚拟线程这个特性在圈子里的讨论热度一直没降。上个月正好项目里有个对外的数据查询服务频繁在高峰期触发线程池拒绝策略,日志里刷出大量RejectedExecutionException,排查下来发现是下游的第三方API响应偶尔变慢,导致调用线程阻塞时间变长,线程池里的工作线程很快就被耗尽了。扩容线程池只是把问题往后推,治标不治本。想到虚拟线程号称”阻塞不占操作系统线程”,刚好手里这个服务跑在JDK 21上,就决定切过去试试看。下面把整个迁移过程、踩过的坑以及最终的压测数据完整记录下来,给有同样想法的朋友一个参考。
一、为什么选择虚拟线程而不是继续堆线程数
先交代一下这个服务的状况。它是一个基于Spring Boot 3.1开发的数据聚合服务,主要逻辑是接收前端请求,然后并发调用四个下游REST接口,把返回的数据拼装成一个聚合结果返回给前端。原来用的是ThreadPoolTaskExecutor,核心线程30,最大线程80,队列容量200。正常流量下没什么问题,但下游接口偶尔会出现一两秒的响应延迟,这个时候进入线程池的任务得不到空闲线程,很快队列就满了,新来的请求直接被拒绝。
最开始的想法是把最大线程数调到150,队列容量加大到500。但这意味着更多的内存占用、更频繁的上下文切换,而且如果下游延迟再拉长一点,线程池迟早还会满。这时候想起了Project Loom带来的虚拟线程。它的核心卖点就是:一个虚拟线程阻塞时,底层的操作系统线程会被释放去执行其他虚拟线程,等阻塞结束再切回来。对于这种IO密集、阻塞时间不稳定的场景,虚拟线程天然就是比传统线程池更适合的模型。
说白了,以前我们之所以要小心翼翼地控制线程数量,是因为每个平台线程都对应着操作系统资源,几百个线程已经是负担。而虚拟线程是JVM管理的轻量级对象,创建成本极低,可以轻松开几万个甚至几十万个,阻塞时也不浪费系统资源。这对我们的服务来说,就是可以直接把”线程不够用”这个噩梦从根源上拿掉。
二、环境准备:JDK 21 和 Spring Boot 版本选择
虚拟线程从JDK 19开始作为预览特性出现,JDK 21正式转正。要稳定使用,必须上JDK 21。Spring Boot从3.2版本开始提供了对虚拟线程的自动配置支持,如果你用的还是3.0或3.1,就需要手动写一些配置代码。我们服务之前跑在JDK 17 + Spring Boot 3.1.5上,这次顺便做了升级。
升级步骤很简单,修改pom.xml里的版本号:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>
<properties>
<java.version>21</java.version>
</properties>
然后在服务器上安装JDK 21,设置JAVA_HOME指向新版本。如果你用Docker部署,直接改基础镜像就行,比如eclipse-temurin:21-jre。整个升级过程对我这个服务来说没有遇到不兼容的问题,Spring Boot 3.2对3.1的改动不大,原有的业务代码一行没改就跑起来了。
三、启用虚拟线程的三种方式,以及我选择的那一种
Spring Boot 3.2提供了两种内置方式启用虚拟线程,再加上手动编写的方式,一共有三种可选。我当时在这三种之间犹豫了一下,下面把各自的优缺点说清楚。
3.1 方式一:全局启用,一个配置搞定
在application.yml(或application.properties)里加一行:
spring:
threads:
virtual:
enabled: true
这个配置会让Spring Boot把默认的TaskExecutor、TaskScheduler以及@Async注解背后使用的执行器全部切换为虚拟线程执行器。对于大多数项目来说,这是最简单省心的方式。但它的覆盖范围比较广,整个应用里所有用到这些组件的地方都会被影响。如果你的应用里有一些对线程类型有特殊要求的逻辑(比如依赖ThreadLocal做上下文传递的旧代码),全局开启可能会引入一些意料之外的行为。
3.2 方式二:手动注入一个虚拟线程执行器
只想要部分功能使用虚拟线程的话,可以写一个配置类,手动创建一个VirtualThreadTaskExecutor的Bean:
@Configuration
public class VirtualThreadConfig {
@Bean
public TaskExecutor virtualThreadExecutor() {
return new VirtualThreadTaskExecutor("custom-vt-");
}
}
然后在需要的地方用@Qualifier("virtualThreadExecutor")显式指定。这种方式控制粒度最细,但每个地方都要手动指定,代码改动量会大一些。
3.3 方式三:直接用静态工厂方法创建,不依赖Spring管理
这是最原始的方式,在任何地方直接调用Thread.ofVirtual().name("my-vt").start(task)来创建虚拟线程。不推荐在Spring Boot项目里普遍使用,因为绕过了Spring的线程管理,生命周期不好控制,而且没法利用Spring提供的执行器抽象。
我最终选了方式一。理由是:这个服务本身就是IO密集型的,几乎所有线程阻塞都发生在等待下游HTTP响应上,全局切换虚拟线程受益最大。至于可能出现的ThreadLocal兼容问题,我提前做了排查,后面会讲到具体怎么处理的。如果你不确定自己的项目适不适合全局切换,先从方式二开始,只给关键的异步调用路径换上虚拟线程,观察一段时间再说。
四、迁移过程中踩到的三个坑
开启全局虚拟线程之后,服务在本地启动一切正常,单元测试也全绿。但部署到测试环境跑了一段时间后,还是出现了几个让我有点头疼的问题。下面逐个说。
4.1 数据库连接池被打满
问题现象:测试环境的错误日志里开始出现HikariPool - Connection is not available, request timed out after 30000ms。这个错以前从未出现过,而且流量并没有明显增加。
排查原因:切换虚拟线程之后,并发请求的处理能力大幅提升,一个请求内部并发调用四个下游接口,每个下游调用在等待响应的过程中,虚拟线程被挂起、平台线程被释放去处理其他虚拟线程。这意味着同时处于”活跃状态”的数据库操作数量比以前多了很多。原来线程池最多80个工作线程,同一时刻最多也就80个线程可能去获取数据库连接。现在虚拟线程可以开到几千个,虽然大部分在等待IO,但总有一些时刻会同时有大量虚拟线程需要执行数据库查询。数据库连接池默认大小是10,一下子就不够用了。
解决方案:调整HikariCP的连接池大小。以前用80个线程池线程,连接池大小设的是20,现在需要根据实际的并发数据库操作量来重新估算。经过几轮压测,最终把maximum-pool-size调到了50:
spring:
datasource:
hikari:
maximum-pool-size: 50
minimum-idle: 10
这个值不是越大越好,要根据数据库的实际承载能力来定。我们用的PostgreSQL,单实例支撑50个并发连接绰绰有余。如果你的数据库资源有限,就需要在启用虚拟线程的同时,对数据库访问做一些并发控制(比如用信号量限制同时执行数据库操作的虚拟线程数),而不是无脑放大连接池。
4.2 ThreadLocal里的用户上下文丢失
问题现象:一个下游接口调用失败时,日志里打印的用户ID始终显示为null。原来这个用户ID是通过一个RequestContextHolder(基于ThreadLocal)在请求进入时设置的,在后续调用中被日志框架读取。
排查原因:虚拟线程虽然也有ThreadLocal支持,但在虚拟线程之间切换时,ThreadLocal的值不会自动传递。我们代码里有几处地方手动创建了新的虚拟线程来执行后台任务,这些新创建的虚拟线程没有继承父线程的ThreadLocal上下文。
解决方案:把ThreadLocal替换为Scoped Value(JDK 21中作为预览特性引入,但在虚拟线程场景下推荐使用)。不过考虑到Scoped Value还是预览特性,而且团队其他成员不熟悉,我选择了一个更务实的方案:在创建新虚拟线程时,显式地把需要传递的上下文作为参数传进去,不再依赖ThreadLocal的隐式继承。对于日志框架需要读取的上下文,改用MDC(Mapped Diagnostic Context)并确保在虚拟线程边界传递。改动量不大,主要是把原来从ThreadLocal里读数据的地方改成了从方法参数或MDC里读。
4.3 限流措施失效导致下游被打爆
问题现象:切换虚拟线程后的第二天,下游一个比较脆弱的接口开始频繁返回429(Too Many Requests),甚至直接连接超时。查了下游的监控,发现来自我们服务的请求量在短时间内涨了一个数量级。
排查原因:以前用线程池限流,池子大小80,同一时刻最多也就80个并发请求打向下游。换成虚拟线程之后,并发请求数不再受线程池容量约束,瞬间可能发起几千个下游调用,直接把下游的限流阈值踩爆了。
解决方案:虚拟线程虽然不限制并发创建,但并发调用外部资源时必须自己做限速。我引入了一个Semaphore来限制同时发往下游的并发请求数:
private final Semaphore downstreamSemaphore = new Semaphore(100);
public String callDownstream(String url) throws InterruptedException {
downstreamSemaphore.acquire();
try {
// 发送HTTP请求
return restTemplate.getForObject(url, String.class);
} finally {
downstreamSemaphore.release();
}
}
信号量的值100是根据下游服务能承受的最大并发数来设定的,这个数字需要和下游团队协商或者通过压测确定。相比之前的线程池方案,信号量限流更精准——它只限制发往下游的并发数,而不会限制整个服务内部其他不涉及这个下游的并发操作。
五、性能对比:三组压测数据说话
说再多理论,不如一组数据直观。迁移完成之后,我在测试环境用JMeter跑了几轮压测,对比了传统线程池(80线程)和虚拟线程+信号量限流两种方案在相同流量下的表现。测试场景是模拟500个并发用户,每个用户发送一个聚合查询请求,每个聚合请求内部并发调用4个下游接口,每个下游接口模拟延迟在200毫秒到1500毫秒之间随机波动。
5.1 吞吐量(TPS)
传统线程池方案:稳定在约 420 TPS 左右。当模拟下游延迟整体上升时,TPS掉到180左右,同时错误率开始攀升(线程池拒绝)。
虚拟线程方案:稳定在约 1150 TPS。下游延迟波动对TPS有影响,但始终维持在900以上,且错误率为零(因为请求不会被拒绝,只是排队等待信号量许可)。
差距接近三倍,这还是在限制了并发调用下游数量(信号量100)的前提下测出来的。如果不加信号量,TPS会冲得更高,但下游会被打挂,没有实际意义。
5.2 平均响应时间
传统线程池:P50约 380ms,P99约 3400ms。长尾明显,因为线程池满了之后请求在队列里排队等候。
虚拟线程:P50约 310ms,P99约 1800ms。长尾大幅缩短,因为虚拟线程基本不需要排队等线程资源,请求到达后立即开始处理。
5.3 资源消耗
CPU使用率:两者差别不大,都在35%到45%之间波动。虚拟线程方案因为并发度更高,CPU略高几个百分点,但远没有到瓶颈。
内存使用:虚拟线程方案堆内存使用增加了大约200MB左右。每个虚拟线程占用的内存很小(几百字节),几万个虚拟线程的额外开销在可接受范围内。
线程数量:传统方案稳定在80个平台线程。虚拟线程方案中,平台线程数保持在CPU核心数附近(我们8核机器上大约12个左右),而虚拟线程数量随请求并发度波动,峰值达到3000多个。
这组数据让我比较信服的一点是:虚拟线程在IO密集场景下的优势不是”稍微好一点”,而是根本性的改善。以前用线程池,响应时间的长尾问题很难解决,因为线程池本身就是个瓶颈。现在这个瓶颈消失了。
六、生产环境上线前后的注意事项
压测数据好看不代表生产一定顺利。正式上线前我还做了几件事,这些准备让整个切换过程比较平稳,没出什么线上事故。
6.1 灰度发布,逐步放量
先在线上环境的某一台机器上开启虚拟线程,观察几个小时,确认没有异常后再全量推送。我们通过配置中心动态下发spring.threads.virtual.enabled的值,出问题可以一键回退,不用重新发布。
6.2 完善监控指标
传统线程池的监控主要看线程数和队列长度,切换到虚拟线程后这些指标意义不大了。我新增了以下监控:
- JVM平台线程数量(
jvm_threads_live_threads) - 活跃虚拟线程数(通过
ThreadMXBean的getThreadCount结合实际命名模式近似估算) - 信号量等待队列长度(针对每个下游调用的信号量)
- 下游接口的请求速率和错误率
虚拟线程本身不太需要监控,因为创建和销毁的开销极低,不会成为瓶颈。真正需要关注的是受虚拟线程并发能力冲击的下游资源和中间件,该加的限流和熔断一定要加到位。
6.3 测试ThreadLocal的遗留代码
开启虚拟线程之前,我在代码库里全局搜索了ThreadLocal和InheritableThreadLocal的使用点,逐一确认它们在虚拟线程下的行为是否还符合预期。大部分场景都没问题,因为虚拟线程在同一个请求生命周期内不会主动切换底层平台线程(除非发生阻塞),ThreadLocal的值在这个范围内是安全的。只有那些手动创建新虚拟线程并行执行任务的代码需要改造,数量不多,一个下午就改完了。
七、总结:你的项目适合上虚拟线程吗
经过这次完整的迁移,我对虚拟线程的适用边界有了更实在的判断。它不是一个”所有Java应用都值得升级”的万能药,但在正确的场景下,效果立竿见影。
非常适合虚拟线程的场景:
- IO密集型服务,线程大部分时间阻塞在网络调用、数据库查询、消息队列等待上。
- 并发请求量波动大,用线程池很难预估合适的大小,经常出现拒绝或资源浪费。
- 微服务架构中,一个请求需要合并多个下游结果,内部并发度高。
不太适合或者需要慎重评估的场景:
- CPU密集型计算,虚拟线程不会让计算变快,反而可能因为线程切换带来额外开销。
- 已经严重依赖
ThreadLocal且难以改造的大型遗留系统。 - 使用大量
synchronized块且竞争激烈的代码——虚拟线程在synchronized块内阻塞时仍然会占用平台线程,会削弱虚拟线程的优势。这种情况下建议先把synchronized换成ReentrantLock。
如果你的项目跑在JDK 21上,并且符合上面说的适合场景,花半天时间试试虚拟线程是件性价比很高的事。不需要重写业务代码,主要精力放在连接池调整、限流梳理和ThreadLocal排查这三件事上。做完之后,面对流量波动时心里会踏实很多——至少不用半夜被线程池拒绝的告警叫醒了。

