Servlet 容器与组件生命周期
HttpServlet 是 Java Web 应用的 HTTP 处理组件。应用实现 doGet()、doPost() 等方法;Servlet 容器接收 HTTP 请求、选择应用和组件,再调用这些方法。容器还负责初始化组件、组织 Filter 链、维护请求和会话对象,并在应用停止时执行销毁回调。
Java Web 进程
├─ 网络入口:监听地址、端口、HTTP 解析、连接读写
├─ Servlet 容器
│ ├─ Web 应用 /shop:ServletContext、应用资源和组件注册
│ │ ├─ Filter:在匹配的分派中包围或截断调用
│ │ ├─ Servlet:接收 request / response 并处理请求
│ │ └─ Listener:观察应用、请求、会话事件
│ └─ Web 应用 /admin:另一组组件和配置
└─ JVM:类加载、线程、内存与进程资源Tomcat、Jetty 和 Undertow 提供容器实现;Spring MVC 的 DispatcherServlet 则运行在 Servlet 容器中。Nginx 可以把请求代理给这个进程,普通反向代理本身不会创建 HttpServletRequest 或调用 Java 方法。
把一个 Servlet 启动为 HTTP 服务
规范、运行时和依赖
Jakarta Servlet 6.1 使用 jakarta.servlet.* 包,Tomcat 11.0.x 实现该规范并要求 Java 17 或更高版本。以下实验固定 Tomcat 11.0.25,使用 Temurin 25 运行、Java 17 编译目标;--release 17 限制源码可用 API 和生成的 class 版本,并不会把正在运行的 JVM 切换成 Java 17。版本对应关系可查 Tomcat 官方矩阵,API 契约见 Servlet 6.1 规范。
下载完整实验工程。解压得到 servlet-container-lifecycle/,其中包含:
servlet-container-lifecycle/
├─ pom.xml
├─ src/main/java/lab/
│ ├─ HelloServlet.java
│ └─ Server.java
└─ src/test/java/lab/LifecycleTest.javaLinux 宿主机需要 Docker、curl 和 unzip。使用能够操作 Docker 的普通账号;这个权限可以控制 Docker daemon,仍须按高权限能力管理。构建和应用容器均显式使用宿主 UID/GID,避免让构建输出变成 root 所有。
依赖声明的核心部分为:
<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>
</dependencies>嵌入式应用把 Tomcat 当运行库,因此容器依赖进入运行 classpath。部署到外置 Tomcat 的 WAR 通常改为依赖 jakarta.servlet-api 并使用 provided scope:API 由外置容器提供,不能把另一套 Servlet API 和容器实现同时塞进 WEB-INF/lib。
Servlet 与启动代码各自做什么
HelloServlet.java 的完整代码如下。三个原子计数器用于集成测试观察真实回调;请求路径只存在于局部参数中,没有保存到实例字段。
package lab;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
public final class HelloServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
final AtomicInteger initialized = new AtomicInteger();
final AtomicInteger serviced = new AtomicInteger();
final AtomicInteger destroyed = new AtomicInteger();
@Override public void init() { initialized.incrementAndGet(); }
@Override protected void doGet(HttpServletRequest request,
HttpServletResponse response) throws IOException {
serviced.incrementAndGet();
response.setContentType("text/plain;charset=UTF-8");
response.getWriter().printf("hello%ncontext=%s%nservlet=%s%npathInfo=%s%npattern=%s%ntype=%s%n",
request.getContextPath(), request.getServletPath(), request.getPathInfo(),
request.getHttpServletMapping().getPattern(), request.getDispatcherType());
}
@Override public void destroy() { destroyed.incrementAndGet(); }
}init() 初始化组件;HttpServlet.service() 根据 HTTP 方法分派到 doGet() 等入口;destroy() 接收销毁通知。继承 HttpServlet 还会得到其 HEAD、OPTIONS 等默认处理,具体行为随 Servlet API 版本演进,不需要在每个应用里重写方法分派器。
启动代码显式创建容器和 /shop 应用,再把 /hello 映射给组件。程序使用自己新建的临时目录,关闭时只清理该目录。
package lab;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import org.apache.catalina.Context;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.startup.Tomcat;
public final class Server implements AutoCloseable {
final Tomcat tomcat = new Tomcat();
final Path base;
final Context context;
public Server(int port) throws IOException {
base = Files.createTempDirectory("servlet-lifecycle-");
tomcat.setBaseDir(base.toString());
tomcat.setPort(port);
tomcat.getConnector();
context = tomcat.addContext("/shop", base.toString());
context.setParentClassLoader(Server.class.getClassLoader());
}
public void start() throws LifecycleException { tomcat.start(); }
public int port() { return tomcat.getConnector().getLocalPort(); }
@Override public void close() throws IOException {
try { tomcat.stop(); tomcat.destroy(); }
catch (LifecycleException e) { throw new IOException(e); }
finally {
try (var files = Files.walk(base)) {
for (Path file : files.sorted(Comparator.reverseOrder()).toList()) Files.deleteIfExists(file);
}
}
}
public static void main(String[] args) throws Exception {
int port = args.length == 0 ? 8080 : Integer.parseInt(args[0]);
Server server = new Server(port);
Tomcat.addServlet(server.context, "hello", new HelloServlet()).setLoadOnStartup(1);
server.context.addServletMapping("/hello", "hello");
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(); }
}, "servlet-shutdown"));
System.out.println("LISTENING " + server.port());
server.tomcat.getServer().await();
}
}getConnector() 在这里触发默认 Connector 的创建。addContext() 建立编程式 Context,不读取默认 web.xml,也不自动装上 JSP Servlet;静态资源与 JSP 若需要,应显式注册或使用完整 Web 应用部署方式。两种启动入口的差异见 Tomcat 嵌入式 API。
构建、启动和查看响应
在 Linux 上进入解压目录,依次执行:
BUILD_IMAGE=maven:3.9.12-eclipse-temurin-25
RUN_IMAGE=eclipse-temurin:25.0.4_7-jdk
mkdir -p .m2
docker pull "$BUILD_IMAGE"
docker pull "$RUN_IMAGE"
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正常构建应出现三个测试通过以及 BUILD SUCCESS。target/classes 保存应用 class,target/dependency 保存运行依赖。依赖解析失败时先看第一个下载失败的地址;企业内网可以挂载经过批准的 Maven settings.xml,用 -s /path/settings.xml 指定仓库和代理。不要因为下载受阻而关闭 TLS 校验。离线环境需要提前准备完整 Maven 缓存和两份镜像,可用 docker save / docker load 转移;仅复制项目源码无法完成首次离线构建。
docker run --detach --name servlet-lifecycle \
--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:18086:8080 \
-v "$PWD/target:/app:ro" -w /app \
"$RUN_IMAGE" java -cp 'classes:dependency/*' lab.Server 8080
docker logs servlet-lifecycle
curl -q --noproxy '*' --fail-with-body \
--connect-timeout 3 --max-time 5 \
http://127.0.0.1:18086/shop/hello日志出现 LISTENING 8080 后再请求;初次进程启动需要时间,连接被拒绝时先检查容器是否退出。端口只发布到宿主回环地址。curl 的 -q 禁止读取默认 curlrc,--noproxy '*' 排除代理环境干扰,--fail-with-body 使非预期 HTTP 错误返回非零退出码;选项定义见 curl 手册。
响应为:
hello
context=/shop
servlet=/hello
pathInfo=null
pattern=/hello
type=REQUEST/shop 先选中 Web 应用;应用内的 /hello 再选中 Servlet。精确映射没有剩余路径,所以 pathInfo 为 null。Servlet 名称 hello 是注册标识,与 Java 类名和 URL 可以不同。
浏览器 F12 的 Network 面板中选择这个请求,可以直接核对 URL、GET 方法、状态、Content-Type 和响应正文。浏览器自动请求的 favicon 是另一个请求;它的 404 不应与 /shop/hello 混在一起。
URL 怎样映射到应用组件
Context 选择与应用内匹配
对于 http://127.0.0.1:18086/shop/api/orders/42?view=brief,请求目标可以拆为:
/shop/api/orders/42?view=brief
├─ /shop → Context:选中 Web 应用
├─ /api/orders/42 → 应用内路径:参与 Servlet mapping
└─ view=brief → 查询参数:不参与 mapping 优先级容器先根据接收请求的连接入口和主机信息选择应用,再匹配应用内路径。同一端口可以服务多个 Context;应用自己的 ServletContext 则提供该应用范围内的资源、属性和组件注册信息。把业务用户数据放到 ServletContext 属性,会让整个应用的请求共享它。
常用的四类映射按以下优先级选择:
| 模式 | 示例 | 匹配含义 |
|---|---|---|
| 精确路径 | /api/orders/42 | 完整路径相等 |
| 路径前缀 | /api/* | 从匹配的前缀中选择最长者 |
| 扩展名 | *.json | 当前路径按扩展名匹配 |
| 默认 | / | 前面均未匹配时接管请求 |
另有空字符串 "" 特殊映射,专门处理应用根请求,不属于上表的逐级回退关系。/ 与 /* 也有明显区别:/* 属于路径映射,会优先于扩展名映射;/ 是默认映射,只有此前都未命中才处理。把框架入口从 / 改成 /*,可能让原先交给 JSP 的请求也落到框架里。
例如同时注册 /api/* 与 *.json 时,/api/a.json 选择路径映射;如果另有精确 /api/a.json,则选择精确映射。容器注册顺序不会推翻这条优先级。映射错误应检查最终注册信息,而非靠调整配置文件顺序试错。
三个路径字段要一起看
getRequestURI() 是请求 URI 的路径部分,不含查询串;getContextPath() 表示应用路径;getServletPath() 和 getPathInfo() 则反映选定映射怎样消耗应用内路径。
| 请求与映射 | contextPath | servletPath | pathInfo |
|---|---|---|---|
/shop/hello → /hello | /shop | /hello | null |
/shop/api/orders/42 → /api/* | /shop | /api | /orders/42 |
/shop/a.json → *.json | /shop | /a.json | null |
/shop/ → "" | /shop | 空字符串 | / |
HttpServletMapping 还给出模式、Servlet 名和匹配种类,适合定位请求到底落到了谁。URI 的原貌、百分号解码和转发后的路径不总是同一组字符串,授权判断应使用明确的规范化规则,不能混用原始 URI 和解码后的路径。
实验测试在一个真实 Context 中注册五种模式,以 HTTP 请求检查 getHttpServletMapping() 和路径字段。可以单独执行:
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 \
-Dtest=LifecycleTest#actualMappingAndLifecycle test输出中的 mapping=EXACT,PATH,EXTENSION,DEFAULT,CONTEXT_ROOT 来自五条请求全部通过断言;init=1 service=1 对应精确映射组件。关闭容器后才检查 destroy=1,没有把手动调用 Java 方法当作容器生命周期。
初始化、共享实例与重新分派
从注册信息到可调用实例
Servlet 注册信息包括名称、类或实例、初始化参数、URL 映射、启动加载顺序和异步支持。容器根据它创建组件,交入 ServletConfig 并执行 init();初始化成功后才能调用 service()。
部署信息
→ 注册 Servlet
→ 创建实例、交入 ServletConfig
→ init 成功
→ service 调用 0 次或多次,可并发
→ 停止新调用、等待在途请求或达到等待上限
→ destroyload-on-startup 为非负值时要求启动阶段加载;较小的值先初始化,相同值的先后由容器决定。负值或未设置可以延迟到首次使用。资源连接建立失败时,延迟初始化会把启动问题推迟到第一个真实请求,所以应根据组件用途选择加载时机。
ServletConfig 是当前 Servlet 的配置,ServletContext 是整个 Web 应用的上下文。ServletContainerInitializer、部署描述符、注解与编程式注册可以参与装配;运行期再调用注册 API 往往已经超出允许阶段。若采用 web.xml 和注解扫描,metadata-complete、web fragment 顺序和类扫描范围会影响最终结果。具体合并规则由规范承接,不应猜测“最后扫描者覆盖一切”。
Tomcat 11.0.25 使用 StandardWrapper 管理一个 Servlet 的运行状态。instanceInitialized 记录是否已初始化;countAllocated 跟踪尚未归还的调用;卸载时在等待限制内观察调用结束,再执行销毁。这些字段解释了“组件已经加载”“组件正在处理请求”“组件完成销毁”为什么是不同状态,见 StandardWrapper 实现。
一个实例可以同时处理多个请求
容器通常对同一注册 Servlet 维护一个实例,再让多个线程并发进入。实例字段中的数据库连接池可以是线程安全的共享资源;本次请求的用户、临时 StringBuilder、响应和事务对象应保存在局部状态中。测试里的 AtomicInteger 只是让跨线程观测安全,并不会使其余字段自动安全。
destroy() 用于释放组件持有的资源。容器会先给在途请求一定的完成机会,但这个等待有上限;进程崩溃、强制终止或宿主故障也可能让回调无法执行。业务持久化应在事务或明确的保存流程中完成,不能寄希望于最后一次销毁回调。
实验还通过类名注册了一个 init() 抛出永久 UnavailableException 的组件:Tomcat 对其请求返回 404,doGet() 未被调用。临时不可用和一般初始化异常的处理不同,不能把所有启动失败都解释为同一个 HTTP 状态。定位时保留初始化异常原因、Servlet 名和容器日志,再看响应来自哪个处理层。
forward、include、error 与 async 会再次进入容器
一次外部 HTTP 请求可以产生多次 Servlet 分派,DispatcherType 区分它们:
| 分派类型 | 触发方式 | 调用关系 |
|---|---|---|
| REQUEST | 客户端请求进入容器 | 初始调用 |
| FORWARD | RequestDispatcher.forward() | 交给另一个目标处理;未提交缓冲会清空 |
| INCLUDE | RequestDispatcher.include() | 把目标输出加入当前响应;被包含目标不能改外层状态和响应头 |
| ERROR | 容器错误页处理 | 携带错误属性进入错误资源 |
| ASYNC | AsyncContext.dispatch() | 异步周期中的重新分派 |
Filter 只在其声明的分派类型上匹配。forward 可以重新选择目标 Servlet,却不是浏览器发起第二次请求;HTTP 重定向则让客户端根据响应位置另发请求,两者的地址栏、请求对象和过滤器次数不同。
响应是否已提交会限制 forward 和错误处理;异步完成又取决于当前分派是否已经返回。图中的分支不能拼成每个请求都会走完的一条流水线。异步 Servlet进一步解释原线程返回后请求怎样继续存在。
响应缓冲与提交点
状态和 Header 在什么时候固定
响应包含状态码、Header、编码和输出缓冲。应用写入缓冲时,字节尚可由容器暂存;显式 flushBuffer()、缓冲溢出或实际输出刷出会导致提交,isCommitted() 随之为 true。状态和响应头已经发送后,再修改这些字段无法得到一份新的完整响应。
未提交时,resetBuffer() 清空正文缓冲而保留状态与 Header;reset() 还清除状态和 Header。已提交后调用重置会抛异常。sendError() 和 sendRedirect() 也要求合法的响应状态;错误处理不能在前半段 JSON 已经刷出后,假装还能把整次调用替换成一个错误 JSON。完整契约见 ServletResponse API。
getWriter() 与 getOutputStream() 分别处理字符和字节输出。字符编码应在获取 writer 前确定;通常同一次响应选择其中一种入口。手动写 Content-Length 必须按最终字节数计算,不能直接使用字符串字符数;压缩和流式输出还会改变长度处理方式。
用提前 flush 观察错误处理失效
集成测试的核心请求处理如下:
res.setContentType("text/plain;charset=UTF-8");
res.getWriter().write("prefix\n");
res.flushBuffer();
try {
res.sendError(500);
} catch (IllegalStateException expected) {
rejected.set(true);
}rejected 是测试持有的 AtomicBoolean,完整上下文在 LifecycleTest.java。真实客户端得到状态 200 和 prefix,同时服务端断言 sendError 被拒绝。状态 200 是 flush 时已经确定的响应头;后续处理错误仍可能让正文截断或连接关闭。
这个现象同样影响 Filter 的回程代码:chain.doFilter() 返回时,下游可能已经提交响应。需要包装、设置 Header 或选择错误格式,应在可修改阶段完成;清理代码仍放在 finally,不能因为响应已提交就跳过资源释放。
同步调用返回后,容器会完成请求处理并回收相关对象。把 request/response 引用保存给普通后台任务,不能延长其寿命;应先调用 startAsync(),或者把业务所需的小型不可变数据复制出来,让后台任务完全脱离 HTTP 对象。
外置部署、停止和故障定位
WAR 的目录与类加载
外置容器接收一个 Web 应用,常见 WAR 结构为:
shop.war
├─ index.html 静态资源
└─ WEB-INF/
├─ web.xml 可选部署描述符
├─ classes/ 应用 class 与资源
└─ lib/ 应用依赖 JAR,不重复打包容器 APIWEB-INF 内资源不能由客户端直接访问。应用类加载器从自己的 classes/lib 加载业务代码,并与容器公共库协调委派;这不是操作系统级安全隔离,共享同一 JVM 的应用仍共用进程资源。Tomcat 的实际加载规则见 类加载说明。
外置 shop.war 的 Context 命名、部署目录、自动部署和版本并行规则由容器配置决定。生产替换 WAR 时还要考虑扫描时机和在途请求,不能把向正在扫描的目录逐个复制 class 当作可靠发布。Tomcat 部署说明列出了可用部署方式;多实例摘流与回退操作见 Tomcat 部署运维。
嵌入式应用把容器与业务一起升级,版本更加集中;共享外置容器可以复用进程,但应用争抢堆、线程和公共库,重部署还可能留下旧类加载器。选择每服务进程或共享 WAR 容器时,应把这些运行成本与团队现有部署方式一起考虑。
从实际观察选择下一步
| 现象 | 先检查 | 后续处理 |
|---|---|---|
| curl 连接被拒绝 | docker ps -a、docker logs servlet-lifecycle | 修正启动异常或端口发布,再重试同一 URL |
| 端口可连但 404 | contextPath、servletPath、mapping;是否有初始化异常 | 纠正代理前缀、Context 或映射,再核对实际命中组件 |
NoClassDefFoundError: javax/servlet/... | WAR 依赖与容器 Servlet 版本 | 成套迁移容器、框架和 Filter;只改一个 import 无法修复二进制依赖 |
response already committed | 第一次 flush、缓冲溢出、writer/stream 调用 | 把 Header/错误选择移到提交前,流式失败按协议结束 |
| 多次部署后线程持续增加 | 应用自建线程、调度器、连接池与回调 | 在组件关闭时停止产生新任务、限时等待、关闭持有资源,再按同负载复测 |
检查应用监听和进程时,Linux 可以执行 ss -lntp;容器内端口与宿主发布端口要分开看。容器日志中的第一条异常通常比末尾“启动失败”更接近原因。
请求量、连接量与线程量也须分别观察。HTTP/2 可在一条连接上承载多个流,异步请求可以暂时没有执行线程;业务处理变慢时,线程数稳定仍可能伴随在途请求增多。详细定位使用 Java Web 常见故障中的分层命令。
关闭实验
docker stop --timeout 15 servlet-lifecycle
docker logs servlet-lifecycle
docker rm servlet-lifecycleJava 模块封装可能让 Tomcat 输出 ThreadLocal 或 RMI 泄漏检查需要 --add-opens 的警告。这表示相应反射检查未启用,不是应用请求失败。需要这些检查时,按日志给出的具体包开放参数配置目标 JVM;不要把警告消失当作已经没有泄漏。
正常停止由 JVM shutdown hook 关闭 Tomcat,组件收到 destroy(),本次临时目录被清理。若 15 秒宽限期耗尽,Docker 会强制终止,不能据此声称所有清理回调都执行过。确认启动和停止都正常后,可以保留源码、Maven 缓存与 target 供后续复跑;它们不是服务器长期运行所需的在线数据。
权威资料与规范地址
映射、分派和生命周期细节可查 Servlet 规范;Tomcat 特有的启动、部署和类加载行为使用对应实现文档。
