Servlet 容器与组件生命周期:请求如何进入 Java 应用
端口已经监听,健康检查却返回 404;Servlet 的 init() 明明成功,业务请求仍没有进入 service();应用重部署后旧线程继续运行,最终把整个容器的类加载器挂在内存里。这些现象都不能靠“Servlet 是处理请求的 Java 类”解释。容器真正提供的是一套部署、映射、实例化、并发调用和回收协议:它把网络层解析出的请求映射为应用组件调用,同时负责跨异常路径收敛请求与响应资源。
Jakarta Servlet 6.1 是正式规范基线,对应 Jakarta EE 11;6.2 仍在开发。新基线使用 jakarta.servlet.*,与旧 javax.servlet.* 不是简单改 import 的源码差异,而是涉及容器、框架、第三方 Filter、部署描述符和打包依赖的二进制兼容边界。Servlet 规范入口应在升级时与目标容器兼容矩阵一起核对。
一次请求先经过容器,不会直接跳进 Servlet
Connector 接受连接并解析 HTTP 消息,容器依据虚拟主机、context path、Servlet mapping 和 dispatcher type 选择 Web 应用与调用链。HttpServletRequest、HttpServletResponse 是本次请求处理的容器门面,不是可在请求结束后任意保存的普通 DTO。底层连接、缓冲、异步状态和安全主体都可能由它们间接引用。
映射不是“按配置先后取第一个”。容器先匹配精确路径,再选择最长路径前缀,然后尝试扩展名映射,最后才落到默认 Servlet。/api/orders/42 应胜过 /api/*,/api/* 应胜过 *.json。欢迎页、静态资源与错误页还会触发新的 dispatch,Filter 是否再次执行取决于其 dispatcher type 配置。
examples/backend-development/servlet/servlet-container-lifecycle/MappingResolutionDemo.java 固化了四类映射优先级;它不是复制某个容器的源码,而是把规范动作变成可执行判据:
javac --release 17 -Xlint:all -Werror examples/backend-development/servlet/servlet-container-lifecycle/ComponentLifecycleDemo.java examples/backend-development/servlet/servlet-container-lifecycle/MappingResolutionDemo.java
java -cp examples/backend-development/servlet/servlet-container-lifecycle MappingResolutionDemoexact=/api/orders/42 prefix=/api/* extension=*.json default=/当请求意外进入默认 Servlet,不要先改 Controller。先记录 context path、request URI、servlet path、path info、dispatcher type 和最终 mapping。代理改写前缀、WAR context 名变化、重复注册与部署描述符覆盖都会改变映射输入。
生命周期是容器与组件之间的状态协议
容器创建 Servlet 实例,注入 ServletConfig,调用一次 init();初始化成功后,多条请求通常并发进入同一个实例的 service();应用停止或重部署时,容器停止把新请求交给它,等待或中止在途处理,再调用 destroy()。实例字段因此默认是多线程共享状态,把本次请求、用户身份或可变缓冲放进去会产生串请求与竞态。
ComponentLifecycleDemo.java 用状态机证明销毁之后不能继续调用:
java -cp examples/backend-development/servlet/servlet-container-lifecycle ComponentLifecycleDemoevents=[init, service:request-1, destroy] rejectedAfterDestroy=true真正容器还要处理初始化异常、临时/永久不可用、并发请求与重部署。实验给出的不变量是 init → service* → destroy,并不声称在 JVM 崩溃时仍必然执行 destroy()。所以关键状态不能只依赖 destroy() 刷盘,外部资源应使用事务或可恢复协议。
响应何时失去修改机会
响应具有缓冲和 committed 状态。缓冲未提交时,应用通常仍能修改状态码与 Header;缓冲填满、显式 flush、关闭输出或容器决定发送时,响应头进入传输,之后再 sendError() 或改 Header 可能失败或静默无效。异常处理器若在业务已经写出部分响应后才介入,就无法再构造完整 JSON 错误。
getWriter() 与 getOutputStream() 代表字符和字节两种输出入口,不能随意混用。字符编码必须在获取 writer 之前确定;Content-Length 必须对应最终传输字节,经过压缩、包装或流式写出时更适合由容器计算。请求内容同理,输入流通常只能消费一次;日志 Filter 需要重复读取时必须使用有界缓存包装,并处理超大体与流式上传。
同步请求在调用栈正常或异常返回后进入完成路径。此时仍保存 request/response 引用的后台任务已经越过所有权边界。容器可能回收门面对象或底层缓冲,后台线程读取到的内容不再可靠。需要异步延长生命周期时必须显式调用 startAsync();只把工作扔进线程池不等于请求变成异步。
部署、嵌入式启动与外置 WAR 是同一协议的不同装配
外置容器扫描应用、创建 Web 应用类加载器并部署 WAR;嵌入式模式由应用代码或框架创建 Server、Connector、Context 和 Servlet 注册。两者都必须完成规范组件发现、映射冲突检查、初始化与流量接入。嵌入式并没有取消容器,只是把容器生命周期并入应用进程。
动态注册必须发生在允许的启动阶段。ServletContainerInitializer、编程式 registration、注解扫描和 web.xml 可能同时贡献组件,团队需要明确谁是权威来源。一个 Filter 被框架 Bean 注册一次、又被容器注解扫描一次,可能执行两遍;排查时应打印最终注册表,而不是只看源码上有几个注解。
重部署的难点不是重新执行 init(),而是让旧 Web 应用类加载器可回收。应用创建的线程、定时器、ThreadLocal、JDBC driver、日志回调、全局缓存和第三方静态单例若仍引用旧类,容器即使调用 destroy() 也无法卸载。关闭顺序应先摘流,停止产生新任务,取消调度,等待有界在途任务,关闭连接池与 executor,清理 ThreadLocal 和驱动注册,最后释放组件与类加载器。
容器级证据如何建立
一次映射故障至少保留:Connector、Host、Context、dispatcher type、最终 mapping、Filter 链、Servlet 名、响应 committed 状态。一次生命周期故障则记录部署 id、类加载器标识、init/destroy 时间、在途请求、后台线程与资源关闭结果。只写“应用启动成功”无法证明组件已经可服务,也无法证明旧实例已经退出。
容量指标要把连接、请求、线程和异步请求分开:当前连接数不等于正在执行的 Servlet 数;HTTP/2 一条连接可有多条流;异步请求可能没有占用原请求线程,却仍占连接、超时任务和应用状态。至少观测 active requests、request queue age、executor active/queue、async active/age、response committed error、部署失败与类加载器存活数量。
安全上,容器入口要限制请求行、Header、请求体和参数数量;禁用不需要的方法与默认管理入口;错误页不能泄露堆栈与文件路径;不同 Web 应用通过类加载器、临时目录和权限隔离。容器负责把字节变成调用,但应用仍要对身份、授权、输入和业务完成负责。
当请求没有进入业务代码时,从 Connector、Context、mapping、dispatcher、Filter 链逐层收集事实;当请求已经进入却无法退出时,再沿响应提交、异步状态和资源关闭追踪。这样才能把“容器有问题”缩小为一个明确失效的生命周期转换。
把生命周期变成发布门禁
发布验证不能止于端口可连。部署带唯一 deployment id 的探针 Servlet,验证 init 仅发生一次、映射落在预期组件、并发请求不共享请求态;制造 service 异常,确认未提交响应进入错误派发、已提交响应不会被二次改写。摘流后持续采集 active request,只有降到允许基线才进入 destroy。
随后检查应用创建的非守护线程、调度任务、连接池、注册驱动和 ThreadLocal。执行多轮部署/卸载,在一致观察窗口后确认旧类加载器数量回到稳定基线,并且旧 deployment id 不再接受调用。升级门禁还要用相同 WAR 跑新旧容器的映射、编码、错误页、异步、Session 和上传契约测试;依赖树、WAR 内容和运行时注册表三份证据必须一致。
