虚拟线程时代,Tomcat 又赢了,Jetty 的"严父"

虚拟线程时代,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
# 不开虚拟线程(注意多了 -Pjetty)
./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 倍。

FyHoh4

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,从平台线程到虚拟线程,它从来没掉过队。

回来吧,我的”汤姆猫”。