Java Web 这个方向里,Servlet是那种“你绕不开、躲不掉、最后还得感谢它”的老前辈。哪怕你现在项目里全是 Spring Boot 起步,Swagger 文档点得飞起,底层处理 HTTP 请求的核心组件依然是 Servlet——那个藏在 DispatcherServlet 背后真正干活的东西。这篇文章不打算讲多高深的理论,我以自己做过的一个用户管理系统为例,从 Servlet 的本质开始,到生命周期、请求响应、会话管理,最后把几个高频坑一起给你捋一遍,保证让你看完能直接上手。
1. 聊聊Servlet的本质:一次请求从哪来、到哪去
1.1 没有Servlet的时候,我们面对的是什么
先回退到最原始的场景。在没有 Servlet 这些框架之前,HTTP 请求过来,服务器要想给你返回一段动态内容,得从最底层的 Socket 开始解析报文。你需要自己处理 TCP 连接、读取字节流、按 HTTP 协议规则解析请求头、请求体,再把业务结果拼装成 HTTP 响应写回去。这一套流程又琐碎又重复,而且极易出错,稍微处理错一个换行符,客户端那边就是一片乱码或者连接异常。
Servlet 的价值就在于:它把这条复杂链路封装成了标准接口。你只需要关注“收到一个请求之后,业务逻辑做什么”,至于请求怎么来、响应怎么回、连接怎么处理,这些通通交给容器(Tomcat、Jetty 这类)完成。换句话说,Servlet 是服务器和业务代码之间的一层标准契约,基于这套契约,你写的业务代码可以在任何遵循 Java 规范的 Web 容器里运行,不需要为不同的服务器单独适配。
1.2 一次请求的完整旅程
一个请求从浏览器发出到页面显示结果,中间经历的过程大概是这样的:
浏览器发起 HTTP 请求,经过网络到达服务器,监听端口的 Web 容器(比如监听 8080 的 Tomcat)接收并解析请求,容器根据 URL 匹配到对应的 Servlet 映射,然后创建或者复用该 Servlet 实例,调用它的service()方法。Servlet 内部通过HttpServletRequest对象读取参数、通过HttpServletResponse对象写出内容。容器再把响应数据打包成 HTTP 响应发送回浏览器,最后释放本次请求相关资源。
这里有个初学者很容易误解的点:HttpServletRequest和HttpServletResponse不是 Servlet 自己创建的,它们是容器帮你"包装"好的对象。你只管用,不用管底层到底是怎么把输入流变成这两个对象的。就像叫外卖,你只需要打开餐盒吃,至于餐盒是怎么从后厨经过配送链到门口的,不需要自己操心。
1.3 Servlet 到底解决什么问题
把 Servlet 放到一个更大的坐标系里看,它解决了三个层面的问题:
第一是标准化。Java 通过一系列接口(Servlet、HttpServlet)和类(HttpServletRequest、HttpServletResponse)固化了 Web 开发的骨架,开发者写一次,到处运行。第二是职责分工。Servlet 中应该只处理请求调度和业务结果输出,具体的业务逻辑可以放到 Service、Dao 层去,这种分层思想一直延续到今天。第三是生命周期管理。Servlet 的创建、初始化、服务、销毁都由容器统一负责,开发者只需要在相应的方法里写逻辑,不需要自己管理线程和连接池。
2. 从零搭建:第一个Servlet的诞生全过程
2.1 开发环境准备与工程结构
开始动手之前,先说下环境。最常见的组合是:JDK 8 + Tomcat 8.5 或 Tomcat 9 + Maven。Tomcat 版本和 Servlet 规范有对应关系:Tomcat 8.5 对应 Servlet 3.1,Tomcat 9 对应 Servlet 4.0,Tomcat 10 对应 Servlet 5.0(包名从javax.servlet变成了jakarta.servlet,这点特别重要,如果用 Tomcat 10+,引入依赖的时候注意包名)。
我用 Maven 建了个最基础的 Web 工程。pom.xml里加依赖其实只需要一个:
<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency>注意这个scope是provided,意思是编译和测试时会用到,但打包到 WAR 里不需要包含它,因为 Tomcat 自带了 Servlet API。有些新手没注意,把 Servlet API 也打进包里,启动的时候反而容易版本冲突。
标准 Web 工程的目录结构这样:
src/main/java // 源码 src/main/webapp // Web 根目录 ├── WEB-INF │ └── web.xml └── index.jsp2.2 第一种方式:注解方式注册
Servlet 3.0 以后,注册方式简洁了很多,直接在类上打注解就行:
@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("<h2>Hello Servlet!</h2>"); } }访问路径是http://localhost:8080/项目名/hello。这里有个细节:@WebServlet的路径必须以/开头,不然容器启动时会直接报错“Servlet [xxx] has no mapping”。
2.3 第二种方式:web.xml 方式注册
有的遗留项目还在用 XML 配置方式,比如某公司旧平台维护的时候,你打开web.xml会看到类似内容:
<servlet> <servlet-name>hello</servlet-name> <servlet-class>com.example.HelloServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>hello</servlet-name> <url-pattern>/hello</url-pattern> </servlet-mapping>两种方式是等价的,注解方式省配置,XML 方式在某些场景下更好做统一管理。我个人建议新项目直接用注解,维护老项目的时候留意两种方式不要混用同一个路径,否则启动时会因为映射冲突起不来。
2.4 第三种方式:编程式注册
第三种是编程式注册,一般用在一个 Servlet 需要根据配置动态决定是否启用或者使用自定义初始化参数的时候。通过实现ServletContextListener在上下文初始化阶段动态添加:
@WebListener public class DynamicServletRegister implements ServletContextListener { @Override public void contextInitialized(ServletContextEvent sce) { ServletRegistration.Dynamic dynamic = sce.getServletContext().addServlet("adminServlet", new AdminServlet()); dynamic.addMapping("/admin/*"); dynamic.setLoadOnStartup(1); dynamic.setInitParameter("role", "admin"); } }这种方式比较少见,但理解它对掌握容器的动态配置机制很有帮助。
2.5 load-on-startup 参数的意义
<load-on-startup>1</load-on-startup>或者注解里没有直接对应的属性时,可以通过@WebServlet(loadOnStartup = 1)指定。不设置的话,Servlet 默认是在容器收到第一个请求时才创建实例:起作用的是“懒加载”,能省资源;但有些 Servlet 需要做繁重的初始化工作(比如加载缓存、创建线程池),放在第一个请求里会导致用户首次访问特别慢。遇到这种场景就把loadOnStartup配为 1,让容器启动时提前完成初始化。这个参数我实际用下来,对系统的“冷启动体验”影响非常大,尤其是后台管理系统里的公共数据加载。
3. 生命周期与核心API,这一块必须吃透
3.1 Servlet 生命周期:三个方法一台戏
Servlet 的生命周期体现在三个方法:init()、service()、destroy()。
容器首次创建 Servlet 实例后调用init(),这个方法通常用于加载配置、建立数据库连接池等一次性准备工作。init()有两个版本,无参版和接收ServletConfig的版本,一般推荐覆盖带参版本,因为可以方便地从ServletConfig里读取初始化参数。service()会在每次请求时被调用,它根据 HTTP 方法类型把请求分发给doGet()、doPost()等处理方法。destroy()在容器销毁 Servlet 实例之前调用,这里释放资源,比如关闭数据源连接。
关键内存模型要记住:容器对每个 Servlet 只维护一个实例,这个实例被所有请求线程共享。所以service()方法里的局部变量是线程安全的,但类成员变量不是。如果写了一个可变的成员变量(比如private int count = 0;),多个请求同时操作它,会出现并发问题。后面排查部分我会细讲。
3.2 HttpServletRequest:从请求里拿东西的技巧
HttpServletRequest封装了请求行、请求头、请求体。常用方法:
getParameter(String name)获取表单参数或 URL 查询参数getParameterValues(String name)获取同名多值参数,比如复选框getHeader(String name)获取请求头getRequestURI()、getRequestURL()获取请求路径getMethod()获取请求方法getSession()获取或创建 Session 对象
有几点心得:参数很多时,容器会把参数放到一个 Map 里,该 Map 的键是参数名,值是字符串数组。所以getParameter返回第一个值,getParameterValues返回全部值。文件上传这类场景,参数不在表单字段里,而是在请求体中按 multipart 格式组织,普通getParameter是拿不到的,要用Part接口读取,或者配合 commons-fileupload 处理。
3.3 HttpServletResponse:把结果写回客户端的正确姿势
HttpServletResponse常用的有:
setStatus(int code)设置状态码setHeader(String name, String value)设置响应头setContentType(String type)设置内容类型,常用text/html;charset=UTF-8getWriter()获取字符输出流,用于输出文本内容getOutputStream()获取字节输出流,用于输出二进制数据,比如图片下载
抓一个新手常犯的错误:要么getWriter()要么getOutputStream(),只能调用其中一个,不能混用。在一次请求处理中先调了字符流又试图拿字节流,容器会抛IllegalStateException。
3.4 ServletContext:整个应用的共享内存
ServletContext是应用级别的对象,一个 Web 应用一个实例,所有 Servlet 共享同一个ServletContext。它最核心的两个用途:一是作为全局参数读取通道(web.xml 里配置 context-param 标签);二是作为应用级数据的共享区域,例如用它的setAttribute记录全局访问量。但要注意,ServletContext里的数据是全应用可见的,相当于一个全局 Map,随意往里面塞业务数据会带来并发访问和内存泄漏风险,只建议存放启动配置类的东西,比如应用全局标记、资源路径等。
4. 请求响应实战:表单提交、参数获取、乱码问题、跳转方式
4.1 一个用户注册的完整流程
用一个注册功能来看 Servlet 是怎么处理数据的。register.html里的表单:
<form action="register" method="post"> 用户名:<input type="text" name="username"><br> 密码:<input type="password" name="password"><br> 兴趣爱好:<input type="checkbox" name="hobby" value="code">编码 <input type="checkbox" name="hobby" value="music">音乐 <input type="checkbox" name="hobby" value="sport">运动 <input type="submit" value="注册"> </form>后端 Servlet:
@WebServlet("/register") public class RegisterServlet extends HttpServlet { @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[] hobbies = req.getParameterValues("hobby"); resp.setContentType("text/html;charset=UTF-8"); PrintWriter out = resp.getWriter(); out.write("<h3>注册信息</h3>"); out.write("<p>用户名:" + username + "</p>"); out.write("<p>密码:" + password + "</p>"); out.write("<p>兴趣爱好:" + String.join("、", hobbies) + "</p>"); } }这个例子把上面提到的关键 API 全部串起来了。值得注意的是,注册按钮点击之前,如果某些 checkbox 没有勾选,getParameterValues返回 null,直接String.join("、", hobbies)会空指针。实际项目里记得判空。
4.2 中文乱码问题:前后端都得管
中文乱码可以说是 Java Web 绕不过去的坎。乱码的本质是编码和解码使用字符集不一致。我给一个能覆盖绝大多数场景的组合:
POST 请求提交数据时,在读取任何参数之前先调用req.setCharacterEncoding("UTF-8")。这个方法对 GET 请求无效,因为 GET 请求的参数是放在 URL 的查询字符串里的。在 Tomcat 8.0 及以上版本中,GET 请求的 URI 默认使用 UTF-8 解码,所以通常不会出问题;如果你还在用老版本 Tomcat,可能需要改server.xml里的Connector节点,加上URIEncoding="UTF-8"。
响应乱码:给resp设置的Content-Type要同时指定编码,即text/html;charset=UTF-8,并且要在使用getWriter()之前设置。这个顺序很关键,因为一旦获取了 Writer,响应字符集的设置就晚了。
还有一类典型乱码出现在传给下游服务时,表现为数据库里存进去中文正常,但接口返回给前端变问号。这个一般是中间某层(比如 HTTP 客户端配置的字符集)没有指定 UTF-8。排查思路就是沿着数据流动路径,一层一层看编码设置。
4.3 跳转方式:转发和重定向,别再用错
新人对转发(forward)和重定向(redirect)经常混淆,区分它们有个很简单的“浏览器地址栏观察法”:
转发的时候,URL 不变,因为整个过程发生在服务器内部,浏览器只发了一次请求。用代码实现:
req.getRequestDispatcher("/welcome").forward(req, resp);重定向的场景,服务器会返回一个 302,然后告诉浏览器“你去访问这个新地址”,浏览器重新发起第二次请求,地址栏会变化。用代码实现:
resp.sendRedirect(req.getContextPath() + "/welcome");实际业务中怎么选?如果只是内部流程跳转、需要把 request 里的数据带到下一个 Servlet,用转发;如果注册成功后防止用户刷新页面重复提交,或者跳转到外部地址,用重定向。我当年做用户注册功能时,注册成功用重定向跳转到登录页,利用了这个机制天然减少重复提交问题的优点。
转发带数据的典型写法是把对象塞进 request 属性域:
req.setAttribute("user", user); req.getRequestDispatcher("/userInfo").forward(req, resp);request在转发前后保持同一个对象,所以属性可以被下个页面读取到。但如果是重定向,request就变了,想要带数据只能通过 URL 参数拼接,比如:
resp.sendRedirect(req.getContextPath() + "/welcome?msg=success");4.4 获取参数的编码细节
表单的 method 不写默认是 GET,此时如果表单里有中文输入,在 Tomcat 8 以后通常没问题。但有些老接口对接方会用 ISO-8859-1 编码发送参数,收到这种数据时我们可以手动转换:
// 如果容器默认按 ISO-8859-1 解码了 POST 参数(极少见) String raw = req.getParameter("name"); String decoded = new String(raw.getBytes("ISO-8859-1"), "UTF-8");这个转换的原理是:先把字符串按原来的错误编码还原成字节数组,再用正确的编码重新解码。这招属于“补救措施”,搞清楚原理能帮你解决很多对接老系统的怪问题,但新开发的项目不要依赖这种方式,把编码统一成 UTF-8 才是正解。
5. 会话状态管理:Cookie和Session怎么选、怎么用
5.1 Http协议为什么需要Session
HTTP 协议本身是无状态的。每次请求之间没有任何关联,服务器认不出“这就是刚才那个用户”。但是注册登录、购物车这些场景必须识别出"当前是谁",于是有了会话机制。核心思路是:服务器给每个浏览器发一个唯一标识,后续请求带上这个标识,服务器就能认出你来。
这个唯一标识在 Java Web 里默认叫JSESSIONID。第一次访问服务器时,容器会创建HttpSession对象,并通过 Cookie 把JSESSIONID写到浏览器。下次请求时 Cookie 会被自动带上,服务器根据这个 ID 找到对应的 Session。
5.2 Session 的创建时机和使用细节
很多人以为访问任何一个 Servlet 都会创建 Session,其实不是。只有当代码里主动调用req.getSession(),或者 JSP 页面默认设置了session="true",才会创建。有了这个认知后,如果发现日志中 Session 创建数量异常多,先检查是不是无关接口触发了getSession()。
使用 Session 时几个常用操作:
HttpSession session = req.getSession(); session.setAttribute("user", user); // 存放登录用户 session.getAttribute("user"); // 获取用户 session.removeAttribute("user"); // 退出登录 session.invalidate(); // 销毁整个会话Session 默认存活时间是 30 分钟,从最后一次访问开始计算。可以修改 web.xml 里的session-timeout,单位是分钟:
<session-config> <session-timeout>60</session-timeout> </session-config>这里有个时限的坑:如果需求是“登录超过 15 分钟没操作就强制退出”,需要把这个时间配小点或者由业务逻辑来处理判断最后活跃时间,不要只依靠容器默认的超时机制。
5.3 Cookie 与 Session 的配合和边界
Cookie 能存的东西很有限,单条最大 4KB,一个域名下数量有限制,而且存的是明文,不能放密码这类敏感信息。Cookie 擅长的是保存一些非敏感型的"用户偏好",比如记住用户名、主题色、上次浏览的频道。
Cookie 和 Session 配合使用时要注意路径问题。写 Cookie 时可以指定setPath("/"),表示应用目录下所有路径都携带这个 Cookie;如果不设,默认是“写 Cookie 的那个路径”,很容易导致其他路径下 Cookie 不生效,找不到 Session。踩过一次坑之后我长记性了:凡是交给浏览器保存的会话标识,一律显式设置 Path。
另外浏览器对 SameSite 属性的处理也会影响 Cookie 的携带。前端跨域调用接口时,如果后端返回的 Cookie 没有配置好SameSite=None; Secure,浏览器可能完全不带这个 Cookie,跨域登录状态就丢了。这个在新老浏览器上表现还不一样,属于特别折磨人的问题。
5.4 分布式场景的 Session 问题
单机版 Session 很好用,但部署到多台服务器之后,就会出现“用户在 A 机器登录了,下一个请求被负载均衡转发到 B 机器,B 机器没有这个用户会话”。
解决思路通常有几种:一是做 Session 粘滞,让同一用户的请求始终打到固定节点,但节点挂了会话就会丢;二是用 Session 复制同步到各个节点,简单但浪费带宽;三是把 Session 数据抽到独立存储(Redis),这就是目前最主流的方案。理解了 Servlet 原生的 Session 机制,再看 Spring Session 之类的组件,就会明白它们做的事情正是把HttpSession的实现替换成“从 Redis 读写”,在使用层面感受不到差异。
6. 常见问题与排查技巧实录
6.1 404 和 500 是怎么来的
页面突然 404,第一反应检查三件事:应用是否成功部署、访问的 URL 地址是否正确、Servlet 的映射路径是否匹配。很多 404 是因为访问路径里少了项目名。如果你把应用发布到 Tomcat,访问地址应该是http://localhost:8080/项目名/映射路径,不是直接http://localhost:8080/映射路径。
500 错误通常是服务端抛了异常。看 Tomcat 的logs目录下localhost.YYYY-MM-DD.log文件,一般能直接看到异常的堆栈信息。这里分享一个技巧:能用全局异常兜底就别让异常裸奔。定义一套统一处理的响应结构,防止用户看到一大串堆栈信息。
6.2 编码问题的快速定位
中文乱码先判断是响应输出乱码还是请求参数乱码。响应乱码查resp.setContentType,请求乱码查req.setCharacterEncoding的位置和容器配置。另外,用浏览器开发者工具看“网络”面板里的响应头,里面有没有包含charset=UTF-8,一眼就能定位到响应端。
如果是输出到控制台乱码,这多数是 IDE 的控制台编码和系统不一致,和代码没有关系,把控制台编码切到 UTF-8 即可。
6.3 线程安全隐患排查
高并发下 Servlet 出现诡异问题,优先怀疑成员变量的并发修改。
举个例子,如果在一个 Servlet 里写了:
private SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");SimpleDateFormat不是线程安全的,多线程同时调用format()可能出现错乱甚至抛异常。解决办法:每次用的时候 new 一个,或者用ThreadLocal包一层,或者改用 Java 8 的DateTimeFormatter(它是线程安全的)。
再比如用成员变量private List<String> cache = new ArrayList<>();来临时存请求数据,在多线程环境下操作这个 List,轻则数据错乱,重则抛并发修改异常。经验是 Servlet 里尽量不做持久状态存储,实在要缓存请转交给专门的缓存服务。
6.4 多次初始化与 ClassNotFoundException
启动时报ClassNotFoundException通常有三种原因:依赖没引入、类路径不对、多个 Web 应用之间 jar 包共享冲突。排查顺序是:先看WEB-INF/lib是否打了对应的 jar,再检查 class 文件编译输出目录,最后看是不是把 Servlet API 的 jar 也放了进去(应该用 provided 作用域排除掉)。
实际调试中,我习惯先确认target目录里的 class 文件存在,再启动 Tomcat,这样能避免“本地编译通过,但部署包没打全”的尴尬。
6.5 重启后 Session 丢?正常
Session 默认存在内存里,容器一重启内存就清空了,所有在线用户都得重新登录。这是原生 Session 的固有特性。如果业务上不能接受,就引入集中式会话存储。反过来说,如果你在开发阶段发现每个请求都会新建 Session,很大概率是 Cookie 没生效,检查一下浏览器是否禁用了 Cookie、Cookie 的 Path 设置是否正确、HttpSession是否真的被创建过了。
7. Filter、Listener 和异步处理,也想顺便聊一聊
7.1 Filter 是拦截器前身
Filter(过滤器)是 Servlet 规范里另一个实用组件,它的作用是在请求到达 Servlet 之前、响应返回客户端之前,对请求和响应做统一处理。最常见的用途就是设置统一编码:
@WebFilter("/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding("UTF-8"); resp.setCharacterEncoding("UTF-8"); chain.doFilter(req, resp); } }Filter 的执行链是多个过滤器串起来,类似生产线上多个工人的协作方式。登录权限校验、操作日志记录、接口耗时统计都是 Filter 的经典应用场景。你会发现后来的拦截器(Interceptor)就是借鉴了 Filter 思路,只不过拦截器能拿到控制器方法级别的信息,功能更丰富。
7.2 Listener 监听容器事件
Listener用来监听 Web 应用中的关键事件,有监听上下文初始化的ServletContextListener,监听 Session 创建的HttpSessionListener,监听请求进入的ServletRequestListener。比较常用的是ServletContextListener,前面讲编程式注册 Servlet 时就用到了它。用在上下文初始化阶段去加载配置中心、初始化数据源,比在每个 Servlet 里重复写初始化代码干净得多。
7.3 异步 Servlet 处理长请求
Servlet 3.0 引入的AsyncContext能让请求处理不占用容器工作线程。简单理解:过去一个耗时 10 秒的业务接口,容器会一直占着这条线程到任务结束;用异步处理后,控制器先把任务丢给业务线程池,立即释放工作线程,等任务完成了再把结果返回。这个机制在长轮询、大文件导出等场景下能明显减少线程占用,但代价是代码复杂度上升,异步要保证最终一定能响应,否则客户端会卡到超时。建议普通项目先不用,但对流量大、耗时长接口多的系统非常值得考虑。
最后说说我的体会
做 Java Web 开发这几年,我最大的感受是:Servlet 不仅是入门必修课,更是定位疑难问题的抓手。很多 Spring Boot 里看不见摸不着的“黑魔法”,比如请求参数是怎么绑定的、DispatcherServlet 是怎么找到处理器方法的,归根结底都是对 Servlet 规范的扩展。你越理解底层,用框架就越不容易翻车。
学习 Servlet 最好的方式是写一个没有框架的小项目,注册、登录、列表、详情,完整走一遍。我当初完整做完这个项目后,再回头看 Spring MVC,很多概念瞬间通了,因为 Spring MVC 的每个核心组件都能映射到 Servlet 的对应角色上。这里给大家留个小建议:去把DispatcherServlet这个类翻出来读一读,对照本文说的 lifecycle 和请求分发逻辑,你会真正体会到什么叫“原来源码是这么玩儿的”。
另外提醒一句,技术栈可以迭代,但扎实的基础不能丢。面试也好、维护老系统也罢,Servlet 的知识始终是那根“定海神针”。把这篇文章里的每个示例自己敲一遍,你会收获比我预期更多的理解。