虚拟线程救了我的Tomcat
发布时间:2026-06-26 03:42 浏览量:1
凌晨两点,告警群炸了。订单接口响应时间从200ms飙到8秒,网关超时率冲到30%。登录服务器一看,Tomcat线程池200个线程全部BLOCKED,请求队列堆了3000多个。
运维在群里问:「要不要重启?」
我说等等,先看堆栈。jstack一打,200个线程全卡在第三方物流接口的HTTP调用上——那个接口偶尔会慢到3秒才返回。传统线程池模型下,一个慢IO就能拖垮整个池子。
这不是我第一次见这种问题,但我决定这次不再调大线程数。我给Spring Boot加了一行配置,切到虚拟线程。重启后,同样的流量,吞吐量翻了3倍,线程数从200暴增到18000,但CPU稳如老狗。
下面我把这次从排查到改造的全过程拆开讲。
Tomcat默认200个工作线程,每个线程约1MB栈内存,加上线程上下文切换的开销,这套模型在CPU密集型场景没问题,但一到IO密集型场景就露馅了。
问题出在一个最基本的事实上:
Java线程是操作系统线程的1:1映射
。一个线程在等IO返回时,操作系统不会把它占用的CPU时间片让给其他线程——它就在那干等着。200个线程全卡在HTTP调用上,就算服务器CPU闲得发慌,新请求也进不来。
你可能会说:那把线程池调到500不就行了?
试过。线程数翻倍,内存占用直接起飞,CPU上下文切换开销也跟着涨。500个线程全堵在IO上的时候,情况甚至更糟——因为现在有500个线程在抢CPU做上下文切换,真正干活的没几个。
这不是线程配置的问题,是模型的问题。线程和IO等待之间的矛盾,是操作系统的底层约束,靠调参数解决不了。
JDK 21引入的虚拟线程,把这个矛盾拆开了:
虚拟线程由JVM调度,不再跟操作系统线程一一绑定
。
具体来说,当一个虚拟线程发起阻塞IO调用时,JVM会把它从载体线程(Carrier Thread)上卸下来,载体线程立刻去执行另一个虚拟线程。等IO返回,JVM再把这个虚拟线程挂到任意一个空闲的载体线程上继续跑。
这就意味着:阻塞不再消耗操作系统线程。你可以创建几万个虚拟线程,每个成本只有几百字节,而且阻塞不会阻塞载体线程。
对Tomcat来说,效果是立竿见影的:以前每个HTTP请求绑一个平台线程,线程池满了就排队。现在每个请求分配一个虚拟线程,用完就释放,不存在"池子"的概念。
Spring Boot 3.2开始内置虚拟线程支持。只需要在application.yml里加一行:
spring: threads: virtual: enabled: true
就这一行,Spring MVC的请求处理线程、@Async异步任务、TaskExecutor调度,全部自动切换为虚拟线程。你的Controller代码一行不改。
验证一下是否生效:
@RestControllerpublic class ThreadCheckController { @GetMapping("/thread-info") public MapthreadInfo { Thread current = Thread.currentThread; return Map.of( "threadName", current.getName, // 虚拟线程名以 "" 开头 "isVirtual", current.isVirtual, // JDK 21新增方法 "threadId", current.threadId ); }}
启动后访问接口,返回"isVirtual": true就说明虚拟线程已接管。
来看看对比效果。我写了一个压测接口,模拟调用第三方服务(Sleep 500ms模拟IO等待):
@RestControllerpublic class OrderController { private final RestClient restClient = RestClient.create; @GetMapping("/order/{id}") public String getOrder(@PathVariable String id) { // 模拟调用第三方物流接口,平均耗时500ms String logistics = restClient .get .uri("http://localhost:8081/logistics/" + id) .retrieve .body(String.class); // 模拟数据库查询,耗时100ms sleepQuietly(100); return "订单 " + id + " 物流状态: " + logistics; } private void sleepQuietly(long millis) { try { Thread.sleep(millis); } catch (InterruptedException e) { Thread.currentThread.interrupt; } }}
用wrk压测,1000并发持续30秒:
指标
平台线程(maxThreads=200)
虚拟线程
QPS3121106平均延迟2860ms821msP99延迟5200ms1480ms活跃线程数200(池满)18500+CPU使用率22%35%
QPS涨了3.5倍,P99延迟从5秒降到1.5秒以内。而且活跃线程数飙到18000多,内存却没有明显增长——每个虚拟线程只占几百字节。
如果在虚拟线程里进了synchronized块,JVM无法在阻塞时把虚拟线程从载体线程上卸下来——这叫Pinning。载体线程被钉住,其他虚拟线程就少了一个可以用的载体线程。
检测方法:启动时加JVM参数 -Djdk.tracePinnedThreads=short,一旦发生Pinning,控制台会打印警告和堆栈。
修复:把热点路径上的synchronized换成ReentrantLock:
// 改前:synchronized可能钉住虚拟线程private final Object lock = new Object;public void process { synchronized (lock) { // 虚拟线程进入synchronized块 doBlockingIO; // 阻塞IO导致载体线程被"钉住" }}// 改后:ReentrantLock不影响虚拟线程调度private final ReentrantLock lock = new ReentrantLock;public void process { lock.lock; // 获取锁,JVM可以正常卸载虚拟线程 try { doBlockingIO; // 阻塞IO时虚拟线程被卸下,载体线程不阻塞 } finally { lock.unlock; }}
Spring Boot 3.2+内部已经把关键路径的synchronized都换成了ReentrantLock,大多数项目不需要自己改。但如果你的代码或第三方Jar包里有高频的synchronized块,建议排查一下。
坑二:连接池反而要调大
传统线程池模型下,数据库连接数通常设得比线程数小(比如200线程配20连接),靠排队复用。切虚拟线程后,同时活跃的虚拟线程可能有上千个,如果数据库连接池还是20个,大量虚拟线程会排队等连接,吞吐量反而上不去。
建议:虚拟线程模式下把HikariCP的maximumPoolSize调到与并发量匹配的水平(比如从20调到100),同时确保数据库端能承受这个连接数:
spring: datasource: hikari: maximum-pool-size: 100 # 虚拟线程模式下适当调大
坑三:不要让下游被打垮
虚拟线程让上游吞吐量暴涨,下游(数据库、第三方API)可能扛不住。务必在调用外部服务时加并发控制:
// 用Semaphore限制对第三方API的并发调用private final Semaphore apiLimiter = new Semaphore(50);public String callExternalApi(String param) { apiLimiter.acquire; // 最多50个虚拟线程同时调用 try { return restClient.get .uri("https://api.example.com/data?q=" + param) .retrieve .body(String.class); } finally { apiLimiter.release; // 释放许可,让等待的虚拟线程进入 }}
虚拟线程不是银弹。CPU密集型计算(比如视频转码、图像处理)不适合虚拟线程——因为计算过程不会触发IO阻塞,虚拟线程无法被卸载,反而多了调度开销。这种场景老老实实用平台线程+ForkJoinPool。
但99%的Java后端业务都是IO密集型:查数据库、调RPC、读Redis、发HTTP请求。对这类场景,虚拟线程就是最优解。
一行配置,零代码改造,吞吐量翻3倍。Java后端的并发模型从"线程池调参"进化到了"一行开关",这是JDK 21给所有Java开发者最好的礼物。