这个标题看着就让人手痒。说实话SpringBoot项目里把线程池换成虚拟线程这操作在一年前还是个需要犹豫半天的决定现在回头看确实是鸟枪换大炮级别的体验升级。这篇文章我打算用最直接的方式把虚拟线程从原理到SpringBoot落地再到实际压测数据一次讲透尤其是那些官网文档不会告诉你的坑。1. 为什么说虚拟线程是SpringBoot的“鸟枪换炮”先看清平台线程池的账本1.1 从一次线上事故说起200个线程被IO堵死的真相先聊一个真实场景。我之前维护过一个典型的SpringBoot单体服务部署在8核16G的机器上Tomcat默认开了200个线程。平时接口响应都在几十毫秒看上去一切岁月静好。结果有一次对接的外部系统抽风一个查询接口的响应时间从50ms飙到了3秒直接导致整个服务的吞吐量断崖式下跌。当时排查下来典型的线程池饥饿200个线程全部阻塞在外部接口等待响应上后续请求排队排队再排队新请求进来直接超时失败。这个事故让我对平台线程模型有了非常痛的领悟——平台线程和IO阻塞是天然对立的。Java传统的线程模型里一个平台线程对应一个内核线程线程的创建、切换、销毁都需要操作系统参与。更关键的是线程一旦进入阻塞状态比如等待网络响应、等待锁、等待数据库连接它占用的资源就全部浪费了。如果你是8核16G的机器Tomcat开200个线程已经是上限附近了你去查线程dump会发现大量线程停在WAITING或者TIMED_WAITING状态根本不是在干活是在干等。那时候我做过一个粗略计算一个平台线程默认栈内存是1MB200个线程就是200MB每个线程在阻塞时仍然占用内核调度资源、占用线程元数据而它的CPU使用率可能只有1%。换句话说绝大多数线程资源花在了“等待”上而不是“计算”上。1.2 “每请求一线程”模式为什么在传统容器里走不通了SpringBoot的经典处理模型是“每请求一个线程”一个HTTP请求进来Tomcat线程池分配一个线程这个线程负责把请求处理完再归还到线程池。这套模型在Web应用刚兴起的年代没问题因为那时候的请求基本都是短平快查个数据库、拼个响应几百毫秒就搞定了。但现在的业务场景早变了。一个普通的线上接口可能要先调一个鉴权服务再调一个订单服务再查几次数据库再发个消息队列通知。一个请求从进入到返回线程真正执行CPU指令的时间可能只有10%剩下90%的时间全部阻塞在RPC调用、数据库查询、Redis操作这些IO等待上。按这个比例线程池开200个、300个甚至更多也只是在给“等待”买票让更多请求排队进入等待区而已。所以在虚拟线程之前解决高并发IO密集场景的经典手段是异步化改造CompletableFuture串起来、引入反应式编程WebFlux、或者把同步调用改成异步消息。每一种方案都有剧烈的代价异步化改造会让代码可读性变得极差业务日志里全是回调嵌套WebFlux更是让绝大多数习惯了同步编程的团队直接崩溃。1.3 虚拟线程到底解决了什么问题虚拟线程Project Loom的产物的本质是让Java能够以极低的成本创建海量的、轻量级的线程对象而且这些线程的调度由JVM自己管理不依赖操作系统内核线程。JDK 21正式发布了虚拟线程SpringBoot 3.2开始官方支持用虚拟线程替换默认的平台线程池。用虚拟线程后的效果是什么你可以给Tomcat配置成“每请求一个虚拟线程”而虚拟线程创建成本近乎为零占用内存只有几KB到几十KB一个8核机器上跑几十万个虚拟线程都没问题。请求进来就开一个虚拟线程处理线程阻塞了就搁在那反正JVM内成本极低根本不需要线程池来控制数量——没有池化自然没有池满。我当时把内部服务切到虚拟线程后最直观的感受是线程池饥饿这个名词从报警监控里彻底消失了。不需要再盯着“activeThreads”这个指标做容量规划也不需要为了一个第三方接口的超时抖动去调线程池参数。2. 虚拟线程的底层机制不是“更轻量的线程池”是“线程该回归本质了”2.1 用户态调度与内核级线程的分工要理解虚拟线程建议先忘掉“线程池”这个概念。虚拟线程和操作系统线程平台线程是两种维度的东西平台线程由操作系统的调度器管理接受内核的调度虚拟线程是一个纯用户态的对象由JVM的调度器Scheduler负责分配给平台线程执行。虚拟线程本身不是一个CPU执行实体它更像是“一段可以被挂起和恢复的执行状态”。当虚拟线程执行到阻塞IO比如socket.read()、Thread.sleep()时JVM调度器会把这个虚拟线程“卸载”下来也就是保存好它的调用栈和状态然后释放它所占用的平台线程让这个平台线程去执行其他虚拟线程。等IO结果回来了调度器再把之前保存的栈给恢复找个空闲的平台线程继续跑。这个过程有个很关键的点阻塞IO变成了协作式调度。虚拟线程遇到阻塞不会白白占着平台线程而是主动让出让计算资源流转起来。这也是为什么“少开点线程反而吞吐更高”——平台线程数量只需要和CPU核心数匹配负责在虚拟线程之间来回切换执行。2.2 一个大佬级别的实验跑10万个线程还是10万个“线程”做个小实验就能直观感受虚拟线程的威力。下面这段代码可以创建一个平台线程的数组然后启动它们每个线程阻塞3秒// 平台线程版本 ExecutorService executor Executors.newFixedThreadPool(200);换成虚拟线程版本// 虚拟线程版本 try (ExecutorService executor Executors.newVirtualThreadPerTaskExecutor()) { for (int i 0; i 100_000; i) { executor.submit(() - { try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); } }平台线程版本在200个线程时就已经气喘吁吁了线程数再往上加基本就是内存爆炸而虚拟线程版本10万个任务照样轻松跑完占用的堆外内存非常有限。你可以把这个测试跑一下已经不需要看更多解释这种数量级的差异会直接说服你。2.3 一个容易混淆的点虚拟线程并不提升计算速度它提升的是吞吐这个要单独拿出来说因为很多人是带着“虚拟线程应该让接口更快”的期待来的。如果你把一个只做纯计算的接口换到虚拟线程响应时间不会有任何变化甚至因为调度器的开销反而稍微慢一点点。虚拟线程的真实价值是在“线程阻塞调度开销”这个维度上省出了大量空间。用一个生活化类比平台线程池模式像一家餐厅只雇了10个服务员每个服务员负责一桌客人的全套服务点单、上菜、结账这桌客人发呆等菜服务员就只能在旁边站着等虚拟线程模式像服务员只管把菜单递给厨师然后立刻去接待下一桌客人菜做好了再回来端给那桌。服务员数量还是10人但同时间段完成的订单量翻了10倍以上。这个类比对理解虚拟线程的性能收益非常有帮助。3. SpringBoot里启用虚拟线程的具体操作官方途径和手动兜底3.1 硬性条件拆解JDK版本和框架版本一步都不能少先把门槛划清楚。虚拟线程是JDK 21正式落地的功能此前在JDK 19、20是预览版强烈不建议在SpringBoot生产环境用预览版。SpringBoot这边3.2版本开始才支持通过配置一键启用虚拟线程3.0和3.1没有这个能力。如果用SpringBoot 2.x要么升级框架要么就只能手动写配置类自己定义执行器。配置前的检查清单JDK版本必须21及以上我用的是21 LTS目前线上也跑得很稳SpringBoot版本3.23.2.x、3.3.x都可以3.4.x也支持构建工具Maven或Gradle你随意这里无所谓依赖不需要额外引入任何包如果你的SpringBoot版本刚好是3.2以上那真的很幸运官方路线是自己改配置文件就行。如果不是也别慌后面有对应的兜底代码方案。3.2 官方的一键配置SpringBoot 3.2起的配置项大解密SpringBoot 3.2留给开发者的是一行配置spring.threads.virtual.enabledtrue这里有个细节要注意这行配置管的是SpringBoot内置的TaskExecutor比如Async注解的异步任务执行器以及自动装配的Tomcat容器线程池并不是。官网文档里这行的准确描述是启用后SpringBoot会把容器使用的执行器切换成虚拟线程执行器。放在servlet Web应用里它就是用来替换Tomcat处理请求的线程池的。也就是说加了这行配置后Tomcat对外表现为“每个请求来一个虚拟线程处理”不需要再调整server.tomcat.threads.max这些参数了因为线程池概念本质上被绕过了。这里建议把server.tomcat.threads.max这类配置从配置文件中删掉或者注释掉避免给自己造成它还有效的错觉。3.3 手动配置ThreadPoolExecutor替换方案的完整代码如果你用的是SpringBoot 3.1、3.0或者SpringBoot 2.x那就需要自己动手写一个配置类把默认的执行器换成虚拟线程。import org.springframework.boot.web.embedded.tomcat.TomcatProtocolHandlerCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.task.support.TaskExecutorAdapter; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; Configuration public class VirtualThreadConfig { Bean public TomcatProtocolHandlerCustomizer? protocolHandlerVirtualThreadExecutorCustomizer() { return protocolHandler - { ExecutorService executorService Executors.newVirtualThreadPerTaskExecutor(); protocolHandler.setExecutor(executorService); }; } }上面的代码是给SpringBoot内置Tomcat用的核心思路是拿到Tomcat的ProtocolHandler然后通过setExecutor把执行器替换成虚拟线程执行器。Executors.newVirtualThreadPerTaskExecutor()是JDK 21提供的工厂方法语义是“每次任务来一个虚拟线程用完就结束持续复用调度器”。如果你还在用SpringBoot 2.x版本没有TomcatProtocolHandlerCustomizer这个API怎么办还有第二套方案直接自己定义容器工厂import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; import org.springframework.boot.web.server.WebServerFactoryCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; Configuration public class VirtualThreadTomcatConfig { Bean public WebServerFactoryCustomizerTomcatServletWebServerFactory protocolHandlerCustomizer() { return factory - { ExecutorService executor Executors.newVirtualThreadPerTaskExecutor(); factory.addProtocolHandlerCustomizer(handler - handler.setExecutor(executor)); }; } }如果你不是用Tomcat而是Jetty也有对应的方案Jetty的QueuedThreadPool换成虚拟线程执行器。不过说实话Java Web容器里Tomcat占了绝大多数场景我这边就先按Tomcat来讲Jetty的配置思路是类似的。3.4 核心参数解析为什么用“每任务一个虚拟线程”而不是固定线程池这里有个很重要的设计判断虚拟线程场景下不要用Executors.newFixedThreadPool(虚拟线程数量)这种写法。原因很简单虚拟线程的优势就是便宜你想限制数量反而破坏了模型。假设你开了10万个虚拟线程它们全部线程池化了本质上还是一个限制容量的池只是池中元素便宜了一点收益远不如无池化。newVirtualThreadPerTaskExecutor()的核心语义是“任务就是全部”每次提交任务都创建一个新的虚拟线程去执行执行完销毁。这不代表每次都创建平台线程资源——底层调度器复用平台线程只是虚拟线程对象本身是轻量的创建销毁也就几微秒的量级。我还特意看了Executors.newVirtualThreadPerTaskExecutor()的JDK源码内部本质是返回了一个调度器驱动下的执行器任务进来创建的是虚拟线程对象虚拟线程对象由JVM内部的一个ForkJoinPool调度到平台线程上执行平台线程数量默认等于CPU核心数对应参数是initialization.available-processors。4. SpringBoot自动装配的隐藏链路不止是Tomcat这一个位置4.1 Tomcat容器线程切换的机制拆解上面配置解决的问题是Tomcat接受HTTP请求后的处理环节。HTTP请求进来后Tomcat的Acceptor线程负责accept连接然后把连接交给协议处理器ProtocolHandler协议处理器拿着刚才配置的执行器来调度处理逻辑。换成虚拟线程后等于“连接接进来就用一个虚拟线程处理整个请求生命周期”请求再多也不怕。有一个常见的实测现象如果启用了虚拟线程你在系统监控里很难再看到Tomcat线程数暴涨的情况。因为平台线程数量被控制在核心数量级线程数是平稳的暴涨或者打满都是虚拟线程级别的行为它们不会出现在老的监控面板上。4.2 Async和定时任务不会自动继承要单独配置这个是很多博主不会特意提醒的地方。SpringBoot 3.2的spring.threads.virtual.enabledtrue配置只对Spring的TaskExecutor相关自动装配生效。但是——EnableAsync注解开启的Async方法、Scheduled定时任务任务有时候用的执行器是独立的不一定自动走虚拟线程。需要注意SpringBoot 3.2及以上的TaskExecutionAutoConfiguration确实会检查ThreadPoolTaskExecutor这个Bean是否存在。实践中你会发现如果你在某个配置类里手动定义了一个ThreadPoolTaskExecutor的Bean它仍然会覆盖虚拟线程设置因为显式定义的Bean优先级高于自动装配。这算是一个决定绕过后台配置的合理手段但如果你本意是全局启用虚拟线程这个覆盖可能让你踩坑。个人建议是明确了要全面启用虚拟线程之后把项目里显式定义的ThreadPoolTaskExecutor、Executor这些Bean全部清理一遍统一交给框架层去处理不做局部定义避免隐藏覆盖。4.3 数据库连接池、Redis连接、第三方RPC被“传染”和没被“传染”的部分关键问题来了请求处理线程换成了虚拟线程那数据库连接池HikariCP怎么办Redis连接池怎么办RPC框架的线程池怎么办先说HikariCP。HikariCP底层是一个独立连接池默认最大连接数是10请求线程从池里拿连接、用完归还这个“拿连接”动作本身就是阻塞的。换成虚拟线程后请求线程在等待连接时是阻塞的虚拟线程它们不会占着平台线程不放但是连接池资源仍然是10个连接这个上限。虚拟线程只是让“等连接”这件事不再白白浪费平台线程不代表数据库能扛住更多并发连接。换句话说虚拟线程治的是“线程资源饥饿”不是“下游资源饥饿”。如果你的数据库本身就慢、连接数本身就紧张换成虚拟线程之后请求还是会在HikariCP的connectionTimeout那里排队只是排队的线程从平台线程变成了虚拟线程占用成本更低。然后再看RestTemplate、OpenFeign这类HTTP客户端。它们默认使用的是JDK的HttpClient或者Apache HttpClient的连接池和请求线程的调度无关。虚拟线程环境下RPC调用方在等待响应的时候虚拟线程会主动让出平台线程平台线程去执行其他虚拟线程这个收益是直接覆盖的。RPC连接池的并发能力本身没有提升但RPC等待不再拖垮整台服务的吞吐。4.4 还有一个隐藏玩家日志框架的异步写入切换到虚拟线程后有个容易忽略的隐藏瓶颈是日志框架。很多项目的日志配置里加了异步Appender比如Logback的AsyncAppender它本质是用一个固定线程池去消费日志队列。如果队列长度和丢弃策略写得比较激进在虚拟线程的高吞吐下日志异步写入会成为一个新的短板日志队列疯狂堆积然后丢弃排查问题的时候发现日志丢了。这个问题的解法有两个方向一个是日志系统的Appender输出端做批量写盘另一个是把日志队列调大同时调低丢弃阈值避免在压测阶段就丢日志。我在生产环境就碰到过一次案例吞吐提升了两倍多结果日志文件里的请求追踪断了一半最后发现AsyncAppender的discardingThreshold设置得太激进。5. 压测数据说话基于OpenFeign请求链路的虚拟线程实测5.1 为什么选OpenFeign作为压测场景上面讲了那么多原理下面用我实际做过的一次压测来验证。当时我们的一个核心链路是SpringBoot服务接收HTTP请求 - 调另一个服务的OpenFeign接口 - 再查一次MySQL - 返回结果。链路不算复杂但足够有代表性这里面有完整的网络IO阻塞Feign调用、数据库IO阻塞MySQL查询、以及穿插的序列化和反序列化计算。我设计了一个对照组对照组A使用传统Tomcat线程池200线程对照组B使用虚拟线程通过手动配置类启用压测参数用相同QPS梯度灌进去500、1000、2000、4000、8000。机器条件8核16GJDK 21SpringBoot 3.2MySQL和下游服务都部署在同一内网环境没有网络抖动等额外干扰因素。5.2 不同并发梯度下的吞吐和延迟对比这是有代表性的数据汇总单次压测时长5分钟取稳定区间的P99QPS梯度平台线程模式成功率平台线程P99延迟虚拟线程模式成功率虚拟线程P99延迟500100%55ms100%58ms1000100%88ms100%72ms200099.2%260ms100%89ms400091%1200ms100%142ms800042%4000ms99.6%280ms一开始在低QPS区间500、1000两者差距不大甚至平台线程P99还略微好看一点点这符合“虚拟线程调度有一定开销”的判断。但QPS到了4000以后平台线程模式开始明显崩溃线程池在外部接口等待上耗尽大量线程处于阻塞排队状态P99突破秒级成功率掉到91%以下。而虚拟线程模式下8个平台线程依然稳定运转P99缓慢上升但控制在300ms以内。这里需要额外强调一点压测期间我特意观察了HikariCP连接池的活跃连接数两种模式下基本趋同说明MySQL侧的能力没成为瓶颈。瓶颈就是纯粹的线程调度模型差异——同样的下游、同样的机器只是换了个线程调度策略。5.3 数据之外的体感变化监控和排障效率的变化除了压测数字虚拟线程还给团队带来了一些“软收益”。最直观的是监控面板上不再有tomcat.threads.current这种指标的毛刺了线程数变成一条平稳的直线。报警邮件里以前经常出现的“线程池活跃度超过90%”这个告警项直接被我删掉了。然后是排障方式的改变。以前接口变慢第一反应是去dump线程一眼看到几百个WAITING线程基本就是外部依赖慢导致线程堆积。现在虚拟线程模式下线程dump里绝大部分是RUNNABLE状态外部依赖慢导致的堆积很难再从线程角度观察到需要依赖Trace系统去定位具体是哪一环耗时变大。这也是团队需要提前适应的。6. 哪些场景千万别用虚拟线程坑汇总与替代方案6.1 同步锁synchronized的坑别在锁里做阻塞操作虚拟线程不是全面免费的午餐最经典的坑之一就是synchronized关键字JDK 21版本的虚拟线程在synchronized块里被阻塞时会固定住底层承载它的平台线程这个行为受JDK版本调度策略影响后续版本在持续优化但在JDK 21时代就是如此。这是一种锁竞争与IO阻塞叠加的情形虚拟线程A持有锁等待网络IO虚拟线程B排队等锁其他虚拟线程想用锁的时候都堵在平台上。最终结果可能是平台线程被锁卡住调度器能调度的虚拟线程数量反而减少了。如果你的代码里有synchronized块并且锁块内会执行阻塞操作外部RPC、数据库查询建议改成ReentrantLock虚拟线程在ReentrantLock的阻塞上不会钉住平台线程JDK 21的调度器支持正确的park/unpark处理。6.2 CPU密集任务的场景虚拟线程没有加速效果强调过了虚拟线程适合IO密集任务不适合CPU密集任务。如果你有一个任务是纯计算比如大对象序列化、复杂算法计算、图像处理它本身就会占满分配给它的平台线程直到计算结束不存在“让出”的机会。假设你有8个核心你创建1万个虚拟线程去跑CPU密集任务它们的总计算资源也就是8个核心的算力继续累加虚拟线程数量只会增加调度切换开销让总耗时变长。这类任务建议明确使用固定数量的平台线程池线程数设置为核心数±1比如Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors())并在执行入口处做好功耗控制。6.3 别把平台线程当壳去套虚拟线程Executor还有一个看起来很合理但实际上是反模式的做法有人把Executors.newVirtualThreadPerTaskExecutor()放在一个池化的线程池里让平台线程池的任务去提交虚拟线程任务层层套壳。这样做会把虚拟线程的“无池化”优势全部抵消掉平台线程池一旦满了照样没有救。虚拟线程的使用设计应该是一条链路贯穿到底——请求进来就是虚拟线程下游RPC操作、IO操作、阻塞调用全都在虚拟线程内完成不要在中途切回平台线程。6.4 什么时候保留平台线程池更稳妥最后说点理性的部分。虽然我把虚拟线程说得很好但并不是所有服务都得立刻切过来。下面的情况我建议保留原有线程模型服务本身就没什么阻塞操作全是CPU计算为主的接口换了无益。对应的下游服务有严格的连接数限制比如数据库最大连接数、第三方API QPS配额虚拟线程带来的并发提升会被下游坚决阻断反而加速下游不可用。团队对JDK版本升级有大量顾虑JDK 17-21的升级涉及很多老库兼容性问题这时不必为虚拟线程强行升级。其实呢很多Java老项目的开源库可能还在用synchronized、还在用Object.wait做等待逻辑JDK 21的默认虚拟线程调度器在这些场景下效率确实会打折。如果你决定全面启用虚拟线程建议先做一轮项目内部库的代码审计把这类老旧的同步模式都清一遍。7. 新线程模型下的日志串联与线程名称观察技巧7.1 线程名称格式换到虚拟线程后你会发现一个细节变化日志里的线程名从tomcat-http-12变成了虚拟线程名/底层平台线程名这种double格式比如Thread-0后面跟着ForkJoinPool-1-worker-3的影子。看到这种格式说明线程调度已经交给JVM了。顺手分享一个小诀窍在测试环境如果日志里大量出现异步线程交替处理同一条请求不必慌张这是虚拟线程的正常行为。传统线程模型下一个请求从头到尾固定在一个线程上虚拟线程模式下可能会在多次阻塞切换后落在不同平台线程上执行它们通过统一的上下文“接续”执行任务。7.2 带上TraceId把跨线程链路串起来虚拟线程场景下跨线程日志的串联需要格外重视。以前每次请求固定在线程上有天然优势——日志里的线程上下文天然串联。虚拟线程会切来切去你需要保证MDCMapped Diagnostic Context里的TraceId在线程切换时是传递的。SpringBoot的taskDecorator机制可以做到代码示例一发import org.springframework.core.task.TaskDecorator; import org.slf4j.MDC; public class MdcTaskDecorator implements TaskDecorator { Override public Runnable decorate(Runnable runnable) { var contextMap MDC.getCopyOfContextMap(); return () - { if (contextMap ! null) { MDC.setContextMap(contextMap); } try { runnable.run(); } finally { MDC.clear(); } }; } }这个装饰器的意义在于当虚拟线程让出平台线程、换到另一个平台线程执行时MDC上下文能跟着虚拟线程对象走而不是绑定在老平台线程上。配置执行器的时候把装饰器塞进去即可。如果团队用了统一的Trace框架这一块一般框架已经处理了不需要重复造轮子但你要确认一下。7.3 监控指标的迁移从线程数思维到任务耗时思维平台线程时代我们判断服务是否过载看得最多的就是线程活跃数和队列长度。虚拟线程模式下这两个指标基本失效了——平台线程数稳定得看不出任何波动队列长度几乎恒等于0。新的监控重心应该转移到任务在队列里的等待时间调度延迟和请求全链路耗时分解上。之前查线程Dump来判断外部依赖慢的方式被任务耗时分解替代了阻塞发生在哪个依赖上通过Trace里的span耗时就能一点一点挖出来。8. 我个人的最终建议怎么平稳切换到虚拟线程如果前面的内容都看完了下面给出一个可以直接上手的迁移路径。先做一轮依赖库审计把synchronized锁里的阻塞操作、Object.wait模式、自研的线程池套壳设计全部列出来能改的改掉不能改的标记为风险项。然后在测试环境启用虚拟线程跑一轮完整的回归压测重点观察下游数据库连接池和Redis连接池的表现确认它们的上限没有成为新的瓶颈。之后在预发环境观察一周不用担心出问题虚拟线程的切换回退是很容易的——把配置关掉或者把那个自定义配置类注释掉就回到了原样。真正的风险集中在库存量代码中的锁竞争和调用链路上而不是虚拟线程本身。对大部分应用场景来说虚拟线程确实是一次性把“平台线程不够用”这个老难题从架构层面给消解掉了。我自己的体会是SpringBoot 3.x时代的框架自动装配已经帮开发者把这个切换成本降到了最低剩下要做的只是根据自己的业务负载去验证和数据复盘。这个方向我相信未来几年会逐渐成为Java服务端默认的线程模型。
阅读完成 · 觉得有帮助?