Java虚拟线程实战:从Tomcat迁移到百万级并发
Java虚拟线程实战:从Tomcat迁移到百万级并发
还有多少团队在2026年的今天,仍然为了“线上出现几百个请求就堵塞”而反复调大Tomcat线程池?我去年接手的一个订单同步服务就是这样:Tomcat最大线程数调到800,数据库连接池调到300,堆内存加到8GB,结果高峰期的请求延迟还是像过山车。把业务线程全部切换到Java虚拟线程之后,同样的硬件、同样的业务代码,压测并发直接从几千跳到了几十万——标题里的“百万级”确实不是噱头,只是对Tomcat而言,线程模型已经不再是最先被击穿的那块短板了。
JDK 21在2023年正式带来了虚拟线程,经过几个LTS版本的迭代,到2026年的今天,虚拟线程已经足够成熟。它解决的核心矛盾是:平台线程(也就是操作系统线程)是宝贵的稀缺资源,而请求却是轻量的逻辑任务。过去我们靠线程池复用线程来挨个处理请求,现在我们可以给每个请求一个虚拟线程,成本低到近乎可以无限创建。迁移过程中我踩了不少坑,今天把这套实战经验完整记录下来。
Tomcat的线程模型,为什么会成为瓶颈
传统Tomcat使用的是“每个请求占用一个平台线程”的模型。平台线程由操作系统管理,创建成本高、上下文切换昂贵,而且一旦线程发起阻塞操作——比如执行SQL查询、调用远程HTTP接口、等待Redis返回——这个宝贵的内核线程就只能干等着,什么工作也做不了。
为了控制开销,Tomcat不得不设置上限。默认的maxThreads通常是200,很多团队会调到500或1000,但这带来两个问题:第一,线程池设得再大,也挡不住极端高峰;第二,当线程数太多,CPU大量时间花费在线程调度和上下文切换上,真正的业务计算反而变慢。我曾经压测过一台4核8GB的机器,Tomcat线程数调到1000后,QPS不升反降,延迟从平均80ms飙升到几千毫秒,原因就是线程切换已经把CPU吃干净了。
虚拟线程彻底改变了这个等式。虚拟线程是由JVM调度的轻量级线程,它运行在少量“载体线程”(carrier thread,通常是操作系统线程)之上。当一个虚拟线程执行阻塞I/O时,JVM会自动把它从载体线程上摘下来,挂起到堆内存中,再让载体线程去执行另一个虚拟线程。这意味着阻塞不再等于浪费。
我常说一句话:Tomcat的线程池是在“共用一个苦力”,虚拟线程则是“每人发一个极轻的替身,苦力只在真正干活时出场”。
最小成本迁移:改配置还是改代码?
如果项目用的是Spring Boot 3.2以上版本,迁移到虚拟线程几乎就是改一行配置的事情。以Spring Boot内嵌Tomcat为例,打开application.yml:
spring:
threads:
virtual:
enabled: true
启动服务后,Tomcat处理请求的线程就会变成虚拟线程。此时再去访问/actuator/metrics,你会发现tomcat.threads.current几乎不再随并发量增加,因为默认的200个平台线程限制不再约束请求的并发度。
不过,对老项目来说,我更推荐的是一次“渐进式迁移”:保留请求入口的Tomcat线程池不动,先把业务代码中的ExecutorService换成虚拟线程执行器,跑通后再整体切换。比如订单批量同步原本是这样写的:
// 旧:固定200个线程,并发量上不去
ExecutorService executor = Executors.newFixedThreadPool(200);
for (String orderId : remoteOrderIds) {
executor.submit(() -> syncOrder(orderId));
}
切换成虚拟线程后,代码只需要改动一行:
// 新:每个任务一个虚拟线程,几乎没有数量上限
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (String orderId : remoteOrderIds) {
executor.submit(() -> syncOrder(orderId));
}
如果并发子任务之间存在依赖关系,还应该用JDK 21同批发布的StructuredTaskScope,它可以把一组虚拟线程组织成一个结构化作用域,任一任务失败时自动取消其他兄弟任务,错误传播能力比Future的组合方式清晰得多:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<Inventory> inventory = scope.fork(() -> inventoryClient.query(skuId));
Future<Price> price = scope.fork(() -> priceClient.query(skuId));
scope.join();
scope.throwIfFailed();
return assembleOrder(inventory.resultNow(), price.resultNow());
}
这一层改造不涉及Tomcat,却往往是整个系统最大的收益来源。因为订单、商品这类服务绝大部分时间都耗在远程调用上,属于典型的I/O密集型服务。
别随手把synchronized也带进虚拟线程
我团队里的一个新人当时改得特别顺手,结果上线第二天就出事故了。排查到最后,问题出在一个不起眼的synchronized方法上。
虚拟线程虽然叫“虚拟”,但它最终还是要跑在平台线程上。当虚拟线程进入synchronized代码块或者执行阻塞I/O时,JDK内部的monitor锁会把对应的载体线程一起锁住,导致载体线程无法腾出来执行其他虚拟线程。这种现象被称为“线程钉扎”(pinning)。大量虚拟线程同时钉扎在少量载体线程上,并发能力就会断崖式下跌,表现和当年线程池耗尽一模一样。
这不是说不能用synchronized,而是要注意不要在synchronized代码块内做阻塞调用。锁的作用域应该尽量小,能放进局部变量的就别放成共享状态。如果确实需要跨线程协调锁,优先考虑JDK 19引入的新接口java.util.concurrent.locks.ReentrantLock,它在阻塞时会让出载体线程,不会造成钉扎。我在压测环境做过对比:同样1000路并发,synchronized包着阻塞调用时,吞吐率只有ReentrantLock版本的六成左右。
另外还要谨慎使用ThreadLocal。虚拟线程之所以能大量创建,是因为它轻、它小;但如果你给每个虚拟线程塞一个大体积的ThreadLocal,比如缓存了数据库连接、对象池之类的东西,几万个虚拟线程就会白白占掉数GB堆内存。这类资源请继续使用显式的连接池或参数传递,不要让它们跟虚拟线程的生命周期绑定。
迁移后的新瓶颈在哪里
把线程池彻底换成虚拟线程之后,这个服务在压测环境跑出了50万QPS的峰值——但中间也经历了反反复复的波折。有一次我以为压不动了是虚拟线程还不够强,结果排查发现是MySQL连接池耗尽。业务代码里写了嵌套查询,每个请求的虚拟线程都会去拿一个数据库连接,连接池只有50个,于是一万路并发全部阻塞在getConnection()上。
这就是虚拟线程时代最魔幻的现实:线程不再是瓶颈,但连接池、HTTP客户端连接、文件描述符、消息队列的消费速度会依次成为新的瓶颈。Tomcat maxThreads这个调优参数从此可以退居幕后了,但maximum-pool-size仍然存在,只不过等待连接的姿势从“占着一个平台线程等”变成了“占着一个廉价的虚拟线程等”。
CPU密集型任务也不适合盲目改用虚拟线程。虚拟线程解决的是“很多任务都在等I/O”的场景;如果每个任务都是纯计算,那么跑满几个CPU核心就够了,创建一万个虚拟线程只会增加切换开销。我在迁移说明里列的判断标准很简单:任务的CPU等待时间如果超过执行时间的一半以上,虚拟线程就是值得的;反之,请继续使用与CPU核数匹配的固定线程池。
写在最后
从Tomcat到虚拟线程,我最大的体感是:调优思维终于从“省线程”变成了“省时间”。过去为了控制线程数量,我们会在代码里精心设计各种异步回调、响应式流,把逻辑拆得七零八落;而虚拟线程允许我们用最朴素的同步阻塞写法,却获得比异步模型更高的吞吐量。2026年再做新项目,我已经默认在启动类上开启虚拟线程了。剩下的那些外部连接池、线程钉扎的坑,都是可以依靠工具和架构设计去解决的问题,而“并发能力却被线程池上限锁死”这件事,终于可以画上句号了。