bigywater.tech
Spring · 75

内嵌容器:Tomcat、Jetty、Undertow 的差别

该选 Tomcat 还是 Jetty?Undertow 还能用吗?两个都在 classpath 上谁赢?虚拟线程在两个容器上效果一样吗?同样的畸形请求两个容器怎么回应?这篇用同一份代码只换 classpath 上的容器,逐项实测。

Jetty
Tomcat 与 Jetty 的 starter 同时在 classpath 上,实际运行的是 Jetty
6467 vs 3519
开了虚拟线程的阻塞接口:Tomcat 6467 请求/秒,Jetty 默认线程池上限下只有 3519
60.1 秒 vs 30.0 秒
默认配置下服务端关闭空闲连接的时间,Tomcat 与 Jetty

classpath 与 Undertow

实测 · 本机执行输出
  Tomcat:classpath 共 39 个 jar,合计 18.7 MB;名字里带 tomcat / jetty 的 6 个:
      spring-boot-starter-tomcat-4.0.8
      spring-boot-starter-tomcat-runtime-4.0.8
      spring-boot-tomcat-4.0.8
      tomcat-embed-core-11.0.24
      tomcat-embed-el-11.0.24
      tomcat-embed-websocket-11.0.24
  Jetty:classpath 共 77 个 jar,合计 19.9 MB;名字里带 tomcat / jetty 的 33 个:
      jetty-alpn-client-12.1.12
      jetty-annotations-12.1.12
      jetty-client-12.1.12
      jetty-compression-common-12.1.12
      jetty-compression-gzip-12.1.12
      jetty-compression-server-12.1.12
      jetty-ee-webapp-12.1.12
      jetty-ee11-annotations-12.1.12
      jetty-ee11-plus-12.1.12
      jetty-ee11-servlet-12.1.12
      jetty-ee11-webapp-12.1.12
      jetty-ee11-websocket-jakarta-client-12.1.12
      jetty-ee11-websocket-jakarta-common-12.1.12
      jetty-ee11-websocket-jakarta-server-12.1.12
      jetty-ee11-websocket-jetty-server-12.1.12
      jetty-ee11-websocket-servlet-12.1.12
      jetty-http-12.1.12
      jetty-io-12.1.12
      jetty-plus-12.1.12
      jetty-security-12.1.12
      jetty-server-12.1.12
      jetty-session-12.1.12
      jetty-util-12.1.12
      jetty-websocket-core-client-12.1.12
      jetty-websocket-core-common-12.1.12
      jetty-websocket-core-server-12.1.12
      jetty-websocket-jetty-api-12.1.12
      jetty-websocket-jetty-common-12.1.12
      jetty-websocket-jetty-server-12.1.12
      jetty-xml-12.1.12
      spring-boot-jetty-4.0.8
      spring-boot-starter-jetty-4.0.8
      spring-boot-starter-jetty-runtime-4.0.8
  Tomcat → factory Bean=[tomcatServletWebServerFactory, tomcatServletWebServerFactoryCustomizer];本次请求的处理线程=http-nio-9601-exec-1;classpath 上能加载到的容器类:Tomcat 11.0.24.0
  Jetty → factory Bean=[jettyServletWebServerFactory];本次请求的处理线程=qtp1019009267-44;classpath 上能加载到的容器类:Jetty 12.1.12
  Tomcat+Jetty 都在 classpath → factory Bean=[jettyServletWebServerFactory, tomcatServletWebServerFactoryCustomizer];本次请求的处理线程=qtp1385140107-45;classpath 上能加载到的容器类:Tomcat 11.0.24.0
  Maven Central 上 spring-boot-starter-undertow 的最新版本:4.0.0-M1(查询时 4.0.8 的这个 pom 返回 404);spring-boot-starter-jetty 最新:4.2.0-M2

Tomcat 方案 39 个 jar,Jetty 方案 77 个 jar;两个 starter 同时在时跑的是 Jetty;Undertow 在 Boot 4.0.8 里已经没有 starter。

启动与内存

实测 · 本机执行输出
  Tomcat:端口可连接 中位 805 ms(范围 767~859);第 1 次请求 35.9 ms,第 2 次 1.4 ms;常驻内存 启动后 163 MB,请求 2 次后 169 MB
  Jetty:端口可连接 中位 860 ms(范围 840~900);第 1 次请求 47.0 ms,第 2 次 1.4 ms;常驻内存 启动后 170 MB,请求 2 次后 174 MB

两者相差几十毫秒、几个 MB,范围略有重叠。

吞吐与虚拟线程

实测 · 本机执行输出
  -- Tomcat 平台线程:处理线程 → name=[http-nio-9618-exec-2] virtual=false
    /fast,并发 64:两轮吞吐 65978 / 66323 请求/秒;p50 0.9 / 0.9 ms;p99 2.2 / 2.2 ms;错误 0 / 0
    /fast,并发 256:两轮吞吐 70311 / 71894 请求/秒;p50 3.4 / 3.3 ms;p99 7.9 / 7.9 ms;错误 0 / 0
    /slow50(sleep 50 ms),并发 400:两轮吞吐 3681 / 3689 请求/秒;p50 110.4 / 110.3 ms;p99 120.9 / 119.1 ms;错误 0 / 0
  -- Jetty 平台线程:处理线程 → name=[qtp1477520324-44] virtual=false
    /fast,并发 64:两轮吞吐 66206 / 63813 请求/秒;p50 0.9 / 0.9 ms;p99 2.1 / 2.2 ms;错误 0 / 0
    /fast,并发 256:两轮吞吐 68239 / 70616 请求/秒;p50 3.5 / 3.4 ms;p99 8.1 / 8.0 ms;错误 0 / 0
    /slow50(sleep 50 ms),并发 400:两轮吞吐 3568 / 3544 请求/秒;p50 58.3 / 58.6 ms;p99 375.2 / 369.3 ms;错误 0 / 0
  -- Tomcat + spring.threads.virtual.enabled=true:处理线程 → name=[tomcat-handler-1] virtual=true
    /fast,并发 64:两轮吞吐 69260 / 66429 请求/秒;p50 0.9 / 0.9 ms;p99 1.9 / 2.2 ms;错误 0 / 0
    /fast,并发 256:两轮吞吐 70228 / 70483 请求/秒;p50 3.4 / 3.4 ms;p99 8.2 / 7.8 ms;错误 0 / 0
    /slow50(sleep 50 ms),并发 400:两轮吞吐 6467 / 6478 请求/秒;p50 62.6 / 62.9 ms;p99 79.7 / 71.2 ms;错误 0 / 0
  -- Jetty + spring.threads.virtual.enabled=true:处理线程 → name=[jetty-2] virtual=true
    /fast,并发 64:两轮吞吐 70376 / 69378 请求/秒;p50 0.9 / 0.9 ms;p99 2.0 / 2.0 ms;错误 0 / 0
    /fast,并发 256:两轮吞吐 77604 / 76076 请求/秒;p50 3.1 / 3.2 ms;p99 7.2 / 7.3 ms;错误 0 / 0
    /slow50(sleep 50 ms),并发 400:两轮吞吐 3519 / 3579 请求/秒;p50 116.2 / 113.4 ms;p99 174.1 / 172.0 ms;错误 0 / 0
  -- Jetty 平台线程 + server.jetty.threads.max=1000:处理线程 → name=[qtp1227903899-44] virtual=false
    /fast,并发 64:两轮吞吐 62174 / 64547 请求/秒;p50 1.0 / 0.9 ms;p99 2.3 / 2.2 ms;错误 0 / 0
    /fast,并发 256:两轮吞吐 67864 / 66823 请求/秒;p50 3.5 / 3.6 ms;p99 8.0 / 7.9 ms;错误 0 / 0
    /slow50(sleep 50 ms),并发 400:两轮吞吐 7309 / 7326 请求/秒;p50 55.0 / 54.6 ms;p99 61.6 / 61.1 ms;错误 0 / 0
  -- Jetty + 虚拟线程 + server.jetty.threads.max=1000:处理线程 → name=[jetty-2] virtual=true
    /fast,并发 64:两轮吞吐 70437 / 70505 请求/秒;p50 0.9 / 0.9 ms;p99 1.9 / 2.0 ms;错误 0 / 0
    /fast,并发 256:两轮吞吐 77987 / 78802 请求/秒;p50 3.1 / 3.1 ms;p99 7.1 / 6.7 ms;错误 0 / 0
    /slow50(sleep 50 ms),并发 400:两轮吞吐 6600 / 6502 请求/秒;p50 60.8 / 62.1 ms;p99 72.8 / 73.6 ms;错误 0 / 0
  -- Tomcat 平台线程 + server.tomcat.threads.max=1000:处理线程 → name=[http-nio-9624-exec-2] virtual=false
    /fast,并发 64:两轮吞吐 65479 / 67622 请求/秒;p50 0.9 / 0.9 ms;p99 2.1 / 2.1 ms;错误 0 / 0
    /fast,并发 256:两轮吞吐 68285 / 68943 请求/秒;p50 3.5 / 3.4 ms;p99 8.3 / 8.4 ms;错误 0 / 0
    /slow50(sleep 50 ms),并发 400:两轮吞吐 7295 / 7308 请求/秒;p50 54.8 / 54.8 ms;p99 63.5 / 63.4 ms;错误 0 / 0

快接口两者同一量级;阻塞接口取决于线程池上限,Jetty 开虚拟线程之后还要调大 server.jetty.threads.max。

边界行为

实测 · 本机执行输出
  -- 请求头 X-Big 的长度(默认的请求头上限):
    X-Big 4000 字节:Tomcat → 200 ;Jetty → 200 OK
    X-Big 7000 字节:Tomcat → 200 ;Jetty → 200 OK
    X-Big 8000 字节:Tomcat → 200 ;Jetty → 200 OK
    X-Big 9000 字节:Tomcat → 400 ;Jetty → 431 Request Header Fields Too Large
    X-Big 70000 字节:Tomcat → 400 ;Jetty → 431 Request Header Fields Too Large
  -- 路径写法(原样发送,不经过客户端规范化):
    GET /echo/a/b
        Tomcat:200   uri=/echo/a/b servletPath=/echo/a/b pathInfo=null
        Jetty:200 OK  uri=/echo/a/b servletPath=/echo/a/b pathInfo=null
    GET /echo/a%2Fb
        Tomcat:400   <!doctype html><html lang="en"><head><title>HTTP Status 400 – Bad Request</title><style type="text/css">body {
        Jetty:400 Bad Request  <html> <head> <meta http-equiv="Content-Type" content="text/html;charset=ISO-8859-1"/> <title>Error 400 Ambigu
    GET /echo/a//b
        Tomcat:200   uri=/echo/a//b servletPath=/echo/a/b pathInfo=null
        Jetty:400 Bad Request  <html> <head> <meta http-equiv="Content-Type" content="text/html;charset=ISO-8859-1"/> <title>Error 400 Ambigu
    GET /echo/a/./b
        Tomcat:200   uri=/echo/a/./b servletPath=/echo/a/b pathInfo=null
        Jetty:200 OK  uri=/echo/a/./b servletPath=/echo/a/b pathInfo=null
    GET /echo/a/../b
        Tomcat:200   uri=/echo/a/../b servletPath=/echo/b pathInfo=null
        Jetty:200 OK  uri=/echo/a/../b servletPath=/echo/b pathInfo=null
    GET /echo/%2e%2e/b
        Tomcat:200   uri=/echo/%2e%2e/b servletPath=/b pathInfo=null
        Jetty:400 Bad Request  <html> <head> <meta http-equiv="Content-Type" content="text/html;charset=ISO-8859-1"/> <title>Error 400 Ambigu
    GET /echo/a;x=1/b
        Tomcat:200   uri=/echo/a;x=1/b servletPath=/echo/a/b pathInfo=null
        Jetty:200 OK  uri=/echo/a;x=1/b servletPath=/echo/a/b pathInfo=null
    GET /echo/a%00b
        Tomcat:400   <!doctype html><html lang="en"><head><title>HTTP Status 400 – Bad Request</title><style type="text/css">body {
        Jetty:400 Bad Request  <html> <head> <meta http-equiv="Content-Type" content="text/html;charset=ISO-8859-1"/> <title>Error 400 Bad Re
    GET /echo/a%5Cb
        Tomcat:400   <!doctype html><html lang="en"><head><title>HTTP Status 400 – Bad Request</title><style type="text/css">body {
        Jetty:400 Bad Request  <html> <head> <meta http-equiv="Content-Type" content="text/html;charset=ISO-8859-1"/> <title>Error 400 Suspic
    GET /echo/a\b
        Tomcat:400   <!doctype html><html lang="en"><head><title>HTTP Status 400 – Bad Request</title><style type="text/css">body {
        Jetty:400 Bad Request  <html> <head> <meta http-equiv="Content-Type" content="text/html;charset=ISO-8859-1"/> <title>Error 400 Suspic
  -- 加上 server.max-http-request-header-size=16KB:
    X-Big 9000 字节:Tomcat → 200 ;Jetty → 200 OK
    X-Big 15000 字节:Tomcat → 200 ;Jetty → 200 OK
    X-Big 20000 字节:Tomcat → 400 ;Jetty → 431 Request Header Fields Too Large
  -- 连接空闲多久被服务端关掉(默认配置,发一个请求后什么都不发,最长等 120 秒):
    Tomcat:60.1 秒后服务端关闭了连接
    Jetty:30.0 秒后服务端关闭了连接

超大请求头 400 与 431、// 与 %2e%2e 的路径、空闲连接超时 60 秒与 30 秒,两个容器都不一样。

分步演示

内嵌容器
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / OpenJDK 21.0.12(Homebrew) / Spring Boot 4.0.8(Spring Framework 7.0.9、Tomcat 11.0.24) / Spring Cloud 2025.1.3;用 javac 编译、java 直接运行,依赖由 Maven 从 Maven Central 解析;数字每次运行会有小幅抖动。同一份应用代码(spring-boot-starter-webmvc,一个 RestController),只换 classpath;Jetty 方案是把 spring-boot-starter-jetty 的依赖解析出来,再从 classpath 里去掉 tomcat-embed、spring-boot-tomcat、spring-boot-starter-tomcat 的 jar;压测端是 JDK HttpClient + 虚拟线程,与被测应用在同一台机器上,每个组合 2 轮,所以绝对数值只能在表内比较;Maven Central 的查询是用 maven-metadata.xml 和直接请求 4.0.8 的 pom
    • 没有验证:HTTP/2、TLS、压缩、WebSocket 在两个容器上的差别;长时间运行的内存与 GC;更大的并发度和更大的响应体;请求头 400 与 431 的差别背后各自是什么配置项(只观察到结果);Jetty 虚拟线程为什么在默认线程池上限下被卡住(只观察到加大 threads.max 之后恢复,没有读源码);Undertow 在其他 Boot 版本里的表现;Tomcat 的 keep-alive 超时与 Jetty 的 idle timeout 是不是同一种含义(这里只测了「发一个请求后空闲多久被服务端关闭」)
    京ICP备2026067502号-1京公网安备11010502063487号