Tomcat、Jetty 与 Undertow
把同一个 Servlet 放进 Tomcat、Jetty 和 Undertow,URL 映射、请求对象和响应 API 有共同的规范。监听连接、调度任务、管理线程和装配应用,则由各自实现。选择容器时,先确认应用能部署,再检查阻塞代码将在哪里执行,最后用真实负载比较延迟与资源消耗。
先对齐 Java、Servlet 和框架版本
同名产品的版本线可能提供不同能力
下面固定到可下载的实现版本,便于复现;升级补丁时仍需重新核对各产品的要求。
| 实验实现 | Servlet 能力 | Java 最低要求 | 对应依赖 |
|---|---|---|---|
| Tomcat 11.0.25 | Jakarta Servlet 6.1 | Java 17 | org.apache.tomcat.embed:tomcat-embed-core |
| Jetty 12.1.12 的 EE11 模块 | Jakarta Servlet 6.1 | Java 17 | org.eclipse.jetty.ee11:jetty-ee11-servlet |
| Undertow 2.3.26.Final 的 Servlet 模块 | Jakarta Servlet 6.0 | Java 11 | io.undertow:undertow-servlet |
Tomcat 的规范对应关系见 Tomcat 版本矩阵;Jetty 12.1 支持多个 EE 环境,使用 EE11 依赖才能得到这里讨论的 Servlet 6.1 环境,见 Jetty 发布与兼容信息。Undertow 这一版本声明的 Servlet 6.0 依赖和 Java 编译目标可直接核对 Undertow 2.3.26.Final POM。
Undertow core 和 Servlet 集成层的发布线已经分开:undertow-core 存在 2.4 系发布,不能由此推导出相同版本的 undertow-servlet,也不能把它标成 Servlet 6.1 容器。依赖解析时分别检查 undertow-core 发布元数据与 undertow-servlet 发布元数据。下面的 Undertow 工程让 core 与 servlet 保持在同一条 2.3.26.Final 依赖线上。
全组示例使用 Temurin 25 运行,源码以 Java 17 为编译目标。Java 版本高于最低要求只解决运行时的一个条件,应用还可能依赖本地库、字节码代理或已经移除的内部 API。
Spring Boot 的支持范围需要单独核对
Spring Boot 4.1.1 的嵌入式容器选项包括 Tomcat 11 与 Jetty 12.1;外部部署要求 Servlet 6.1 或更新的兼容环境。Undertow 2.3 的 Servlet 6.0 不能直接代入这一要求。具体版本以 Spring Boot 系统要求为准。
因此,容器迁移通常涉及三组调整:
| 调整对象 | 要检查的内容 |
|---|---|
| Maven 依赖和自动配置 | 移除原容器后,引入框架明确支持的替代模块 |
| 启动、注册与打包 | 嵌入式 main、可执行 JAR 和外部 WAR 的装配方式不同 |
| 容器专属配置 | 线程池、代理地址信任、访问日志、上传限制、错误页与优雅停止需要分别迁移 |
历史应用若还使用 javax.servlet.*,也需要先处理命名空间迁移。Jakarta Servlet 5 及以后使用 jakarta.servlet.*;应用、Filter、第三方库和部署描述符应一起检查。规范入口见 Jakarta Servlet 6.1。
在三个容器上运行同一 HTTP 契约
完整工程与构建环境
下载三个容器实验工程,解压进入 servlet-container-comparison/。目录中有三个独立模块,每个都包含完整 POM、src/main/java/lab/Server.java 和集成测试;Undertow 模块另有原生 Handler 派发测试。
servlet-container-comparison/
├── pom.xml Maven 聚合工程
├── tomcat/
│ ├── pom.xml
│ └── src/ 嵌入式 Tomcat 与 HTTP 测试
├── jetty/
│ ├── pom.xml
│ └── src/ EE11 ServletContextHandler 与 HTTP 测试
└── undertow/
├── pom.xml
└── src/ DeploymentManager、HTTP 与线程派发测试Linux 宿主安装 Docker 与 curl。工作目录和 Maven 缓存由当前普通用户拥有,构建容器与运行容器均显式使用宿主 UID/GID,不需要 sudo 修改工程权限。
BUILD_IMAGE=maven:3.9.12-eclipse-temurin-25
RUN_IMAGE=eclipse-temurin:25.0.4_7-jdk
mkdir -p .m2
docker run --rm --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/cache -v "$PWD/.m2:/cache" \
-v "$PWD:/work" -w /work "$BUILD_IMAGE" \
mvn -B -Dmaven.repo.local=/cache/repository clean verify三个模块分别有 1、1、2 个测试,应全部通过。共同的输出为:
GET=200 UTF8=true POST=405测试发送真实 HTTP 请求,断言 GET 的内容类型包含 UTF-8、正文精确等于 ok:深度\n,并确认未实现的 POST 返回 405。Undertow 模块还应输出:
nativeHandlerIo=true afterDispatchIo=false构建出现依赖下载失败时先检查仓库连通性;出现 UnsupportedClassVersionError 时检查实际运行的 Java;某一模块测试失败时,用 -pl 模块名 单独重跑,并查看对应 target/surefire-reports/。聚合构建失败后不要继续拿旧 target 当作新产物运行。
Tomcat 模块的完整起点
下面是 Tomcat 模块的全部 POM。其余两个模块只替换实现依赖,并采用各自的服务器装配 API,完整文件包含在下载包中。
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>example</groupId><artifactId>servlet-tomcat-lab</artifactId><version>1.0.0</version>
<properties><maven.compiler.release>17</maven.compiler.release><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties>
<dependencies>
<dependency><groupId>org.apache.tomcat.embed</groupId><artifactId>tomcat-embed-core</artifactId><version>11.0.25</version></dependency>
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><version>5.13.4</version><scope>test</scope></dependency>
</dependencies>
<build><plugins>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.14.1</version><configuration><compilerArgs><arg>-Xlint:all</arg><arg>-Werror</arg></compilerArgs></configuration></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>3.5.4</version></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-dependency-plugin</artifactId><version>3.8.1</version><executions><execution><id>runtime</id><phase>package</phase><goals><goal>copy-dependencies</goal></goals><configuration><includeScope>runtime</includeScope></configuration></execution></executions></plugin>
</plugins></build>
</project>src/main/java/lab/Server.java 把临时目录、监听器与 Servlet 放在同一个可关闭对象中。启动失败也会执行关闭逻辑;正常停止则由 JVM shutdown hook 处理。
package lab;
import java.io.IOException;
import java.nio.file.*;
import java.util.Comparator;
import jakarta.servlet.http.*;
import org.apache.catalina.*;
import org.apache.catalina.startup.Tomcat;
public final class Server implements AutoCloseable {
private final Tomcat server=new Tomcat();
private final Path base;
public Server(int port)throws IOException{
base=Files.createTempDirectory("tomcat-comparison-");
server.setBaseDir(base.toString());server.setPort(port);server.getConnector();
Context context=server.addContext("",base.toString());context.setParentClassLoader(Server.class.getClassLoader());
Tomcat.addServlet(context,"hello",new Hello());context.addServletMapping("/*","hello");
}
public void start()throws LifecycleException{server.start();}
public int port(){return server.getConnector().getLocalPort();}
@Override public void close()throws IOException {
try{server.stop();server.destroy();}catch(LifecycleException e){throw new IOException(e);}
finally{try(var files=Files.walk(base)){for(Path p:files.sorted(Comparator.reverseOrder()).toList())Files.deleteIfExists(p);}}
}
public static final class Hello extends HttpServlet {
private static final long serialVersionUID=1L;
@Override protected void doGet(HttpServletRequest request,HttpServletResponse response)throws IOException {
response.setContentType("text/plain;charset=UTF-8");response.getWriter().write("ok:深度\n");
}
}
public static void main(String[] args)throws Exception {
Server server=new Server(args.length==0?8080:Integer.parseInt(args[0]));
try{server.start();}catch(Exception failure){try{server.close();}catch(IOException cleanup){failure.addSuppressed(cleanup);}throw failure;}
Runtime.getRuntime().addShutdownHook(new Thread(()->{try{server.close();}catch(IOException e){e.printStackTrace();}}));
System.out.println("LISTENING "+server.port());
new java.util.concurrent.CountDownLatch(1).await();
}
}这里使用 write("ok:深度\n") 明确规定响应中的换行字节。实际对照时,三个实现的 PrintWriter.println() 行尾并不完全相同;文本协议若要求固定字节,直接写出约定的行尾更稳妥。普通网页里显示相同的两段文本,也可能在逐字节测试中有差异。
切换模块,只改变容器实现
先以 Tomcat 为例。容器只发布宿主回环端口,应用目录只读,临时文件限定在 64 MiB 的 tmpfs 中:
IMPL=tomcat
docker run --detach --name servlet-compare \
--user "$(id -u):$(id -g)" --read-only \
--cap-drop ALL --security-opt no-new-privileges \
--tmpfs /tmp:rw,nosuid,nodev,size=64m,mode=1777 \
-p 127.0.0.1:18090:8080 \
-v "$PWD/$IMPL/target:/app:ro" -w /app \
"$RUN_IMAGE" java -cp 'classes:dependency/*' lab.Server 8080
docker logs servlet-compare日志出现 LISTENING 8080 后请求:
BASE=http://127.0.0.1:18090
curl -q --noproxy '*' --fail-with-body --max-time 5 -i "$BASE/"
STATUS=$(curl -q --noproxy '*' --max-time 5 -sS \
-o post-response.txt -w '%{http_code}' -X POST "$BASE/")
TRANSPORT=$?
test "$TRANSPORT" -eq 0 && test "$STATUS" = 405
docker stop --time 15 servlet-compare
docker rm servlet-compare正常 GET 返回 200 和 ok:深度。POST 是预期负例,所以不使用将 405 转成命令失败的 --fail-with-body,而是先确认 curl 传输退出码为 0,再检查 HTTP 405。任一判断失败都应查看保存的响应和容器日志,不把已经收到错误响应头视为完整实验结果。
依次把 IMPL 改为 jetty、undertow,重复启动、请求与清理。不要同时复用相同容器名或宿主端口。运行期间可用 docker exec servlet-compare id 检查身份;停止后,匿名 tmpfs 中的临时内容随容器释放。
这组检查覆盖基本请求处理。Session 持久化、反向代理、HTTP/2、TLS 和 WebSocket 尚未启用,迁移这些能力时需要补各自的测试。
三种执行模型怎样处理阻塞
Tomcat:连接就绪之后仍要等待执行资源
常见 NIO Connector 的主要职责可以按下面的调用方向理解:
监听 socket
└─ Acceptor:接受连接
└─ Poller:观察连接的 I/O 就绪状态
└─ Executor:执行协议处理任务
└─ Coyote Adapter
└─ Engine → Host → Context → Wrapper
└─ Filter chain → ServletPoller 观察到可读事件,并不意味着 Servlet 已经获得执行线程。Executor 达到容量后,任务还可能排队。Servlet 内调用慢 SQL、远程 HTTP 或锁等待时,占用的是执行该调用的线程;底层使用 NIO 并不会自动把这些阻塞转换成异步业务。
Tomcat 的 maxConnections、acceptCount、maxThreads 分别涉及接入连接数量、达到接入限制后操作系统接收队列的容量设置、内部执行线程上限,不能互相替代。acceptCount 也不是“Servlet 待执行请求数”。使用共享 Executor 后,Connector 的某些线程属性不再生效,应到 Executor 配置核对。属性适用条件见 Tomcat HTTP Connector与 Tomcat Executor。
一个 Keep-Alive 连接可以先后承载多个请求;HTTP/2 连接还可以承载多个并发流。按“连接数乘一个业务线程”计算容量,会混淆连接驻留与应用执行两个阶段。
Jetty:线程池也执行容器内部工作
Jetty 的 Connector 接受连接,Selector 观察 I/O,执行策略决定由当前线程继续处理任务,还是把任务交给其他线程。Adaptive Execution Strategy 会结合任务的阻塞特征和可用执行资源选择方式。
配置 QueuedThreadPool 时要给内部职责留位置。部分线程由 Connector 的 acceptor、selector 等组件租用,应用能使用的数量应结合这些租用开销计算。若不分用途地把线程数压得很低,可能先阻塞推进请求所需的内部任务。
Jetty 官方还特别说明了内部任务队列与普通业务限流的区别:一次请求可能产生多个任务,简单限制线程池队列长度会拒绝维持连接或协议进展所必需的任务。通常应在请求入口或业务资源处做并发限制,而不是拿一个随意缩小的内部队列代替过载控制。执行策略、租用线程和队列设计见 Jetty 线程架构。
使用虚拟线程时也要确认具体集成方式。虚拟线程能降低大量等待线程的成本,数据库连接、外部服务配额和堆内缓冲仍需单独限流。线程数量下降或名称改变,不能替代延迟与成功率测量。
Undertow:原生 Handler 与 Servlet 入口要分开看
Undertow 原生 Handler 可能直接在 I/O 线程收到 HttpServerExchange。在这个位置执行阻塞数据库调用,会阻挡同一 I/O 线程推进其他连接。需要阻塞处理时先派发到 worker:
完整写法如下:
HttpHandler handler = new HttpHandler() {
@Override
public void handleRequest(HttpServerExchange exchange) throws Exception {
if (exchange.isInIoThread()) {
exchange.dispatch(this);
return;
}
exchange.getResponseSender().send("worker");
}
};需要导入 io.undertow.server.HttpHandler 与 io.undertow.server.HttpServerExchange,然后把 handler 交给 Undertow.builder().setHandler(handler)。工程中的 NativeDispatchTest 使用同一机制,真实检查入口 isInIoThread()==true,派发后为 false。派发与响应完成语义可核对 Undertow HttpServerExchange 实现。
Servlet 集成层另有部署、请求上下文和初始派发流程,会把普通阻塞 Servlet 的执行交给 worker。不要把“原生 Handler 可能运行于 I/O 线程”照搬成“每个 Servlet 都必须手动 dispatch”。具体适配路径见 Undertow ServletInitialHandler。
等待在哪里,容量就应在哪里计算
假设一个接口平均占用业务执行线程 200 ms,稳定到达速率为每秒 100 次,则平均约有 20 个请求处于这个执行阶段。这是稳定系统下“平均并发量约等于到达速率乘平均驻留时间”的估计,不是把线程池固定设成 20 的容量结论。
尾延迟、突发、超时重试、连接池等待和 CPU 消耗都会改变所需余量。可按资源分别做测量:
| 资源 | 重点测量 | 扩容前先确认 |
|---|---|---|
| 平台线程或虚拟线程执行任务 | 活跃数、任务排队时间、阻塞栈 | CPU 已饱和,还是在等待外部资源 |
| 数据库连接池 | 等待数、获取耗时、超时数 | 慢事务与连接泄漏是否占住连接 |
| 外部 HTTP 客户端 | 并发、连接数、响应时间、失败率 | 对方限额与超时预算能否承受更高并发 |
| 响应缓冲与上传临时目录 | 占用、增长速率、回收情况 | 大请求与慢客户端是否放大内存或磁盘压力 |
Servlet 异步与非阻塞读写的 API 约束见请求线程、异步与非阻塞 I/O。异步处理把等待从容器初始执行线程移走后,还需要给后台任务与未完成请求设置上限。
迁移验证应覆盖正常路径和失败路径
把配置差异变成可观察的检查
先保留相同 JDK、应用版本、数据集和请求模型,再切换容器。否则一个依赖升级、GC 参数或协议差异,就可能掩盖容器变化的影响。
| 检查面 | 正常请求 | 必须加入的异常或限制 |
|---|---|---|
| 路由与分派 | context、精确映射、路径参数 | 404、ERROR 分派、异步重复分派 |
| 表单与上传 | UTF-8、多文件、重复字段 | 超限、缺失 boundary、临时目录不可写 |
| Session 与 Cookie | 创建、续用、退出 | 过期、节点切换、同名 Cookie 的 Path |
| 代理和安全 | 正确 scheme、host、客户端地址 | 伪造转发头、非受信代理、直接访问后端 |
| 生命周期 | 就绪、停止、连接排空 | 慢请求、后台任务、停止期限到达 |
| 协议 | 实际启用的 HTTP/1.1、HTTP/2 | 慢读、断连、头部超限、复用连接 |
对照时同时记录客户端状态码、响应字节、容器日志和资源指标。客户端超时可能发生在连接建立、排队、应用处理或下载阶段;服务器返回 200 后也可能继续遇到写出失败。单独比较每秒请求数,会遗漏这些情况。
常见失败的第一步
| 现象 | 第一步 |
|---|---|
| 启动时找不到 Servlet 类 | 先检查 javax 与 jakarta、实际 runtime 依赖以及 Java 版本,再看业务代码。 |
| 换容器后线程参数无效 | 确认参数属于当前产品、当前版本和实际使用的 Executor;嵌入式配置还要检查是否被框架覆盖。 |
| 低负载正常,高负载连接超时 | 区分监听接入限制与业务执行队列,采集连接数、线程栈和下游等待,不先盲目增加线程。 |
| Undertow 原生 Handler 卡住多条连接 | 查看栈是否停留在 I/O 线程的阻塞调用;正确派发后,再检查 worker 与下游容量。 |
| Jetty 缩小队列后出现难解释的失败 | 恢复支持内部调度的配置,在 HTTP 入口设计明确的请求并发限制。 |
生产切换前保留旧部署与回退步骤,用小流量验证新增错误、延迟分位数和资源趋势。回退时还要考虑已经改变的 Session 格式、数据库结构或客户端状态;仅还原容器 JAR 不能撤销这些外部变化。
权威资料与规范地址
完整契约、配置选项和实现细节可按下列地址查阅。
