虚拟线程时代,Tomcat 又赢了,Jetty 的”严父”
事情是这样的:SpringBoot 4.0 正式版发布后,我想验证一下虚拟线程在不同容器下的表现。Undertow 已经被踢出局了(之前写过一篇《Undertow 凉透了》),现在只剩 Tomcat 和 Jetty 两个选择。
我原本以为,开启虚拟线程后,两个容器的性能应该差不多。毕竟虚拟线程是 JVM 层面的特性,跟容器关系不大吧?
结果呢?差了 15 倍。
我以为测错了,又跑了一遍。没错。

先说结论
如果你用 SpringBoot 4.0 + 虚拟线程,选 Tomcat,别犹豫。
Jetty 在虚拟线程模式下的表现,跟没开虚拟线程几乎一样。
测试环境
为了让结果有参考价值,我把环境说清楚:
- SpringBoot: 4.0.1
- JDK: 25
- 测试机器: MacBook Pro M4, 32GB 内存
- Docker 镜像内存限制: 1024MB
- 压测工具: wrk
- 压测时长: 30 秒
测试接口很简单,一个 /pay 接口,里面有个 1 秒的 Thread.sleep() 模拟 IO 阻塞(比如调用支付网关):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @Service public class PayService {
private static final String[] CHANNELS = new String[]{"ALIPAY", "WECHAT", "UNION", "VISA", "MASTER"};
public String doPay(String orderId) { try { String channel = CHANNELS[ThreadLocalRandom.current() .nextInt(CHANNELS.length)]; Thread.sleep(1000); return "Order %s paid via %s".formatted(orderId, channel); } catch (InterruptedException e) { throw new RuntimeException(e); } } }
|
Controller 也很简单:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| @RestController @RequestMapping("/pay") public class PayController {
private static final Logger log = LoggerFactory.getLogger(PayController.class); private static final AtomicInteger counter = new AtomicInteger(1);
private final PayService payService;
public PayController(PayService payService) { this.payService = payService; }
@GetMapping public String pay( @RequestParam(required = false, defaultValue = "PIG2026") String orderId) { int id = counter.getAndIncrement(); log.info("Request {} processed by {}", id, Thread.currentThread()); String result = payService.doPay(orderId); log.info("Request {} resumed by {}", id, Thread.currentThread()); return result; } }
|
四个镜像,四组对照
我构建了四个 Docker 镜像:
| 镜像名 |
容器 |
虚拟线程 |
| tomcat |
Tomcat |
关闭 |
| tomcat-vt |
Tomcat |
开启 |
| jetty |
Jetty |
关闭 |
| jetty-vt |
Jetty |
开启 |
开启虚拟线程只需要一行配置:
1
| spring.threads.virtual.enabled=true
|
Native Image 构建
这次测试我用的是 GraalVM Native Image。SpringBoot 4.0 对 Native Image 的支持已经相当成熟,构建过程比以前顺滑多了。
构建命令很简单,核心就是加个 -Pnative profile:
构建 Tomcat Native 镜像:
1 2 3 4 5 6 7 8
| ./mvnw clean -DskipTests spring-boot:build-image -Pnative \ -Dspring-boot.build-image.imageName=pig-pay-tomcat:4.0.0-25-native
echo "spring.threads.virtual.enabled=true" >> src/main/resources/application.properties ./mvnw clean -DskipTests spring-boot:build-image -Pnative \ -Dspring-boot.build-image.imageName=pig-pay-tomcat:4.0.0-25-native-vt
|
构建 Jetty Native 镜像:
1 2 3 4 5 6 7 8
| ./mvnw clean -DskipTests spring-boot:build-image -Pjetty,native \ -Dspring-boot.build-image.imageName=pig-pay-jetty:4.0.0-25-native
echo "spring.threads.virtual.enabled=true" >> src/main/resources/application.properties ./mvnw clean -DskipTests spring-boot:build-image -Pjetty,native \ -Dspring-boot.build-image.imageName=pig-pay-jetty:4.0.0-25-native-vt
|
构建完成后,你会得到四个 Native 镜像:
1 2 3 4
| - pig-pay-tomcat:4.0.0-25-native - pig-pay-tomcat:4.0.0-25-native-vt - pig-pay-jetty:4.0.0-25-native - pig-pay-jetty:4.0.0-25-native-vt
|
Native Image 的好处是启动快、内存占用小。但要注意,构建过程比较慢,我这边 M4 MacBook 上大概要 3-5 分钟一个镜像。第一次构建会更久,因为要下载 GraalVM 的 builder 镜像。
压测结果:数据说话
不开虚拟线程的基线对比
先看看不开虚拟线程时,Tomcat 和 Jetty 的表现:
| 并发数 |
Tomcat QPS |
Jetty QPS |
差距 |
| 100 |
95.96 |
94.19 |
基本持平 |
| 500 |
192.76 |
187.08 |
Tomcat 略优 |
| 1000 |
192.92 |
187.06 |
Tomcat 略优 |
| 3000 |
179.49 |
171.11 |
Tomcat 略优 |
| 5000 |
114.23 |
98.66 |
Tomcat 略优 |
两者差距不大,都被 200 线程的天花板锁死了。这符合预期。
开启虚拟线程后
重点来了。开启虚拟线程后:
| 并发数 |
Tomcat-VT QPS |
Jetty-VT QPS |
差距 |
| 100 |
96.45 |
95.53 |
基本持平 |
| 500 |
477.99 |
191.90 |
Tomcat 2.5 倍 |
| 1000 |
947.68 |
191.96 |
Tomcat 5 倍 |
| 3000 |
2699.67 |
178.13 |
Tomcat 15 倍 |
| 5000 |
616.43 |
112.09 |
Tomcat 5.5 倍 |
看到这个数据我愣了。
Tomcat 开启虚拟线程后,QPS 从 179 飙到 2699,提升了 15 倍。

Jetty 呢?从 171 到 178。基本没变。
Jetty 为什么会这样?
我查了半天资料,终于搞明白了。即使开启了虚拟线程,它仍然受到 server.jetty.threads.max=200 的限制。
虚拟线程的意义在于”便宜”——创建和阻塞的成本极低,可以轻松创建成千上万个。但 Jetty 还是用老思维在管理它们,虚拟线程的优势完全发挥不出来。
Tomcat 就豁达多了。开启虚拟线程后,它直接无视了 server.tomcat.threads.max=200 的限制,让虚拟线程自由飞翔。
启动时间对比
顺便看下启动时间,毕竟使用了 Native Image,启动速度应该不错。
| 镜像 |
启动时间 |
| tomcat |
0.643s |
| tomcat-vt |
0.658s |
| jetty |
0.806s |
| jetty-vt |
0.710s |
差距不大,都在 1 秒以内。这个维度两者打平。
如何切换容器
如果你之前用的是 Jetty,切换到 Tomcat 很简单。
原来的 Jetty 配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <exclusions> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jetty</artifactId> </dependency>
|
切换到 Tomcat:直接用默认的 spring-boot-starter-web 就行,不需要任何额外配置。
1 2 3 4
| <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>
|
然后开启虚拟线程:
1
| spring.threads.virtual.enabled=true
|
搞定。
完整脚本放在文末,感兴趣的可以自己跑一遍。
回来吧,我的”汤姆猫”
去年技术社区还在推”用 Undertow 替代 Tomcat”,结果 Undertow 直接被 SpringBoot 4.0 踢出局。
有时候,”稳健的主流”比”激进的最优”更值得信赖。
Tomcat 可能不是最时髦的选择,但它一直在默默进化。从 Servlet 1.0 到 Servlet 6.1,从平台线程到虚拟线程,它从来没掉过队。
回来吧,我的”汤姆猫”。