说到 Servlet,很多刚入行的 Java 小伙伴心里可能都在打鼓:“这玩意儿不是早就过时了吗?Spring Boot 都统治世界了,还学它干嘛?”
别急,先放下你的偏见。我见过太多面试者,简历上写着精通 Spring Cloud 微服务架构,结果面试官问一句:“如果请求进来,Spring MVC 是怎么找到 Controller 的?”对方直接懵圈。其实,Spring MVC 的核心就是 Servlet,而 Servlet 是 Java Web 的基石。不懂 Servlet,就像不懂内燃机原理却想修法拉利——车能开,但出了故障你只能叫拖车。
今天咱们不背八股文,我把这些面试题拆解成一个个真实的“坑”和“雷”,带你看看在真实的生产环境里,Servlet 到底是怎么跑起来的,以及为什么你必须在面试中把它讲得头头是道。
一、 生命周期:不只是 init() 和 destroy()
面试题高频点:
“请简述 Servlet 的生命周期。” “Servlet 是线程安全的吗?如果不是,如何解决?”
1. 那些教科书没告诉你的细节
标准答案通常是四步:加载 -> 实例化 -> 初始化(init)-> 服务(service)-> 销毁(destroy)。
但在真实项目中,真正让你头疼的不是 init,而是 Service。当一个 HTTP 请求到达服务器时,Tomcat 会创建一个 HttpServletRequest 和一个 HttpServletResponse 对象,然后调用 Servlet 的 service() 方法。在 HttpServlet 中,service() 会根据请求方式(GET/POST/DELETE 等)分发给对应的 doGet、doPost 等方法。
关键点来了: 默认情况下,一个 Servlet 类在容器中只有一个实例(单例模式)。这意味着,如果有 1000 个用户同时访问这个 Servlet,它们共享同一个实例变量。
2. 真实场景:购物车里的“幽灵商品”
想象一下,你正在开发一个电商系统的核心模块——购物车。你在 Servlet 里定义了一个成员变量:
public class CartServlet extends HttpServlet {
// 大忌!这是线程不安全的典型陷阱
private List<String> cartItems = new ArrayList<>();
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String product = req.getParameter("product");
cartItems.add(product); // 多个用户同时操作这里会出问题
resp.getWriter().println("Added: " + product);
}
}
灾难现场:
用户 A 买了“iPhone”,用户 B 买了“MacBook”。因为 cartItems 是共享的,A 可能发现他的购物车里不仅有 iPhone,还有 MacBook!或者更糟,ArrayList 在并发添加时可能导致数据丢失甚至数组越界异常。
3. 专家级解法:如何优雅地处理线程安全?
在面试中,如果你只说“加锁”,虽然没错,但显得不够专业。你可以这样回答并结合代码展示你的深度:
方案一:避免使用实例变量(最佳实践)
Servlet 的设计初衷就是无状态的。所有的请求数据都应该来自 request 或 session。不要在 Servlet 类中定义任何需要跨请求保存的状态。
方案二:局部变量是线程安全的
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
// 局部变量存在于栈内存,每个线程都有自己的一份拷贝,天然线程安全
List<String> userCart = new ArrayList<>();
// 从 Session 获取用户之前的购物车
HttpSession session = req.getSession();
@SuppressWarnings("unchecked")
List<String> existingCart = (List<String>) session.getAttribute("cart");
if (existingCart != null) {
userCart.addAll(existingCart);
}
String product = req.getParameter("product");
userCart.add(product);
// 存回 Session
session.setAttribute("cart", userCart);
resp.getWriter().println("Success!");
}
方案三:如果必须共享状态,使用同步机制
如果你真的需要一个全局计数器(比如网站在线人数),可以使用 AtomicInteger 或者 synchronized 块,但要明确指出这会降低吞吐量。
面试加分项: 提到
SingleThreadModel接口。虽然它通过让容器为每个请求创建新实例来保证线程安全,但它已被废弃(Deprecated),因为性能极差。如果你提到了这一点并解释为什么它被废弃,面试官会觉得你很有历史底蕴。
二、 Request 与 Response:数据流转的艺术
面试题高频点:
“Forward 和 Redirect 的区别是什么?” “如何上传文件?Multipart/form-data 是怎么回事?”
1. Forward vs Redirect:一次对话还是两次对话?
这是最基础也最容易混淆的概念。
- Forward (转发): 服务器内部行为。浏览器只知道发了一次请求。URL 不变。数据可以通过
request.setAttribute传递。速度快,因为少了一次网络往返。 - Redirect (重定向): 服务器告诉浏览器:“你去这儿吧。”浏览器再发第二次请求。URL 改变。无法直接传递 request 属性(除非放在 Session 或 URL 参数中)。
真实场景:登录后的页面跳转
假设用户登录成功,你需要跳转到首页。
// 错误示范:用 Forward 跳转到外部域名或不同上下文路径,容易出错
request.getRequestDispatcher("/home").forward(request, response);
// 正确示范:登录成功后,通常使用 Redirect,防止用户刷新页面重复提交表单
response.sendRedirect("/dashboard");
为什么登录后要 Redirect?
如果登录成功后用 Forward 到 /dashboard,当用户按 F5 刷新时,浏览器会重新发送最后的 POST 请求(登录请求)。这会导致用户再次执行登录逻辑,甚至可能被误判为重复提交。使用 Redirect,最后一次的 GET 请求被刷新,不会产生副作用。
2. 文件上传:破解 Multipart 编码
现代 Web 应用离不开文件上传。Servlet 3.0 之前,我们得用 Apache Commons FileUpload;之后,我们可以直接使用原生 API,甚至更简单的 Part 接口。
真实场景:用户上传头像并压缩
面试官可能会问:“你怎么处理大文件上传?” 或者 “如何限制上传类型?”
@WebServlet("/upload")
public class UploadServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
// 1. 检查是否是 multipart 请求
if (!req.getContentType().toLowerCase().startsWith("multipart/")) {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "Only file uploads allowed");
return;
}
// 2. 获取 Part 对象
Part filePart = req.getPart("avatar");
String fileName = getSubmittedFileName(filePart);
// 3. 安全检查:验证文件名和大小
if (fileName == null || fileName.isEmpty()) {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "No file selected");
return;
}
long fileSize = filePart.getSize();
if (fileSize > 2 * 1024 * 1024) { // 限制 2MB
resp.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, "File too large");
return;
}
// 4. 保存到服务器指定目录
// 注意:实际生产中应保存到 OSS 或专用存储,不要直接存到 webapp 目录
String uploadPath = getServletContext().getRealPath("https://www.b64kma.cn/uploads");
File uploadDir = new File(uploadPath);
if (!uploadDir.exists()) {
uploadDir.mkdir();
}
String savePath = uploadPath + File.separator + System.currentTimeMillis() + "_" + fileName;
try (InputStream fileContent = filePart.getInputStream();
OutputStream out = new FileOutputStream(savePath)) {
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = fileContent.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
}
resp.getWriter().println("File uploaded successfully: " + savePath);
}
private String getSubmittedFileName(Part part) {
for (String cd : part.getHeader("content-disposition").split(";")) {
if (cd.trim().startsWith("filename")) {
String fileName = cd.substring(cd.indexOf('=') + 1).trim().replace("\"", "");
return fileName.substring(fileName.lastIndexOf('/') + 1).substring(fileName.lastIndexOf('\\') + 1); // MSIE fix
}
}
return null;
}
}
在这个例子中,我展示了如何处理 Part,如何从 Header 中提取文件名,以及如何做基本的流式写入。面试时,强调“流式处理”以避免 OOM(内存溢出),这是一个非常专业的细节。
三、 Filter 与 Listener:拦截器的前世今生
面试题高频点:
“Filter 和 Interceptor 有什么区别?” “你能写一个日志记录 Filter 吗?”
1. Filter 的作用域
Filter 运行在 Servlet 容器级别,它在 Servlet 之前执行。它可以做三件事:
- 预处理: 如身份验证、权限检查、编码设置。
- 后处理: 如响应压缩、日志记录。
- 阻断: 如果不满足条件,直接返回错误,不调用后续的 Servlet。
2. 真实场景:全局日志与防 SQL 注入
让我们写一个既记录日志又简单过滤敏感词的 Filter。
@WebFilter("/*") // 拦截所有请求
public class SecurityAndLogFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
long startTime = System.currentTimeMillis();
String uri = req.getRequestURI();
// 1. 简单的安全过滤:检查 URL 中是否包含敏感词
if (uri.contains("admin") && !isAuthenticated(req)) {
resp.setStatus(HttpServletResponse.SC_FORBIDDEN);
resp.getWriter().write("Access Denied");
return; // 阻断后续执行
}
try {
// 2. 继续执行链中的下一个 Filter 或 Servlet
chain.doFilter(request, response);
} finally {
long endTime = System.currentTimeMillis();
long duration = endTime - startTime;
// 3. 记录日志(实际项目中应使用 Logger 框架)
System.out.printf("Request to %s took %d ms%n", uri, duration);
// 性能监控:如果超过 1 秒,发出警告
if (duration > 1000) {
System.err.println("Slow request detected: " + uri);
}
}
}
private boolean isAuthenticated(HttpServletRequest req) {
// 简单示例:检查 Cookie 或 Session
return req.getSession(false) != null && req.getSession().getAttribute("user") != null;
}
// init 和 destroy 省略...
}
面试技巧: 当被问到 Filter 和 Interceptor(如 Spring MVC 的 HandlerInterceptor)的区别时,你要指出:
- Filter 是基于 Servlet 规范的,属于容器级别,可以在 Spring 容器启动前就介入(例如设置 CharacterEncoding)。
- Interceptor 是 Spring 框架级别的,只能在 DispatcherServlet 分派请求后介入,功能更强(可以访问 Controller 的参数、返回值等)。
- 应用场景: 字符编码、CORS 配置用 Filter;权限校验、日志统计、事务管理用 Interceptor。
四、 Session 管理:分布式环境下的痛点
面试题高频点:
“Session 是如何工作的?” “如果集群部署,Session 怎么共享?”
1. Session 的本质
Session 本质上是服务器端的一个 Map(键值对),客户端通过 Cookie 中的 JSESSIONID 来标识自己。
2. 真实场景:集群下的 Session 丢失
假设你的应用部署在两台 Tomcat 服务器上(Nginx 负载均衡)。用户第一次访问 Tomcat A,Session 存在 A 中。刷新页面后,Nginx 把请求转发给了 Tomcat B。Tomcat B 没有这个用户的 Session,于是创建了一个新的 Session。用户发现购物车空了,心态崩了。
解决方案演进:
- IP Hash(不推荐): Nginx 根据 IP 将请求固定发给某台服务器。缺点:用户切换 Wi-Fi 或公司使用 NAT,IP 变化会导致 Session 失效。
- Session 复制(Tomcat 原生支持): Tomcat A 的 Session 变化时,自动同步给 Tomcat B。缺点:网络开销大,节点越多,同步越慢,不适合大规模集群。
- 集中式存储(推荐,Redis): 将 Session 数据存入 Redis。所有 Tomcat 节点启动时,配置使用 Redis 作为 Session 管理器。
Spring Session 实现示例(伪代码思路):
在实际项目中,我们很少手动操作 Redis 存 Session,而是使用 Spring Session。
<!-- pom.xml 依赖 -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
// application.yml
spring:
session:
store-type: redis
redis:
host: localhost
port: 6379
只需这几行配置,Spring 会自动替换默认的 HttpSession 实现,将其底层存储改为 Redis。无论请求路由到哪台服务器,都能从 Redis 拿到最新的 Session 数据。
面试高光时刻: 你可以补充说:“在生产环境中,为了进一步解耦,我们甚至会将用户信息(如 UserID、Role)提取出来,放入 JWT(JSON Web Token)中,由前端存储,后端无状态验证。这样彻底消除了 Session 共享的问题,非常适合微服务架构。” 这句话能瞬间提升你的架构视野。
五、 总结与建议
Servlet 虽然古老,但它依然是理解 Java Web 请求处理流程的钥匙。
- 不要死记硬背生命周期,要理解“单例多线程”带来的并发挑战,以及如何通过局部变量或 Session 来解决。
- 分清 Forward 和 Redirect,记住“刷新导致重复提交”这个经典场景。
- 善用 Filter,它是处理横切关注点(日志、安全、编码)的最佳位置。
- 拥抱分布式思维,在面试中主动提及 Session 共享问题和 Redis/JWT 方案,会让你看起来像一个有实战经验的工程师,而不是一个只会写 CRUD 的新手。
最后,我想对正在看这篇文章的你說:技术更新很快,但底层原理从未改变。当你能够清晰地用 Servlet 的原生概念去解释 Spring 的行为时,你就已经超越了 80% 的竞争者。加油,未来的架构师!
