Servlet 这门技术,放在十年前是 Java Web 开发者的必修课,放在今天看,很多刚接触 Spring Boot 的同学可能只在依赖清单里见过它,甚至有人觉得它已经过时了。但实际上去掉 Spring 的外壳,真正在底层处理 HTTP 请求、管理会话、控制生命周期的,还是这一套 Servlet 规范。我做了这么多年 Java 后端,踩过不少框架的坑,最后排查问题还是得回到 Servlet 容器这一层来定位。
这篇文章我不会跟你讲那些空泛的概念,而是用一次完整的项目实践,把 Servlet 从原理到落地整个串一遍。内容包括 Servlet 在 Web 服务器中的定位、它的生命周期和线程模型、请求响应的处理链路,以及我自己写原生 Servlet 项目时遇到的各种问题和处理方案。不管你是在学 SSM 之前想打牢基础,还是用 Spring Boot 写了好几年接口但从没深究过底层机制,这篇文章都值得你花点时间看完,因为框架会换,但 Servlet 这套底层逻辑不会变。
1. Servlet 到底在解决什么问题
1.1 没有 Servlet 的年代,Web 开发有多痛苦
先聊一点历史,不是为了怀旧,而是为了让你真正理解 Servlet 出现的意义。在 Servlet 规范出现之前,如果想让一台服务器对外提供动态内容,通常的做法是用 CGI(Common Gateway Interface)。CGI 的思路很简单:服务器收到一个请求,就 fork 一个进程去执行对应的脚本,脚本处理完返回 HTML,然后进程退出。
听起来没什么问题对吧?但你想一下,如果同一秒进来 100 个请求,服务器就要创建 100 个进程来处理。进程的创建和销毁需要操作系统分配内存、加载上下文,这个过程是很重的。而且每个进程之间是隔离的,你没法在一个进程里同时处理多个请求。这种做法扛不住任何一点流量,更别说保持状态了。
Servlet 把这个问题彻底换了个思路:容器启动的时候创建一组常驻对象(Servlet),每个请求到达时,由容器内部的线程池分配一个线程去调用 Servlet 的业务方法。线程比进程轻量得多,复用一个 Servlet 实例处理所有请求,避免了频繁创建销毁的开销。这就是为什么 Java 能成为企业级 Web 开发主流语言之一,Servlet 功不可没。
1.2 Servlet 的核心定位:HTTP 协议与 Java 代码之间的翻译官
Servlet 本质上是 Java 世界里对 HTTP 请求-响应模型的一种标准化抽象。它定义了一组接口和类,让你不用关心 Socket 连接、不用手动解析 HTTP 报文、不用拼接响应头,只需要实现一个继承HttpServlet的类,重写doGet或doPost方法,就能处理来自浏览器的请求。
你看一个最简单的 Servlet 代码:
@WebServlet("/hello") public class HelloServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType("text/html;charset=UTF-8"); resp.getWriter().write("<h1>Hello Servlet</h1>"); } }就这么几行,一个能响应 HTTP GET 请求的 Web 程序就跑起来了。Tomcat 帮你完成了底下所有脏活累活:监听 8080 端口、接受 TCP 连接、解析 HTTP 请求行和请求头、把参数封装成HttpServletRequest对象、调用你的doGet方法、再把你的输出包装成合法的 HTTP 响应返回给客户端。
所以你可以把 Servlet 理解为 HTTP 协议和 Java 代码之间的翻译官。它不关心你的业务逻辑是什么,只负责建立一条标准的通信管道。你的代码负责业务,容器负责协议,两边通过 Servlet 规范解耦。
1.3 为什么现在的主流框架仍然离不开 Servlet
现在很多人写接口直接用 Spring Boot,添加一个spring-boot-starter-web依赖,写个@RestController,一个接口就完成了。但你要知道,Spring Boot 内嵌的 Tomcat 底层就是 Servlet 容器,DispatcherServlet本身就是一个 Servlet。Spring MVC 做的事情,是把你写的@RequestMapping方法映射成对 Servlet 的调用,但它无法脱离 Servlet 规范独立存在。
我的建议是,在学习任何 Java Web 框架之前,先花时间把 Servlet 搞透彻。因为框架会给你很多便捷的封装,但一旦出问题,HTTP 状态码异常、请求参数丢失、过滤器不生效、会话失效,最终你排查问题还是要回到 Servlet 容器这个层面。把地基打牢,上层建筑才不会歪。
2. Servlet 核心机制拆解:生命周期、请求与响应、会话管理
2.1 生命周期:Servlet 从出生到消亡的完整过程
Servlet 的生命周期管理是理解它一切行为的基础。整个生命周期由容器负责,开发者通常只需要关注三个方法:init()、service()、destroy()。
初始化阶段,容器加载 Servlet 类,通过反射创建实例,然后调用init()方法。这个方法的调用时机有两种:一种是容器启动时立即初始化,需要在web.xml里配置<load-on-startup>为正整数;另一种是第一次请求到达时才初始化,也就是懒加载。init()只调用一次,所以适合做资源初始化的工作,比如加载配置文件、建立数据库连接池。我见过有人把耗时操作放在构造方法里,这是不对的,因为 Servlet 实例化时容器还没完成依赖注入,此时做初始化容易出问题。
请求处理阶段,每次请求到达时,容器分配一个线程,调用service()方法。HttpServlet的service()方法内部会根据 HTTP 方法类型分发到doGet、doPost、doPut等不同方法。注意,Servlet 实例是单例的,所有请求共享同一个实例,这也是后面要讲线程安全问题的根源。
销毁阶段,容器关闭或应用重新部署时调用destroy(),释放资源。把这个过程画成一个流程,就是:
加载 Servlet 类 -> 实例化 -> init() -> service() 循环处理请求 -> destroy() -> 垃圾回收理解生命周期对排查问题特别有帮助。比如你遇到过修改代码后没生效的情况,多半是容器还在用旧的 Servlet 实例,需要重新部署才能触发销毁和重新初始化。
2.2 请求与响应:一次 HTTP 请求在 Servlet 中的完整旅程
当浏览器发起一个请求,比如访问http://localhost:8080/api/users?page=1,Tomcat 收到后做了这些事:
从 TCP 连接中读取原始字节流,按照 HTTP 协议解析出请求方法、请求 URI、协议版本、请求头、请求体。然后容器创建HttpServletRequest和HttpServletResponse两个对象,前者封装了请求的所有信息,后者是容器为你准备好的响应通道。
HttpServletRequest里有几个常用的方法,我实际开发中用到频率最高的是getParameter()、getAttribute()、getSession()。这里要特别注意getParameter和getAttribute的区别,很多新手会混淆。getParameter获取的是客户端提交的数据,也就是 URL 参数或表单提交的数据;getAttribute获取的是服务端在请求转发时通过setAttribute存入的数据。简单记:一个来自客户端,一个来自服务端内部。
响应对象比较关键的几个操作是设置状态码setStatus()、设置响应头setHeader()、设置内容类型setContentType()、获取输出流getWriter()或getOutputStream()。这里有个易错点:字节流输出和字符流输出只能二选一,同时调用会抛异常。字符流适合输出文本,字节流适合输出文件下载等二进制内容。
2.3 会话跟踪:HTTP 的无状态困境与 Session 方案
HTTP 协议本身是无状态的,每一次请求都是独立的,服务器无法区分两个请求是否来自同一个浏览器。但业务上你肯定需要知道用户是否已经登录、购物车里放了什么,这就引入了会话跟踪机制。
Servlet 规范提供了HttpSession接口来解决这个问题。基本原理是:容器在为请求创建 Session 时,生成一个唯一的 ID,通过Set-Cookie响应头把这个 ID 写入浏览器 Cookie,之后每次请求浏览器自动携带这个 Cookie,容器通过 Cookie 值找到对应的 Session 对象。
HttpSession session = req.getSession(); session.setAttribute("userId", 10001); session.setMaxInactiveInterval(1800);getSession()方法在没有 Session 时会自动创建一个新的,如果你只是想获取已有的 Session,应该用req.getSession(false),避免在不需要创建时白白生成一个。
Session 的失效时机也很重要。默认情况下,容器会维护一个 Session 超时时间,超过这个时间没有请求,Session 就被销毁。Tomcat 默认是 30 分钟,你可以在web.xml中配置:
<session-config> <session-timeout>30</session-timeout> </session-config>这里的 30 是分钟,和setMaxInactiveInterval的单位不同,后者是以秒为单位。这个细节我踩过坑,以为设的是分钟,结果实际是秒,导致 Session 一两分钟就过期了。
3. 从零写一个原生 Servlet 项目:用户管理系统实战
3.1 项目规划与技术选型思路
为了把上面这些概念串起来,我用原生 Servlet 写一个简单的用户管理系统。功能包括:用户列表展示、新增用户、用户详情查看、登录认证、访问控制。刻意不使用任何框架,只用 Servlet + JSP + JDBC,这样你能看到 Web 应用最原始的样子。
项目结构是这样的:
src/main/java ├── com.example.servlet │ ├── dao │ │ └── UserDao.java │ ├── entity │ │ └── User.java │ ├── filter │ │ ├── EncodingFilter.java │ │ └── LoginFilter.java │ ├── servlet │ │ ├── LoginServlet.java │ │ ├── LogoutServlet.java │ │ ├── UserListServlet.java │ │ └── UserDetailServlet.java │ └── util │ └── DBUtil.java src/main/resources └── db.properties src/main/webapp ├── WEB-INF │ └── web.xml ├── index.jsp ├── login.jsp ├── user_list.jsp └── user_detail.jsp3.2 数据库访问层的设计要点
我用 MySQL 作为存储,建一张简单的用户表:
CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password` varchar(100) NOT NULL, `email` varchar(100) DEFAULT NULL, `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;数据库连接我做了个简单的工具类,把连接参数放在db.properties配置文件里,用静态代码块加载驱动。生产上肯定要用连接池,但这里为了演示原生 JDBC 流程,先直接用DriverManager。
public class DBUtil { private static String url; private static String username; private static String password; static { try { InputStream in = DBUtil.class.getClassLoader() .getResourceAsStream("db.properties"); Properties props = new Properties(); props.load(in); Class.forName(props.getProperty("driver")); url = props.getProperty("url"); username = props.getProperty("username"); password = props.getProperty("password"); } catch (Exception e) { throw new ExceptionInInitializerError("数据库初始化失败"); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, username, password); } public static void close(Connection conn, PreparedStatement ps, ResultSet rs) { if (rs != null) { try { rs.close(); } catch (SQLException e) {} } if (ps != null) { try { ps.close(); } catch (SQLException e) {} } if (conn != null) { try { conn.close(); } catch (SQLException e) {} } } }写代码的时候一定要记得关闭资源,而且要按 ResultSet、Statement、Connection 的顺序逆序关闭。Java 7 之后可以用 try-with-resources 语法,但我保留这个传统写法是为了让你看清楚资源的释放逻辑。
3.3 Servlet 层的请求分发与参数处理
用户列表功能需要查询数据库,把用户数据放进request.setAttribute("userList", list),然后转发到 JSP 页面渲染。
@WebServlet("/user/list") public class UserListServlet extends HttpServlet { private UserDao userDao = new UserDao(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); List<User> userList = userDao.findAll(); req.setAttribute("userList", userList); req.getRequestDispatcher("/WEB-INF/user_list.jsp").forward(req, resp); } }转发和重定向的区别是重点。req.getRequestDispatcher(url).forward(req, resp)是服务器内部跳转,地址栏不变,request 对象可以共享;resp.sendRedirect(url)是浏览器重新发起一次请求,地址栏变化,request 对象不共享。登录成功后应该使用重定向,防止表单重复提交;查询列表后展示数据应该使用转发。
新增用户我用doPost处理,接收参数后做校验,再调用 DAO 层保存。这里要重点演示参数获取,因为实际项目中表单提交的数据类型繁多,需要逐个getParameter并转型:
@WebServlet("/user/add") public class UserAddServlet extends HttpServlet { private UserDao userDao = new UserDao(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String username = req.getParameter("username"); String password = req.getParameter("password"); String email = req.getParameter("email"); // 简单校验,更多校验逻辑这里省略 if (username == null || username.trim().isEmpty()) { req.setAttribute("error", "用户名不能为空"); req.getRequestDispatcher("/user/add.jsp").forward(req, resp); return; } User user = new User(); user.setUsername(username); user.setPassword(password); user.setEmail(email); boolean result = userDao.insert(user); if (result) { // 添加成功,重定向到列表页,防止刷新重复提交 resp.sendRedirect(req.getContextPath() + "/user/list"); } else { req.setAttribute("error", "添加失败"); req.getRequestDispatcher("/user/add.jsp").forward(req, resp); } } }3.4 Filter 实现统一编码与登录拦截
如果说 Servlet 是处理业务请求的入口,那 Filter 就是在入口之前的检查关卡。一个经典的场景是统一设置请求和响应的编码,避免每个 Servlet 都重复写编码设置。我现在项目里用了三层服务架构,每个 Controller 不用关心编码问题,全靠 Filter 在入口统一处理。
@WebFilter("/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; req.setCharacterEncoding("UTF-8"); resp.setCharacterEncoding("UTF-8"); resp.setContentType("text/html;charset=UTF-8"); chain.doFilter(req, resp); } }登录拦截 Filter 更为关键。我需要检查每个请求的 Session 中是否存在登录标识,如果没有,就重定向到登录页。要注意放行登录相关的路径,否则会陷入死循环:访问登录页被拦截,重定向到登录页,又被拦截。
@WebFilter("/*") public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; String uri = req.getRequestURI(); String contextPath = req.getContextPath(); if (uri.startsWith(contextPath + "/login") || uri.startsWith(contextPath + "/static")) { chain.doFilter(req, resp); return; } HttpSession session = req.getSession(false); if (session == null || session.getAttribute("loginUser") == null) { resp.sendRedirect(contextPath + "/login.jsp"); return; } chain.doFilter(req, resp); } }目录结构里我把 JSP 页面放在/WEB-INF下,这是 Web 应用的受保护目录,浏览器直接访问不到,只能通过 Servlet 转发访问。这层物理隔离比 Filter 拦截更安全,属于双保险。
3.5 web.xml 的配置思路与注解的取舍
现代 Servlet 3.0+ 规范支持注解配置,@WebServlet、@WebFilter可以免去web.xml中的配置。但我仍然会保留一个web.xml,因为它承担了部分注解做不到的事,比如 Session 超时时间、欢迎文件列表、错误页面映射。
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" version="4.0"> <display-name>Servlet Demo</display-name> <welcome-file-list> <welcome-file>index.jsp</welcome-file> </welcome-file-list> <session-config> <session-timeout>30</session-timeout> </session-config> <error-page> <error-code>404</error-code> <location>/error404.jsp</location> </error-page> <error-page> <exception-type>java.lang.Exception</exception-type> <location>/error.jsp</location> </error-page> </web-app>注解和 XML 的取舍,我的原则是:能用注解的尽量用注解,减少配置文件的体量,提高开发效率;像 Session 超时、全局错误页这类全局配置,放web.xml更清晰,查找也方便。
3.6 JSP 页面渲染与 EL 表达式
JSP 本质上是 Servlet 的一种变体,容器会把 JSP 文件编译成一个HttpJspPage实现类的 Java 源码,再编译成 Class。所以 JSP 不是运行时的模板引擎,它本身就是 Servlet。我在 JSP 里使用 EL 表达式来获取 request 或 session 中的数据,避免在页面里写 Java 脚本片段。
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head> <title>用户列表</title> </head> <body> <h2>用户列表</h2> <table border="1"> <tr> <th>ID</th> <th>用户名</th> <th>邮箱</th> <th>创建时间</th> <th>操作</th> </tr> <c:forEach items="${userList}" var="user"> <tr> <td>${user.id}</td> <td>${user.username}</td> <td>${user.email}</td> <td>${user.createdAt}</td> <td><a href="${pageContext.request.contextPath}/user/detail?id=${user.id}">查看</a></td> </tr> </c:forEach> </table> <a href="${pageContext.request.contextPath}/user/add.jsp">新增用户</a> </body> </html>这里我想特别提醒一点:${pageContext.request.contextPath}这个表达式的意义。如果不加这个前缀,你写死/user/list路径,在应用部署到根路径时可以访问,但一旦部署为某个子应用,比如http://localhost:8080/demo/user/list,所有写死的路径就全 404 了。养成使用 contextPath 的习惯,可以避免这个典型的部署路径问题。
4. Servlet 高级特性与性能优化实践
4.1 异步 Servlet:解决长轮询与耗时任务的阻塞问题
Servlet 3.0 引入了异步处理能力,这个特性的价值在传统同步模型下被很多人低估了。传统的同步 Servlet,请求处理线程被业务逻辑阻塞,Tomcat 默认线程池是 200,如果 200 个请求都在等待慢业务返回,第 201 个请求就只能排队。异步 Servlet 允许线程直接返回,把耗时任务挂起,等业务完成后通过AsyncContext再推送响应。
@WebServlet(value = "/async/hello", asyncSupported = true) public class AsyncServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { AsyncContext asyncContext = req.startAsync(); asyncContext.start(() -> { try { Thread.sleep(3000); asyncContext.getResponse().getWriter().write("Async done"); } catch (Exception e) { e.printStackTrace(); } finally { asyncContext.complete(); } }); } }注意asyncSupported = true这个属性必须设置,否则调用startAsync()会抛出IllegalStateException。我在接入 WebSocket 推送消息时就用了这个方案,效果比同步阻塞好得多。
4.2 Servlet 线程安全问题与最佳实践
前面提到 Servlet 是单例多线程的,这意味着实例变量是所有请求共享的,存在严重的线程安全问题。看下面这个反面教材:
public class DangerServlet extends HttpServlet { private UserService userService = new UserService(); // 没问题 private int count = 0; // 严重问题:多线程并发修改 @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) { count++; } }count++在并发场景下不是原子操作,会出现重复计数或计数丢失。正确做法是:能不用实例变量的就不需要用,确需保留的用AtomicInteger、synchronized或ThreadLocal。实际开发中,Servlet 里只放无状态服务依赖,业务数据一律通过方法参数传递。
推荐的做法是用ThreadLocal解决部分上下文传递问题。比如通过 Filter 解析出当前登录用户,存在 ThreadLocal 里,后续所有业务代码都能直接取到:
public class UserContext { private static final ThreadLocal<User> HOLDER = new ThreadLocal<>(); public static void set(User user) { HOLDER.set(user); } public static User get() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } }4.3 文件上传下载的细节处理
文件上传是 Servlet 里绕不开的需求。Servlet 3.0 之后,@MultipartConfig注解配合getPart()方法处理上传极其方便,彻底告别了以前第三方组件解析的繁琐流程。
@WebServlet("/upload") @MultipartConfig(maxFileSize = 5 * 1024 * 1024, maxRequestSize = 10 * 1024 * 1024) public class UploadServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { Part filePart = req.getPart("file"); String submittedFileName = filePart.getSubmittedFileName(); // 解决上传文件名的路径问题 String fileName = Paths.get(submittedFileName).getFileName().toString(); String savePath = "/data/upload/" + fileName; filePart.write(savePath); resp.getWriter().write("上传成功:" + fileName); } }一个很隐蔽的坑是getSubmittedFileName()返回的文件名可能包含客户端伪造的路径信息,比如../../etc/passwd。所以一定要用Paths.get(fileName).getFileName().toString()提取纯文件名部分。这是我在安全测试中遇到的真实攻击场景,很多上传漏洞都是因为没做这个处理。
4.4 性能优化:从连接池到响应压缩
从 JDBC 直连换成连接池,是性能提升最明显的一步。我最初用直连方式做压测,250 个并发请求时数据库连接频繁报错,换了连接池之后稳定支持到 2000 QPS 左右。Apache Commons DBCP 和 HikariCP 我都用过,后者性能更好,推荐新手直接用 HikariCP。
另外一个容易忽视的优化是学些响应内容压缩。Tomcat 的压缩配置很简单,在server.xml的Connector节点加几个属性:
<Connector port="8080" protocol="HTTP/1.1" compression="on" compressionMinSize="1024" noCompressionUserAgents="gozilla, traviata" compressibleMimeType="text/html,text/xml,text/plain,text/javascript,application/json"/>设置之后超过 1KB 的文本响应会自动 gzip 压缩。这在带宽受限的网络环境下效果很出色,页面体积直接减掉 60% 以上。
5. 常见问题与排查技巧实录
5.1 404 与 500 错误的本质区别
报 404 时,第一反应不是去改代码,而是先确认资源到底在哪里。404 的原因通常是三类:URL 写错、Servlet 未映射、部署路径不对。排查流程我建议从浏览器网络请求开始:看请求的完整 URL、请求的上下文路径、Servlet 的@WebServlet注解路径是否匹配。
报 500 就相对好排查,因为 Tomcat 会在错误页面中打印异常堆栈。拿到堆栈先看最顶部的几行,通常定位到具体是哪个类哪一行。常见原因包括 null 指针、数据库连接失败、参数类型转换异常。看异常堆栈有个技巧:往下翻到Caused by部分,这里才是导致异常的根因。很多新手只看第一行,导致明明数据库连不上却一直在检查代码。
5.2 中文乱码问题的一揽子解决方案
中文乱码是老生常谈,但几乎每个项目都会遇到。乱码的本质是编码和解码不一致。你要记住一条黄金法则:从客户端到浏览器,每一环节的字符编码必须统一为 UTF-8。
具体有三个关键环节:
一是请求参数解码。Post 请求通过req.setCharacterEncoding("UTF-8")解决;Get 请求的参数在 URL 中,这个设置不生效,需要改 Tomcat 的 URI 编码,在server.xml的 Connector 节点加URIEncoding="UTF-8"。
二是响应编码。设置resp.setContentType("text/html;charset=UTF-8")让浏览器用 UTF-8 解码响应体。
三是数据库连接编码。JDBC URL 必须加上useUnicode=true&characterEncoding=UTF-8,否则数据存进库再读出来就变了。
这三个环节只要有一个出错,乱码就会出现。排查时用一个简单的原则:在每一环节打印数据,看看到底从哪一步开始变的。我之前遇到过一次诡异乱码,数据在代码里显示正常,存入 MySQL 再查出来就乱码了,排查半天发现是表本身的字符集是 latin1,改成 utf8mb4 后就正常了。
5.3 部署与热更新的注意点
部署 Servlet 项目时,我很早就发现一个要点:开发期用 IDEA 或 Eclipse 的插件方式部署,改代码后自动重新加载;生产环境则建议打包成 WAR 包部署到独立的 Tomcat。两种模式的差异在于开发期插件会用 Exploded 模式把编译后的文件直接部署到容器中,改 JSP 能立即生效,但改 Java 类仍需重新编译并触发容器重载。
Tomcat 默认支持热部署,监控webapps目录下的 WAR 包变化,发现新的或更新的包会自动部署。但有个坑:自动部署过程中如果流量太大,可能出现短暂中断。线上环境我建议关闭自动部署,改成手动维护deployOnStartup属性,用脚本控制部署流程。
还有一种常见问题是在更新代码时被 IDE 提示 "SEVERE: The web application appears to be started by a different thread",这通常是因为容器在异步线程中执行代码,重新加载时主线程判定错误,属于容器自身的误报,重启容器就好。不必过度紧张,但如果你在启动代码中直接开启后台线程,容器关闭时就要注意线程是否跟着结束,否则会出现类加载器泄漏。
5.4 Session 丢失问题排查记录
Session 丢失在用户登录场景中非常致命。用户登录成功,跳转页面后 Session 数据没了,表现为用户被强行退回登录页。我排查这个问题的经验是先确认两点:第一,浏览器是否禁用了 Cookie;第二,Cookie 的 Path 是否覆盖了当前访问的路径。
Tomcat 默认的会话 Cookie 名为JSESSIONID,Path 默认是/,也就是整个应用有效。但如果你的应用部署在/app1上下文下,访问http://localhost:8080/app1/user/list,而 Cookie 的 Path 被改成了/other,那浏览器就不会携带这个 Cookie,Session 自然就找不到了。用浏览器开发者工具看请求头里的 Cookie,如果没有JSESSIONID,基本可以断定是 Path 不匹配。
另一个容易忽略的场景是跨域名共享 Session。如果用户从a.com跳到b.com,两个域名之间 Cookie 不互通,Session 自然也不共享。解决思路有两个:domain=com的方式只在二级域名有效,或者用单点登录系统。这个是架构层面的事,但你要知道问题根源在 Cookie 的跨域限制。
5.5 类加载冲突与依赖版本问题
Tomcat 的类加载器体系有自己的层级,lib目录下的 jar 由 Common 类加载器加载,WEB-INF/lib下的 jar 由 Web 应用自己的类加载器加载。这造成一个经典问题:如果多个 Web 应用部署在同一个 Tomcat 中,某些公共类要放到lib目录,而不是每个应用的WEB-INF/lib都放一份。
另外,如果lib目录和WEB-INF/lib存在同名不同版本的 jar,会报NoSuchMethodError或ClassNotFoundException。有一年我在维护旧项目时,项目里引了老版本的mysql-connector-java,Tomcat 的lib里放了一个新版本,驱动类加载优先级不同,导致项目跑起来部分接口正常、部分报错。后来统一了版本,并把公共 jar 移到lib目录,问题才彻底解决。
排查这类问题可以用一个技巧:在代码中打印类的加载路径。
Class.forName("com.mysql.jdbc.Driver").getClassLoader()或者启动时加-verbose:class参数,JVM 会把每次加载类的来源打印出来。
5.6 常用排查命令与技能速查
写一些常用的排查命令,这些在实际线上排障时非常有用:
# 查看端口占用,确认 Tomcat 是否在运行 netstat -anp | grep 8080 # 查看 Tomcat 实时日志 tail -f /opt/tomcat/logs/catalina.out # 查看指定的应用日志 tail -f /opt/tomcat/logs/localhost.2024-01-15.log # 查看线程栈,排查死锁或线程阻塞 jstack <pid> > thread_dump.txt # 查看堆内存使用情况 jmap -heap <pid>当遇到请求卡住的情况,直接jstack拿线程栈,搜索http-nio-8080-exec-开头的线程,看它们阻塞在哪个方法上,问题基本就能定位。这个方法帮我排查过数据库连接池耗尽、Redis 超时、第三方接口无响应等多个线上故障。
6. 亲手做一遍这个项目后,我对 Servlet 的几点思考
6.1 Servlet 的价值不只在技术上,更在思维模式
整个项目做下来,最大的体会是:Servlet 强迫你理解 HTTP 的每一个细节。你用 Spring Boot 写接口时,参数绑定、JSON 转换、异常处理都是框架帮你搞定的,你不需要关心请求是怎么进来的、响应是怎么出去的。但当你用 Servlet 手写这些流程时,你会深刻理解 HTTP 协议的无状态性、请求与响应的生命周期、数据是如何在网络中流转的。
这种理解在排查问题时非常有用。有一次我排查一个线上接口偶尔超时的问题,Spring Boot 应用里怎么看都像是接口业务代码的问题,后来一层层往下追,发现问题出在 Tomcat 的maxThreads配置上,线程池被长连接耗尽。如果没有 Servlet 容器的知识储备,根本不会往这个方向排查。
6.2 关于学习路径的建议
我接触过不少同学的简历,写着熟悉 Java Web,但实际上只写过 Spring Boot 的 CRUD。问到底层原理,基本都是"面试背过,实际没用过"的状态。我的建议是,无论你用什么框架开发,都应该完整地写过一次原生 Servlet 项目,感受一次没有框架的世界。
只有当你亲手处理过表单参数、手动设置过响应编码、自己写过 Filter 做登录校验,你才能真正理解 Spring MVC 的设计价值,也才能在遇到问题时准确判断到底是框架的问题还是容器的问题。我见过有人花了三天排查一个 Spring 配置问题,最后发现是 Tomcat 连接数满了,这类场景你要是了解 Servlet 容器,起初就会注意到这一类可能。
6.3 最后分享一个学习中的小技巧
如果你想快速验证自己对 Servlet 的理解,有一个很有效的自测方式:用原生 Servlet 实现一个 REST 接口,包含路径参数解析、请求体 JSON 解析、响应 JSON 输出这三个能力,不许用任何框架。当你发现你居然需要自己写 JSON 序列化工具、自己解析 URL 路径参数时,你才会真正明白那些框架替你做了多少工作。
这个过程对初学者来说可能有点痛苦,但这是值得的。咬咬牙写完整,你会对 Java Web 开发的整体脉络有通透的理解。后面再接触 Spring 全家桶,就能做到知其然也知其所以然。