Servlet 与 Java Web 请求全过程
Servlet 是 Java Web 容器与业务代码之间的标准契约。Tomcat 负责网络监听、HTTP 解析、线程调度和对象创建,Servlet 负责在 service(request,response) 中处理一次应用层请求。Spring MVC 的 DispatcherServlet、Jersey 的入口以及很多老系统接口都建立在这套规范上。
学习目标
- 从 TCP 连接进入 Tomcat 开始,讲清请求如何找到 Servlet;
- 理解 Servlet 实例、容器工作线程、Request、Response 的生命周期;
- 掌握 URL 映射优先级、上下文路径、Servlet Path 和 Path Info;
- 解释
init/service/destroy的调用条件、并发边界和失败后果; - 正确读取参数、请求体、请求头,正确设置状态码、编码和响应体;
- 区分 forward、include、redirect,理解“响应已提交”为什么不能回头;
- 理解 Servlet 3 异步处理能解决什么、不能解决什么;
- 能排查 404、405、400、乱码、请求体为空、线程串数据和容器线程耗尽。
一、Servlet、容器和 HTTP 各自负责什么
flowchart TD
A["客户端建立TCP连接"] --> B["Tomcat Connector接收字节"]
B --> C["解析HTTP请求行请求头和请求体"]
C --> D["创建容器内部Request与Response"]
D --> E["Adapter进入Servlet容器管线"]
E --> F["按Context和URL映射Servlet"]
F --> G["Filter链"]
G --> H["Servlet.service"]
H --> I["业务写入Response"]
I --> J["容器生成HTTP响应字节"]
J --> K["通过连接返回客户端"]| 角色 | 主要职责 |
|---|---|
| HTTP | 定义请求方法、路径、头、状态码和消息体语义 |
| Servlet 规范 | 定义生命周期、Request/Response、Filter、Session、异步等 Java API 契约 |
| Tomcat/Jetty/Undertow | 实现规范,并提供网络、线程池、类加载、部署和连接管理 |
| 应用 Servlet | 根据请求执行应用逻辑并构造响应 |
Servlet 不负责直接监听 8080 端口,也不亲自解析原始 TCP 字节。它接收到的是容器已经包装好的 HttpServletRequest 和 HttpServletResponse。
二、规范版本与包名边界
Servlet 版本和 JDK 版本不是同一维度:
| 规范 | 代表能力 | 常见时代 |
|---|---|---|
| Servlet 2.5 | 主要依赖 web.xml 配置 | 老 Java EE 项目 |
| Servlet 3.0 | 注解、异步、文件上传、容器初始化扩展 | 常见 JDK 7/8 项目 |
| Servlet 3.1 | 非阻塞 I/O API | Java EE 7 |
| Servlet 4.0 | HTTP/2 相关能力 | Java EE 8 |
| Jakarta Servlet 5+ | 包名从 javax.servlet 改为 jakarta.servlet | Jakarta EE 9+、Tomcat 10+ |
老项目常见:
import javax.servlet.http.HttpServlet;Tomcat 10+ 常见:
import jakarta.servlet.http.HttpServlet;这不是只改一个 import 就总能升级成功:第三方 Filter、Listener、JSP 标签库和框架也必须使用同一包体系。Tomcat 9 支持 javax 时代规范,Tomcat 10 转入 jakarta;部署不匹配时可能出现类找不到、接口类型不兼容或应用启动失败。
三、Web 应用和上下文路径
假设浏览器访问:
http://example.com:8080/order-app/api/orders/100?detail=true可拆为:
| 部分 | 示例 | 含义 |
|---|---|---|
| scheme | http | 应用层协议 |
| host | example.com | 主机名 |
| port | 8080 | 服务端口 |
| context path | /order-app | Web 应用在容器中的部署根路径 |
| application path | /api/orders/100 | 在应用内部用于映射的路径 |
| query string | detail=true | 查询字符串 |
常见 API:
request.getRequestURI(); // /order-app/api/orders/100
request.getContextPath(); // /order-app
request.getServletPath(); // 取决于映射方式
request.getPathInfo(); // 取决于映射方式
request.getQueryString(); // detail=true不要把 getRequestURL、getRequestURI、getServletPath 混用。反向代理后,外部 scheme/host 还可能与容器看到的内部地址不同,需要正确处理 Forwarded/X-Forwarded-*,并只信任受控代理写入的头。
四、URL 映射是怎样选择的
Servlet 可通过 web.xml 或注解注册。
4.1 web.xml 方式
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" version="3.1">
<servlet>
<servlet-name>orderServlet</servlet-name>
<servlet-class>com.example.OrderServlet</servlet-class>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>orderServlet</servlet-name>
<url-pattern>/api/orders/*</url-pattern>
</servlet-mapping>
</web-app>4.2 Servlet 3 注解方式
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
@WebServlet(
name = "orderServlet",
urlPatterns = "/api/orders/*",
loadOnStartup = 1,
asyncSupported = true
)
public class OrderServlet extends HttpServlet {
}同一个 Servlet 名称、URL 映射发生冲突时,应用可能部署失败或按规范配置规则处理,不能假定“后定义覆盖前定义”。大型项目应统一注册方式,避免扫描注解和 XML 分散维护。
4.3 四类映射与优先级
精确映射 /api/orders/100
路径映射 /api/orders/*
扩展名映射 *.do
默认映射 /选择顺序通常是:
flowchart TD
A["取得应用内请求路径"] --> B{"存在精确匹配吗"}
B -- "是" --> C["选择精确映射"]
B -- "否" --> D{"存在最长路径匹配吗"}
D -- "是" --> E["选择最长的前缀路径映射"]
D -- "否" --> F{"存在扩展名匹配吗"}
F -- "是" --> G["选择扩展名映射"]
F -- "否" --> H["使用默认Servlet映射"]路径映射会选择最长匹配,例如 /api/* 与 /api/orders/* 同时存在时,后者处理 /api/orders/100。
4.4 servletPath 与 pathInfo
若映射是 /api/orders/*,请求 /api/orders/100 通常得到:
servletPath = /api/orders
pathInfo = /100若是精确映射 /api/orders/100,servletPath 通常是完整映射路径,pathInfo 为 null。路由代码若依赖 pathInfo,必须根据实际映射方式测试,不能用字符串硬切整个 URI。
五、Servlet 生命周期全过程
flowchart TD
A["应用部署"] --> B{"loadOnStartup大于等于0吗"}
B -- "是" --> C["启动时实例化"]
B -- "否" --> D["首次匹配请求时实例化"]
C --> E["构造Servlet对象"]
D --> E
E --> F["注入ServletConfig"]
F --> G["调用init一次"]
G --> H["并发调用service多次"]
H --> I["应用停止重载或卸载"]
I --> J["停止接收并等待在途请求"]
J --> K["调用destroy一次"]
K --> L["实例等待回收"]5.1 实例何时创建
- 配置
load-on-startup非负值时,容器通常部署阶段创建并初始化,数值用于相对启动顺序; - 未配置或为负值时,常见行为是首次请求时懒加载;
- 同一个 Servlet 定义通常对应一个实例,但规范和容器部署模型决定细节,不能依赖构造器做外部注册;
- 应用重新部署会使用新类加载器和新实例。
5.2 init 做什么
init(ServletConfig) 只调用一次,适合验证本 Servlet 的只读配置、构建线程安全且可关闭的资源。初始化失败会导致 Servlet 不可用,启动加载场景下可使应用部署失败。
@Override
public void init() throws javax.servlet.ServletException {
String endpoint = getServletConfig().getInitParameter("endpoint");
if (endpoint == null) {
throw new javax.servlet.ServletException("endpoint is required");
}
}不建议在 init 中执行无限等待的远程调用。配置中心或数据库故障时,应用会卡在启动阶段,发布平台只看到实例不健康。应设置严格超时、给出明确异常并根据依赖重要性决定失败启动还是降级。
5.3 service 如何分派 HTTP 方法
容器调用 HttpServlet.service,它根据 HTTP 方法分派到 doGet/doPost/doPut/doDelete/doHead/doOptions/doTrace。没有实现相应方法时通常返回 405,而不是自动改用 doPost。
不要直接重写 service 后忘记调用父类,否则可能绕过标准方法分派、条件请求和 HEAD 行为。只有确实需要统一控制整个分派过程时才重写。
5.4 destroy 的真实边界
destroy 用于停止本 Servlet 创建的后台任务、关闭资源和注销注册。它不是绝对保证:进程被 kill -9、机器断电或 JVM 崩溃时不会执行,因此关键数据不能只依赖 destroy 刷盘。
资源最好由应用级 Listener、依赖注入容器或连接池统一管理,避免每个 Servlet 重复创建数据库连接池或线程池。
六、单实例、多线程与线程安全
Servlet 初始化完成后,容器工作线程可并发调用同一个实例的 service:
flowchart TD
A["同一个OrderServlet实例"] --> B["http线程1处理订单A"]
A --> C["http线程2处理订单B"]
A --> D["http线程3处理订单C"]
B --> E["共享Servlet成员字段"]
C --> E
D --> E错误代码:
public class UnsafeServlet extends javax.servlet.http.HttpServlet {
private String currentUser;
protected void doGet(javax.servlet.http.HttpServletRequest request,
javax.servlet.http.HttpServletResponse response) {
currentUser = request.getParameter("user");
// 另一个请求可能已覆盖 currentUser
}
}安全边界:
- 请求数据放方法局部变量;
- Request/Response 只在当前请求链内使用;
- 成员字段只保存初始化后不变的不可变配置或线程安全组件;
- 不把
StringBuilder、普通 HashMap、当前用户、当前订单等可变请求状态放成员字段; ServletContext和 Session 中的对象同样可能被多个线程访问,容器只管理属性容器,不自动让 value 内部线程安全。
给整个 service 加 synchronized 虽可能避免字段竞态,却会把所有请求串行化,吞吐和延迟恶化;正确做法是消除共享可变请求状态。
七、HttpServletRequest 全过程
Request 是容器对当前请求的包装,生命周期通常到请求完成;异步场景会延长。不要保存到静态字段、异步任务或缓存中长期使用。
7.1 参数来自哪里
getParameter 可能组合查询字符串和 application/x-www-form-urlencoded 表单字段。多个同名参数使用 getParameterValues。JSON 请求体不会自动变成 parameter,需要用输入流读取并由 JSON 库反序列化。
String id = request.getParameter("id");
String[] tags = request.getParameterValues("tag");
String contentType = request.getContentType();
String method = request.getMethod();客户端传入的参数、头、Cookie 都不可信,必须做长度、格式、范围、权限和业务校验。不能把 X-User-Id 之类普通请求头直接当已认证身份。
7.2 编码设置必须在读取参数之前
request.setCharacterEncoding("UTF-8");
String name = request.getParameter("name");对表单 body,容器第一次解析参数时会按当时编码解码;先读取参数再设置编码通常已经晚了。GET 查询串编码还受容器 Connector 配置和 Servlet 规范版本影响,不能只靠 setCharacterEncoding 解决所有历史乱码。
7.3 请求体只能消费一次
javax.servlet.ServletInputStream input = request.getInputStream();原始 body 是流。读取后游标向前,后续 Controller/Servlet 再读可能为空。getInputStream() 和 getReader() 不能在同一请求上混用,通常会抛 IllegalStateException。
Filter 若要记录 body 并让下游继续读取,必须实现有大小上限的缓存包装器;不能直接读完,也不能无上限缓存上传文件,否则会破坏业务或造成 OOM。敏感字段、密码、token 和医疗数据不能原样写日志。
7.4 属性不是参数
request.setAttribute("order", orderDto);
Object value = request.getAttribute("order");- parameter 来自客户端请求数据,通常是字符串;
- attribute 由服务端代码在当前请求中设置,可保存对象;
- forward 时仍使用同一 Request,因此 attribute 可共享;
- redirect 是新请求,原 Request attribute 不存在。
7.5 客户端 IP 不能盲信头
getRemoteAddr() 通常得到与容器直接连接的对端,经过网关时可能是代理 IP。X-Forwarded-For 可被客户端伪造,只有网络拓扑保证请求必须经过受控代理,且应用按可信代理链规则解析时才能使用。
八、HttpServletResponse 全过程
Response 在容器内有缓冲区。状态码、响应头和 body 在提交前可修改;发生以下情况之一可能提交:
- 缓冲区写满并 flush;
- 应用显式
flushBuffer(); - Writer/OutputStream 被 flush/close;
- forward/请求结束时容器提交;
- 某些 sendError/sendRedirect 操作触发提交语义。
flowchart TD
A["创建未提交Response"] --> B["设置状态码响应头和编码"]
B --> C["向响应缓冲写body"]
C --> D{"缓冲满flush或请求结束吗"}
D -- "否" --> C
D -- "是" --> E["提交状态行和响应头"]
E --> F["继续发送响应体"]
F --> G["提交后不能可靠修改状态和头"]8.1 编码顺序
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json;charset=UTF-8");
java.io.PrintWriter writer = response.getWriter();
writer.write("{\"message\":\"成功\"}");应在 getWriter() 之前设置编码和 Content-Type。Writer 创建后字符到字节的编码器通常已经确定,之后修改可能无效。
8.2 Writer 与 OutputStream 互斥
文本使用 getWriter(),二进制下载使用 getOutputStream()。同一响应同时调用二者通常抛 IllegalStateException。下载还应设置正确 Content-Type、Content-Disposition,并防止文件名响应头注入。
8.3 状态码不是总返回 200
| 场景 | 常见状态码 |
|---|---|
| 查询成功 | 200 |
| 创建成功 | 201 |
| 无响应体成功 | 204 |
| 参数格式错误 | 400 |
| 未认证 | 401 |
| 已认证但无权限 | 403 |
| 资源不存在 | 404 |
| 方法不支持 | 405 |
| 冲突或版本冲突 | 409 |
| 服务端未知错误 | 500 |
| 服务暂不可用 | 503 |
业务错误全部返回 HTTP 200 会让网关、监控和客户端重试无法正确判断;但状态码与业务错误码应共同设计,不能只靠状态码表达全部业务细节。
8.4 sendError 和异常页
sendError(status,message) 通知容器按错误处理机制生成响应,可能清空缓冲并触发 error-page。若响应已提交,容器无法重写已经发送的状态和头,可能只中断连接或记录异常。
九、forward、include 与 redirect
| 维度 | forward | include | redirect |
|---|---|---|---|
| 谁执行下一步 | 服务端容器 | 服务端容器 | 浏览器/客户端 |
| HTTP 请求次数 | 通常 1 次 | 通常 1 次 | 至少新增 1 次 |
| 地址栏 | 不变 | 不变 | 改变 |
| Request 对象 | 同一个 | 同一个 | 新请求 |
| request attribute | 可共享 | 可共享 | 不共享 |
| 可跨站 | 否,通常当前上下文资源 | 否 | 可以,但要防开放重定向 |
9.1 forward
request.setAttribute("order", order);
request.getRequestDispatcher("/WEB-INF/views/order.jsp")
.forward(request, response);
return;forward 后应立即 return,避免继续写响应。若此前已 flush 导致 response committed,会抛 IllegalStateException。容器在 forward 前通常清理未提交响应缓冲,但不会撤销已经执行的数据库操作。
9.2 include
include 把另一个资源的输出包含进当前响应,被包含资源不能随意改变最终状态码和关键响应头。常用于传统 JSP 片段,现代 JSON API 较少直接使用。
9.3 redirect
String location = response.encodeRedirectURL(
request.getContextPath() + "/orders/100");
response.sendRedirect(location);redirect 返回 3xx 和 Location,客户端重新发请求。不能直接把用户输入拼进 Location,否则会形成开放重定向钓鱼漏洞。POST 后重定向可使用 PRG 模式避免浏览器刷新重复提交,但最终仍需服务端幂等。
十、完整 Servlet 3 Demo
下面用内存 Map 模拟订单查询,演示映射、路径解析、校验、编码、状态码和线程安全边界。真实项目把业务放 Service,Servlet/Controller 只处理协议转换。
package com.example.web;
import java.io.IOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@WebServlet(name = "orderServlet", urlPatterns = "/api/orders/*", loadOnStartup = 1)
public class OrderServlet extends HttpServlet {
private Map<String, String> orders;
@Override
public void init() throws ServletException {
Map<String, String> data = new HashMap<String, String>();
data.put("100", "PAID");
data.put("101", "CREATED");
orders = Collections.unmodifiableMap(data); // 初始化后安全发布只读对象
}
@Override
protected void doGet(HttpServletRequest request,
HttpServletResponse response) throws IOException {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json;charset=UTF-8");
String pathInfo = request.getPathInfo();
if (pathInfo == null || !pathInfo.matches("/[0-9]{1,20}")) {
writeError(response, HttpServletResponse.SC_BAD_REQUEST,
"invalid order id");
return;
}
String orderId = pathInfo.substring(1);
String status = orders.get(orderId);
if (status == null) {
writeError(response, HttpServletResponse.SC_NOT_FOUND,
"order not found");
return;
}
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().write(
"{\"id\":\"" + orderId + "\",\"status\":\"" + status + "\"}");
}
private void writeError(HttpServletResponse response,
int status,
String message) throws IOException {
response.setStatus(status);
response.getWriter().write("{\"error\":\"" + message + "\"}");
}
}教学代码中的 message 是固定字符串。真实系统拼接用户内容时必须使用 JSON 库转义,避免生成非法 JSON 或注入;订单数据应由 Service/DAO 提供,不应在 Servlet 中手写数据库连接。
十一、Servlet 3 异步处理
同步 Servlet 中,容器工作线程从进入 service 到响应结束一直被占用。异步 Servlet 可调用 startAsync() 暂时退出原容器线程,待异步任务完成后调用 complete() 或 dispatch。
flowchart TD
A["容器线程进入Servlet"] --> B["调用request.startAsync"]
B --> C["原容器线程返回线程池"]
C --> D["异步任务等待或执行"]
D --> E["异步线程写Response或dispatch"]
E --> F["调用AsyncContext.complete"]
F --> G["容器提交响应并回收请求"]@WebServlet(urlPatterns = "/async-report", asyncSupported = true)
public class AsyncReportServlet extends HttpServlet {
protected void doGet(HttpServletRequest request,
HttpServletResponse response) throws IOException {
final javax.servlet.AsyncContext async = request.startAsync();
async.setTimeout(3000L);
async.start(new Runnable() {
public void run() {
try {
async.getResponse().setContentType("text/plain;charset=UTF-8");
async.getResponse().getWriter().write("report ready");
} catch (Exception e) {
// 记录任务ID和异常,按统一错误策略处理
} finally {
async.complete();
}
}
});
}
}关键边界:
- 调用链中的 Servlet 和 Filter 都要支持 async;
- 必须设置超时并注册 AsyncListener 处理完成、超时和错误;
- 每条路径只正确 complete 一次;
- 异步不等于无线程,
AsyncContext.start仍使用容器管理的线程; - 不要把慢数据库查询变成无限异步任务,数据库连接容量没有增加;
- 原请求线程的 ThreadLocal、MDC、SecurityContext 不会自动安全传播到另一线程;
- 异步开始后 Request/Response 生命周期延长,但 complete 后绝不能继续使用;
- 客户端断开不保证已经提交的业务自动取消。
Servlet 3.1 的非阻塞 ReadListener/WriteListener 又是另一层 API,用于容器通知可读可写;不能把 Servlet 3.0 异步简单等同于 NIO 非阻塞 I/O。
十二、ServletContext 与 ServletConfig
| 对象 | 范围 | 主要用途 |
|---|---|---|
ServletConfig | 一个 Servlet 定义 | Servlet 名称、init-param、ServletContext |
ServletContext | 一个 Web 应用 | 应用参数、资源、属性、日志、动态注册入口 |
ServletContext attribute 在整个应用共享,可能被多个请求线程访问。放入不可变配置或线程安全服务可以,不能放一个普通 HashMap 后让所有请求无同步修改。
应用级资源的初始化与销毁更适合 ServletContextListener,下一章会讲 Listener 的事件顺序、并发边界和类加载泄漏。
十三、Spring MVC 与 Servlet 的关系
flowchart TD
A["Tomcat解析HTTP"] --> B["Filter链"]
B --> C["DispatcherServlet.service"]
C --> D["HandlerMapping查找Controller"]
D --> E["HandlerAdapter调用方法"]
E --> F["参数解析与校验"]
F --> G["Controller调用Service"]
G --> H["返回值处理"]
H --> I["HttpMessageConverter序列化"]
I --> J["写入HttpServletResponse"]Spring MVC 没有绕过 Servlet。DispatcherServlet 是前端控制器 Servlet,把所有请求集中后完成映射、参数绑定、异常处理、视图解析和消息转换。Spring Boot 内嵌 Tomcat 只是把容器作为依赖启动,Servlet 生命周期和线程安全原则仍成立。
Spring Bean 默认也常是单例,因此 Controller/Service 同样不应保存当前用户等请求可变状态。Spring RequestContext、事务和安全上下文大量依赖 ThreadLocal,手动异步时尤其要关注上下文传播与清理。
十四、扩展点与启动发现
| 扩展点 | 作用 |
|---|---|
@WebServlet / web.xml | 声明 Servlet 和映射 |
ServletContainerInitializer | 容器启动时发现框架并执行初始化 |
WebApplicationInitializer | Spring 对 Servlet 3 初始化机制的封装接口 |
ServletContext.addServlet | 启动阶段动态注册 Servlet |
| Filter | 请求进入 Servlet 前后拦截 |
| Listener | 监听应用、Session、Request 生命周期事件 |
| AsyncListener | 监听异步完成、超时、错误、重新开始 |
ServletContainerInitializer 通常通过 JAR 中 META-INF/services/javax.servlet.ServletContainerInitializer 被容器发现,并可配合 @HandlesTypes 扫描关注类型。扫描范围和类路径很大时会增加启动耗时,框架要谨慎定义发现范围。
十五、生产故障排查 Runbook
15.1 404:到底哪一层没找到
flowchart TD
A["请求返回404"] --> B{"域名端口和Context正确吗"}
B -- "否" --> C["修正入口或部署路径"]
B -- "是" --> D{"应用部署成功吗"}
D -- "否" --> E["查启动日志和类加载异常"]
D -- "是" --> F{"Servlet映射命中吗"}
F -- "否" --> G["查URL pattern和代理重写"]
F -- "是" --> H["查业务是否主动返回404"]记录完整外部 URL、代理转发后的内部 path、context path、应用部署日志和访问日志。不能看到 404 就只检查 Controller 注解。
15.2 405 Method Not Allowed
确认客户端实际方法,是否只实现了 doGet 却发送 POST,OPTIONS 预检是否被正确处理,代理是否改写方法。不要为了消除 405 把所有方法都转给一个处理逻辑,否则可能破坏幂等和权限语义。
15.3 参数为空或请求体为空
检查 Content-Type 与客户端编码;JSON 不能用 getParameter 获取;是否有 Filter 提前读取 body;是否混用 Reader/InputStream;是否 multipart;Content-Length/Transfer-Encoding 和代理限制是否异常。
15.4 中文乱码
分别确定乱码发生在请求查询串、表单 body、JSON body、响应 Writer、数据库还是日志。检查设置编码的时间点,抓取原始字节和 Content-Type;不要在已经错误解码成 String 后反复 getBytes 猜编码。
15.5 Cannot forward after response has been committed
检查是否在 forward 前写入大量响应、flush、关闭 Writer,或异常处理器试图在响应提交后再次重写。记录 response.isCommitted() 和首次写出位置,重构为“先决定结果,再统一写响应”。
15.6 用户数据串号
审查 Servlet/Controller 单例成员字段、静态变量、共享 DTO、ThreadLocal 清理。连续并发压测复现,日志使用请求 ID 但不能输出敏感用户信息。成员字段改局部变量;ThreadLocal 必须 finally remove。
15.7 请求大量卡住
- 看容器当前线程、最大线程、accept/backlog 和连接数;
- 连续取得 jstack,按
http-nio-*等线程分组; - 栈停在 socketRead 查下游超时,停在数据库连接池查连接等待,BLOCKED 查锁,CPU 高查业务循环/序列化;
- 对比请求进入 TPS、完成 TPS、P95/P99、下游延迟和拒绝数;
- 不要直接提高 Tomcat maxThreads,把数据库或下游打得更慢。
15.8 应用重部署后内存不释放
检查 Servlet/Listener 创建的线程未停、ThreadLocal、JDBC Driver、定时器、静态缓存和第三方库是否由公共类加载器持有 WebAppClassLoader。destroy 里关闭资源,优先使用容器管理生命周期,并通过堆转储查看到旧 WebAppClassLoader 的引用链。
十六、常见错误与后果
| 错误 | 后果 | 正确做法 |
|---|---|---|
| Servlet 成员字段保存当前用户 | 并发串数据 | 方法局部变量 |
| 先 getParameter 再设置编码 | 表单已错误解码 | 解析前统一设置 |
| Filter 读完 body 不包装 | Servlet 再读为空 | 有上限缓存包装 |
| 同时 getWriter 和 getOutputStream | IllegalStateException | 按文本/二进制二选一 |
| flush 后再 forward/sendError | 响应已提交 | 先决策后统一输出 |
| redirect 拼接任意用户 URL | 开放重定向 | 白名单和站内相对地址 |
| init 中无限调远程服务 | 应用启动卡死 | 超时、失败策略和健康检查 |
| async 不 complete | Request/Response 和资源泄漏 | 超时监听与 finally 完成 |
| 每个 Servlet 创建线程池 | 资源失控、停机泄漏 | 应用统一管理 |
十七、面试标准回答
Servlet 生命周期是什么
容器按启动加载或首次请求实例化 Servlet,注入 ServletConfig 后调用一次 init;初始化成功后多个容器线程可并发调用同一实例的 service,HttpServlet 再按方法分派到 doGet/doPost;应用卸载或停止时容器等待在途请求并调用一次 destroy,但进程崩溃时不保证执行。
Servlet 为什么不是线程安全的
通常同一实例被多个请求线程并发调用。方法局部变量属于各自调用,但成员字段由所有请求共享;把当前用户、订单或可变缓冲放成员字段会产生覆盖和串数据。Servlet 类不是天然不安全,关键是不能保存未同步的请求级可变状态。
forward 和 redirect 区别
forward 是容器内部使用同一个 Request/Response 转交,浏览器通常只有一次请求、地址栏不变、attribute 可共享;redirect 返回 3xx 和 Location,让客户端重新请求,地址改变、原 Request 数据不保留,还要防开放重定向。
Response committed 是什么意思
表示状态行和响应头已开始发送给客户端,之后无法可靠修改状态、编码和头,也不能再 forward 或用统一错误页重写。缓冲写满、flush、close、sendRedirect 或请求结束都可能触发提交。
Servlet 异步是否等于非阻塞
不等于。Servlet 3.0 AsyncContext 允许释放原请求线程并在其他线程完成响应,但异步任务仍需要线程,慢 SQL 仍占数据库连接。Servlet 3.1 ReadListener/WriteListener 才提供面向流的非阻塞通知 API。
十八、关联知识与验收
完成以下任务才算掌握:
- 能从 Connector 收到 HTTP 字节画到 Servlet.service 和响应提交;
- 能根据四类映射算出命中的 Servlet、servletPath 和 pathInfo;
- 能解释 load-on-startup、懒加载、init 失败和 destroy 不保证执行;
- 能复现成员字段串数据并改成无共享状态设计;
- 能说明 body 为什么只能读一次、Response 何时 committed;
- 能写 JDK 7/8 兼容的 Servlet 3 Demo,正确返回 400、404、200;
- 面对 404、405、乱码、body 为空和线程耗尽,能说出证据链和第一步检查,而不是只会重启 Tomcat。
