最近在帮一个转行的朋友补Java Web基础,他上来就问我:Servlet这东西还有必要学吗?Spring Boot一把梭不就行了?我想这也是很多同学的共同困惑。不夸张地说,在现在的技术社区里,Servlet已经被"框架原教旨主义者"和"Spring Boot一键挡"两头夹击,搞得好像是个上古遗物。但实际上,Servlet依然是整个Java Web生态的地基。不用Servlet,你连一句"Hello World"都发不到浏览器上——Spring MVC的前身就是Servlet,Spring Boot的内嵌容器里跑的也是Servlet规范。搞清楚它,再回头看框架源码、排查线上问题,你会觉得逻辑链瞬间通透了。
这篇博文我把它当作一份"从会用到理解"的Servlet实战笔记来写,围绕Servlet的生命周期、请求链路、会话管理、文件上传、并发陷阱这几个核心主题展开,既有原理拆解,也有可以直接复制运行的示例代码和踩坑经验。适合正在学习Java Web、被框架包裹但始终觉得不够扎实的同学,也适合准备梳理底层原理的开发者。我会尽量说人话,把"为什么"讲清楚,把"怎么做"写明白。
1. 先弄清楚Servlet到底在Web开发里扮演什么角色
很多人学Servlet第一课就是"Servlet是运行在服务器端的小程序",这句话没错,但太抽象了。我更愿意把Servlet理解为"一个专门处理HTTP请求和响应的Java类"。你写了这个类,把它交给Servlet容器(比如Tomcat),容器负责接收网络上来的HTTP请求,然后调用你写的方法来处理,再把处理结果写回给客户端。
简单画一条完整的请求链路,你就能明白Servlet在整个Web后端的位置:
浏览器发起HTTP请求 → 经过网络抵达Tomcat的Connector(端口监听) → Engine → Host → Context(对应一个Web应用) → 找到匹配的Servlet(按URL映射规则) → 调用Servlet的service()方法(内部再分发到doGet/doPost) → Servlet把结果写入response对象 → Tomcat把响应返回给浏览器在这条链路里面,从"Tomcat收到请求"到"找到Servlet"之间的部分,属于容器内部机制,Servlet本身不用管。但从"匹配到Servlet的service方法"开始,剩下的所有业务逻辑,都是你在这个类里写的。也就是说,Servlet就是你写的所有业务代码的入口。
1.1 Servlet和Spring MVC的关系,不要搞反了
既然Spring Boot用得多,我就先用Spring MVC来做个对照。Spring MVC的核心入口是一个配置了映射关系的类,叫做DispatcherServlet(结合特定实现来说)。它本身就是一个Servlet,只不过职责从"处理某一个具体请求"扩展成了"统管所有请求的中枢"。它拿到请求之后,再分发给各个Controller方法去处理。
所以你会发现,你在Spring MVC里面学的@RequestMapping、参数绑定、@ResponseBody这套东西,本质上都是在DispatcherServlet这个大Servlet的调度下完成的。框架帮你做了大量封装,但底层机制的根,还是Servlet规范。这也是为什么面试官特别喜欢问"Spring MVC和Servlet的关系"——因为只要搞懂Servlet,框架的入门就通了一大半。
1.2 自己实现Servlet和用框架实现的体验差异
自己写Servlet的感觉,就像开手动挡汽车,每一步都自己踩离合换挡:
- 直接在doGet/doPost方法里手写参数解析、对象封装、JSON序列化
- 手动设置响应的ContentType、编码、状态码
- 手动做参数校验、异常处理
- 手动控制转发的路径
用框架的感觉则像开自动挡,你只需要关心"从哪来到哪去",中间流程框架帮忙处理了。问题在于,如果完全不知道手动挡的原理,自动挡出了故障,你是完全蒙圈的。线上遇到404、405、请求中文乱码、Session取不到值这类问题,不懂Servlet,排查方向都找不到。
2. 生命周期:一个Servlet从创建到销毁的整个过程
Servlet生命周期是所有Servlet知识里面最基础也最关键的一部分。核心就三个方法:init()、service()、destroy(),加上一个由容器管理的实例化过程。先记一个结论:Servlet是单实例多线程的,不是每个请求创建一个新Servlet对象。
2.1 实例化与init阶段
当一个Servlet第一次被请求命中时(也可以是容器启动时通过配置提前加载),容器会通过反射创建这个Servlet的实例,然后立即调用init()方法完成初始化。
这个阶段的要点有两个:
init()只调用一次。所有后续请求都复用同一个实例。所以如果有需要加载的配置、需要初始化的连接池、需要预热的缓存,放在init()里是最合适的。
init()里面可以拿到ServletConfig对象。这个对象里封装了当前Servlet在web.xml或注解里配置的初始化参数。比如数据库连接串、上传文件大小限制等。
我自己踩过的一个坑,是把数据库连接放在构造函数里创建。当时以为构造函数肯定先执行,结果连接池初始化逻辑里依赖了ServletConfig中的参数,构造函数阶段ServletConfig还没注入,读到的全是null,服务差点起不来。后来老老实实把资源初始化代码放到init()里,问题解决。
2.2 service阶段:请求与响应的分派逻辑
每次请求进来,容器都会调用Servlet的service()方法,并传入ServletRequest和ServletResponse的实例。HttpServlet在父类中已经重写了service方法,它做的事情非常简单:根据HTTP请求的方法类型(GET、POST、PUT、DELETE等),把请求分派到对应的doGet()、doPost()、doPut()、doDelete()等方法上。
这里有一个常被忽略的细节:如果你在子类中重写了service()方法,那么框架默认的"按方法分派"逻辑就失效了。我自己在一次封装公共逻辑时,想当然地重写了service(),结果所有请求不论什么方法都走同一个分支,POST请求里的参数完全读不到。因为我把对super.service()的调用漏掉了。
日常开发中,默认几乎都只用doGet和doPost两个方法,其余方法很少单独重写。但要注意,如果提交方式不对(比如表单写了POST、Servlet只有doGet),浏览器会收到405错误,这个状态码提示的就是"方法不允许"。
2.3 destroy阶段和容器关闭的关联
当Web应用被卸载或者容器关闭时,容器会调用destroy()方法。这个方法用来释放init()阶段创建的资源:关闭连接池、停止后台调度线程、清空缓存。同样地,destroy()只调用一次,调用完之后这个Servlet实例就不再接收任何请求了。
有一个常见误解:有人觉得把资源写在finalize()里也能达到同样效果。完全不是。finalize()的调用时机不受控制,且该机制本身在新版本JDK中已经逐步废弃了。资源释放就老老实实用destroy()。
下表整理了生命周期三个阶段的核心信息:
| 阶段 | 调用次数 | 典型用途 | 易踩坑点 |
|---|---|---|---|
| init | 1次 | 初始化配置、连接池、加载缓存 | 依赖ServletConfig时注意调用时机 |
| service | 每次请求 | 处理业务逻辑、分发请求 | 重写service时别丢失分发逻辑 |
| destroy | 1次 | 释放资源、停止线程 | 别依赖finalize,就用destroy |
2.4 加载时机与load-on-startup配置
默认情况下,Servlet是懒加载的。也就是说,容器在启动时并不会实例化它,而是等第一个请求到达时才创建。对于大型系统来说,第一个请求往往要承担"初始化耗时",体验很差。这时可以通过load-on-startup参数来调整加载时机:
<servlet> <servlet-name>userServlet</servlet-name> <servlet-class>com.example.UserServlet</servlet-class> <load-on-startup>1</load-on-startup> </servlet>数字的含义是启动顺序。如果有多个Servlet都配置了该参数,容器会按数值从小到大依次初始化。配置之后,容器启动时就会完成Servlet实例化和init()调用。对于配置了连接池等重资源的Servlet来说,这个设置能显著提升第一个请求的响应速度。
3. 环境搭建与第一个Servlet的完整实现流程
光说不练假把式。这一节我会完整演示一个Servlet项目的创建、编码、部署和调试过程。使用最基础的工具组合:IDEA社区版 + Tomcat 9 + Servlet API 3.1规范(支持注解配置,省去繁琐的xml配置)。
3.1 项目结构和基础依赖
Servlet依赖本身不在JDK中,它是独立的一套规范API,由容器(Tomcat)来提供实现。所以编译时我们需要引入Servlet API依赖,但打包部署时这个依赖不需要打进包里,因为Tomcat里面已经有了。
使用Maven构建项目,依赖配置如下:
<dependencies> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>3.1.0</version> <scope>provided</scope> </dependency> </dependencies>scope设为provided是关键。如果你误设为compile,打包时Servlet API会被打进WEB-INF/lib目录,部署到Tomcat后可能出现类冲突,引发各种诡异问题。
项目推荐结构如下:
servlet-demo/ ├── pom.xml └── src └── main ├── java │ └── com/example/servlet/ │ ├── UserServlet.java │ └── HelloServlet.java └── webapp └── WEB-INF └── web.xml对于纯Servlet项目,web.xml可以非常精简,甚至大部分情况下可以省略(全部靠注解配置)。但是如果需要配置欢迎页面、全局错误页,或者指定Servlet初始化参数,web.xml还是要备着。
3.2 使用@WebServlet注解实现第一个Servlet
从Servlet 3.0开始,支持使用注解来声明Servlet,这比在web.xml里逐个配置方便太多。先写一个最简单的示例:
package com.example.servlet; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @WebServlet(name = "helloServlet", urlPatterns = "/hello") public class HelloServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType("text/plain;charset=UTF-8"); resp.getWriter().write("Hello, Servlet!"); } }把项目部署到Tomcat并启动后,浏览器访问:
http://localhost:8080/servlet-demo/hello就能看到返回的文本。这里面有两个容易忽略的细节,我先直接说明:
urlPatterns的写法。必须以
/开头,否则容器会报启动异常。想要一个Servlet响应多个URL,可以写成{"/hello", "/hello/*"},但要注意通配符的匹配优先级规则。resp.setContentType要写在获取Writer之前。如果先getWriter()再setContentType,响应头已经提交,设置不生效,容易导致中文乱码。这个顺序问题我在后面编码部分还会展开。
3.3 web.xml方式与注解方式的对比
虽然注解方便,但在团队项目里,全注解配置有一个问题:Servlet的URL映射散落在各个类文件上,要梳理接口路由时,得翻遍整个代码库。web.xml方式则是把所有映射集中在文件里,一目了然。
这两种方式是可以混合使用的。我自己通常的做法是:核心网关类Servlet用web.xml声明,便于运维同事排查路由;业务逻辑的Servlet直接用注解,开发效率更高。
一个典型的web.xml配置示例:
<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_3_1.xsd" version="3.1"> <servlet> <servlet-name>userServlet</servlet-name> <servlet-class>com.example.servlet.UserServlet</servlet-class> <init-param> <param-name>pageSize</param-name> <param-value>20</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>userServlet</servlet-name> <url-pattern>/user</url-pattern> </servlet-mapping> </web-app>两种方式对应的初始化参数读取逻辑其实是一个思路,都通过getInitParameter("pageSize")来获取,区别仅仅是配置从哪来。
3.4 运行时的核心请求与响应对象
在Servlet的doGet/doPost方法中,我们会频繁与两个对象打交道:
- HttpServletRequest:代表客户端发来的请求封装。可以从里面取参数、取请求头、取Cookie、取Session、读取请求体内容。
- HttpServletResponse:代表服务器将要发回的响应封装。可以向里面写入响应体、设置响应头、设置状态码、添加Cookie、重定向。
我常把这个过程比作"收快递"与"寄快递":request是拆开快递盒,看看买家寄来了什么;response是打包回寄物品,设置包裹单上的信息,再把内容物塞进去。理解了这层关系,很多API的归属就不会记混了。
4. 请求处理核心:参数获取、编码问题与文件上传
Servlet开发中最折磨人的往往不是业务逻辑,而是"请求参数怎么拿不到"、"为什么中文又双叒叕乱码"这种基础问题。这一节我把这些高频痛点集中梳理一遍。
4.1 GET与POST参数获取及乱码根因分析
获取参数的API很简单:
String name = req.getParameter("name"); String[] hobbies = req.getParameterValues("hobby"); // 同名多值,比如多选框 Map<String, String[]> paramMap = req.getParameterMap(); // 全量参数真正容易出问题的是编码。HTTP请求中的参数编码由请求方的Content-Type头决定(POST提交的form表单会带上charset),而GET请求的参数是拼在URL里的,编码由容器的URIEncoding决定。
中文乱码的根因,是"请求方的字节编码"和"服务器解析时使用的解码编码"不一致。举个最典型的例子:
- 浏览器在发送POST请求时,HTML页面是UTF-8编码,表单数据提交时也按UTF-8编码字节。
- Tomcat默认按ISO-8859-1来解码request中的body参数(老版本Tomcat的行为),编码不一致,出现乱码。
对于POST请求,解决办法是在读取任何参数之前,设置请求体的解码字符集:
req.setCharacterEncoding("UTF-8");这句话必须在第一次调用getParameter()之前执行,否则参数已经被容器解析过一遍,再设置就不生效了。
对于GET请求的乱码,setCharacterEncoding是不管用的,因为GET参数不在body里而在URI中。需要修改Tomcat的Connector配置:
<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" URIEncoding="UTF-8" />这个问题在新版本Tomcat中默认就是UTF-8,但在老版本或自定义过配置的服务器上,经常会踩坑。排查思路很简单:先确认HTML页面编码、Servlet读取编码、容器URI编码三者一致。
4.2 请求转发与重定向的区别和选择
这是一个必考且必踩坑的点。请求转发(forward)和重定向(sendRedirect)都能实现页面跳转,但内部机制完全不同。
转发是服务端内部行为。浏览器只发了一次请求,Servlet将请求传递给另一个Servlet或JSP去处理,浏览器地址栏的URL不会变化。整个过程共享同一个request和response对象。
// 转发到另一个Servlet或JSP req.getRequestDispatcher("/userInfo.jsp").forward(req, resp);重定向是客户端行为。Servlet告诉浏览器"你再去请求另一个地址",浏览器收到响应后,重新发起第二次请求。地址栏会改变,第一次请求中的request对象在第二次请求中当然也就不存在了。
// 重定向到另一个地址 resp.sendRedirect("/servlet-demo/login");分享一个我踩过的坑:在写登录功能时,用户登录成功后需要跳转到主页。我一开始用了转发,结果用户手动刷新浏览器时,把登录请求又提交了一遍,产生重复提交。改成重定向之后,浏览器地址变成主页地址,刷新不再触发登录提交。这就是业界常说的"POST/Redirect/GET"模式,属于转发和重定向的经典应用场景。
两者对request域属性的影响也不一样。转发模式下,setAttribute的数据在目标组件中可以直接读取;重定向模式下,request属性全部丢失,只能通过URL参数、Cookie或者Session来传递数据。
4.3 文件上传:Part接口与大小限制配置
Servlet 3.0之后,文件上传不再需要手动解析multipart/form-data格式的请求体,直接用Part接口就可以了。但前提是Servlet类上要加一个注解标记:
@WebServlet(name = "uploadServlet", urlPatterns = "/upload") @MultipartConfig( maxFileSize = 5 * 1024 * 1024, // 单个文件最大5MB maxRequestSize = 20 * 1024 * 1024, // 整个请求最大20MB fileSizeThreshold = 1024 * 1024 // 超过1MB写入临时文件 ) public class UploadServlet extends HttpServlet {上传处理代码:
Part filePart = req.getPart("file"); // "file"对应表单里file input的name属性 String submittedFileName = filePart.getSubmittedFileName(); String contentType = filePart.getContentType(); // 保存文件:注意处理好路径安全问题 String uploadPath = getServletContext().getRealPath("/uploads"); File uploadDir = new File(uploadPath); if (!uploadDir.exists()) { uploadDir.mkdirs(); } filePart.write(uploadPath + File.separator + System.currentTimeMillis() + "_" + submittedFileName);关于文件上传,至少有四个要点值得记下来:
表单必须用POST且设置enctype="multipart/form-data"。如果漏了enctype属性,后台用req.getPart会直接抛异常。
不要信任客户端传来的文件名。getSubmittedFileName()拿到的名字,用户完全可以伪造,甚至可能携带路径信息。安全做法是后端重新生成文件名(比如UUID或时间戳加随机数)。
不要存到web应用目录之外。如果文件数量多,全部存入webapp/uploads下,重新部署时会被清空。生产环境应配置一个独立的文件存储目录,然后通过映射或资源处理器来访问。
大小限制配置往往只在开发环境容易测到。大文件上传报错时的提示比较隐晦,建议在全局错误页里统一捕获并提示。
4.4 使用统一的编码处理Filter
上面讲的编码问题,如果每个Servlet都手动写一遍setCharacterEncoding,代码重复度太高。更好的方案是写一个Filter统一处理:
@WebFilter(urlPatterns = "/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding("UTF-8"); response.setCharacterEncoding("UTF-8"); chain.doFilter(request, response); } }Filter的执行时机早于Servlet的service方法,因此在这里设置编码,可以保证后续所有Servlet读取参数时都使用UTF-8解码。这个Filter顺序很重要,尤其当项目里同时存在多个Filter时,要确保编码Filter在第一个位置(按Order排序)。
5. 会话管理:Cookie与Session的底层协作机制
HTTP协议本身是无状态的,服务器不记得上一次请求是谁发的。但是业务要求我们是"有记忆"的——购物车不能换个页面就清空,登录状态不能刷新就没。Servlet规范给出的标准方案就是Session,而Session的载体往往就是Cookie。
5.1 Session的工作原理和默认超时时间
Session的核心机制可以一句话概括:服务器为每个会话创建一个唯一ID(JSESSIONID),在响应时通过Set-Cookie下发给浏览器;浏览器后续每次请求都会自动携带这个ID,服务器通过ID找到对应的会话数据。
第一次请求时,服务端通过req.getSession(true)(或直接getSession())创建Session:
HttpSession session = req.getSession(); session.setAttribute("userId", "10001"); String sessionId = session.getId();后续请求中,再次调用getSession()即可取到同一个对象,前提是请求中携带了正确的JSESSIONID。
Session数据默认存放在服务器内存中。默认超时时间是30分钟,超过时间没有活动,容器会自动销毁这个Session。超时时间可以通过web.xml配置:
<session-config> <session-timeout>60</session-timeout> </session-config>这里的单位是分钟。如果业务上对"登录保持时长"有特殊要求(比如7天自动登录),分布式Session的TTL、Redis中的过期时间、Cookie的过期时间,都要联动考虑,不能只改一处。
5.2 Cookie的属性和HttpOnly带来的现实价值
Cookie就是一小段由服务器发给浏览器、浏览器保存并在后续请求中带回的数据。Session依赖的这个Cookie,属于"会话Cookie"——它还有一个特殊属性叫HttpOnly。
HttpOnly属性的作用是:阻止JavaScript通过document.cookie访问这个Cookie值。这一条能有效防御XSS(跨站脚本攻击)带来的Session劫持风险。如果没有HttpOnly,攻击者一旦注入脚本,就能直接偷走用户的SessionID,登录态就彻底暴露了。
在Servlet中设置Cookie:
Cookie cookie = new Cookie("username", "admin"); cookie.setMaxAge(7 * 24 * 60 * 60); // 7天有效,单位是秒 cookie.setHttpOnly(true); // 禁止JS读取 cookie.setPath("/"); // 全站有效 resp.addCookie(cookie);读取Cookie:
Cookie[] cookies = req.getCookies(); if (cookies != null) { for (Cookie cookie : cookies) { if ("username".equals(cookie.getName())) { String value = cookie.getValue(); } } }Cookie的value存储在客户端,千万别放敏感信息(密码、身份证、Token)。即使做了HttpOnly,也只是防XSS跨越,防不了物理层面的抓包查看。
5.3 分布式集群环境下Session不共享的应对思路
当系统从单机演进到多机部署(负载均衡),Session问题就出现了:用户第一次请求打到A机器,登录状态存在A机器的内存里;第二次请求如果被负载均衡转发到B机器,B机器上并没有这份Session,用户就"掉线"了。
业内处理方案比较成熟,从轻到重说几个:
Nginx的IP Hash策略:让同一个IP的请求固定转发到同一台机器,是一种临时性规避方案。但用户如果换网络(手机切Wi-Fi),IP变了,照样失联。
Session同步复制:每台机器把自己的Session变更同步给其他机器,维护成本高,机器多了效率下降明显。
中央化存储:把Session数据放到Redis中,所有机器共享读写。这也是目前微服务架构下的主流方案。实现方式可以自己做封装,也可以借助Spring Session框架来透明替换。
如果你正在用原生的HttpSession对象,在引入Spring之后想切换到"第三方集中存储方案",可以研究下Spring Session的源码路径:通过包装HttpServletRequest,拦截getSession()方法,从你自己定义的缓存中读数据。核心思路就是这么一步,理解了原生的HttpSession机制,框架的扩展逻辑就变得非常好懂。
5.4 无状态Token和Session的朴素对比
很多人会问:现在接口开发都用JWT(JSON Web Token)了,Session是不是就该退休了?这两种方案解决的问题确实高度重叠,但适用场景不同。
用Session的状态是存在服务器,通过ID关联;JWT的状态是存在客户端,服务器通过签名验签来还原。JWT天然适合无状态API服务、跨语言系统、移动端场景。但它也有短板:服务端无法主动让它失效,会话的续期和注销需要额外机制来弥补,一定要结合场景来做取舍。
在纯Servlet学习阶段,务必要把Session机制本身吃透。因为不管是JWT、OAuth2还是Spring Security,你都会发现它们是在"会话管理"这个概念上做文章,只是存储介质、校验方式变了。底层的请求一响应模型,并没有跳出Servlet这套范畴。
6. 并发与线程安全:为什么Servlet成员变量别乱用
Servlet是单实例多线程模型,这个特性决定了它运行时有一个天然隐患:成员变量不是线程安全的。每次请求都由一个新线程来执行同一个Servlet实例的service方法,多个线程可以同时读写这个实例的成员变量,此时若没有同步措施,数据就会出现互相覆盖、读到脏数据的现象。
6.1 从一段实际引发线上故障的代码说起
先给出一个反面示例,这种代码我在不少小项目里都见过:
@WebServlet("/counter") public class CounterServlet extends HttpServlet { private int count = 0; @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { count++; resp.getWriter().write("当前访问次数:" + count); } }表面上这段代码是想统计访问次数,但并发情况下,count++不是原子操作。它经历了"读取count值→计算加1→写回count值"三步,两个线程同时跑到这里,可能同时读到旧值,各自加1后写回,导致实际少计数了。如果这个计数影响业务判断(比如超过限额就停了),故障就出来了。
同样的并发风险也出现在:
- 把数据库连接(Connection对象)作为成员变量复用
- 把SimpleDateFormat作为成员变量复用(它内部有共享的 Calendar 属性,非线程安全)
- 把用户信息放成员变量里,结果A用户的请求拿到的属性值来自B用户的会话
6.2 正确的线程安全设计思路
既然知道Servlet实例会被多线程共享,那么设计原则就很明确:
尽量不要在Servlet类里定义可变的成员变量。如果一定要有,那就把它设计成线程安全的类型,比如AtomicInteger、ConcurrentHashMap,或者加锁来保护。
局部变量是线程安全的。doGet/doPost方法内部创建的变量,每个线程都有自己的栈帧,互不干扰,绝大多数业务数据直接用局部变量处理即可。
外部资源的访问,要确保资源本身是线程安全的。比如使用连接池获取连接(连接从池里取出用完归还),而不是共享单一连接;使用线程安全的日期格式化工具,比如基于ThreadLocal的SimpleDateFormat,或者直接用Java 8的DateTimeFormatter。
ServletContext对象是多应用共享的。它的setAttribute方法写入的数据,相当于全局变量,访问时同样要考虑到并发,通常只用于配置读取,避免写操作频繁。
这些原则不仅适用于Servlet,你在写Spring Boot项目时同样适用。框架解决了"对象由谁创建、生命周期怎么管理"的问题,但底层仍然逃不过"多个线程操作同一个对象"这个并发模型。
6.3 结合业务看一次真实的线上Session事故
说一起亲历的排查过程,能帮你更快建立"异常现场感"。当时某业务系统偶发用户串号,用户A明明登录的是自己的账号,刷新后却看到用户B的姓名和订单。事后定位,问题就出在正在将ServletRequest对象中取出的用户信息赋给Servlet的成员变量,这个用户信息对象被多个请求线程共享。
因为Servlet是单例,成员变量指向的对象只有一份,当这个对象内部有可写字段(比如"当前登录用户id")时,线程A刚设置完id=1001,线程B又把它改成1002,紧接着线程A渲染页面,读到的是1002的私有数据。定位思路并不复杂:先看是否所有请求都返回私有数据,再看代码里是否有请求级别的数据存在了非线程安全位置,最后在关键字段上打日志加上线程id,即可验证。
所以,我强烈建议你在每个Servlet里都保持一种严格的"字段洁癖":所有和用户相关的数据,全部在方法内部获取、传递;成员变量只保留不可变常量(如static final的配置值)或者线程安全的共享缓存,形成这个习惯之后,90%的Servlet并发问题都能从源头避开。
7. 常用能力扩展:监听器、过滤器与Servlet协作
Servlet规范里除了Servlet本身,还有两个重要组件:Filter和Listener。它们三个协同工作,才组成了一个完整的Servlet应用骨架。太多新手只学Servlet,忽略了过滤器和监听器,导致写代码时走很多弯路。
7.1 Filter的定位与实际应用场景
Filter的中文名叫过滤器,它在请求到达Servlet之前拦截请求,在Servlet处理完响应后拦截响应。这个"双拦截"时机是Filter最强大的地方:
public class PerformanceFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { long start = System.currentTimeMillis(); chain.doFilter(request, response); long cost = System.currentTimeMillis() - start; System.out.println("请求耗时:" + cost + "ms"); } }常见Filter应用场景包括:
- 字符编码统一处理(前面已经提过)
- 登录权限校验(未登录用户拦截下来去登录页)
- 请求日志打印(记录IP、URL、耗时)
- 敏感词过滤与参数清洗
- 接口响应压缩、跨域设置
Filter的执行顺序与配置顺序有关,多个Filter形成责任链,依次调用chain.doFilter()向下传递。如果你需要保证某个Filter最先执行(比如编码),把它配置在最前面就行。
7.2 Listener的定位与实际应用场景
Listener是监听器,用于观察Web应用中的某个事件。Servlet规范提供了两类常见监听器:
ServletContextListener:监听Web应用初始化与销毁。初始化好之后,可以做"全局资源加载"——比如启动时就加载配置中心参数、初始化定时任务、初始化数据字典缓存。只要应用一启动,就自动触发contextInitialized方法。
HttpSessionListener:监听Session创建与销毁。常用来做在线人数统计、用户登录日志记录。当用户Session超时销毁时,Lisener可以在后台帮忙清理一些关联的临时数据。
我常用的一个模式:在ServletContextListener的contextInitialized中,把全局配置加载到ServletContext的Attribute里。这样Servlet、JSP任何地方想读配置,直接getServletContext().getAttribute("config"),一次加载全局共享,避免每次请求都读硬盘或数据库。
7.3 Filter与Interceptor的区别
国内框架用户更多接触的其实是Spring MVC的Interceptor(拦截器),很多人会把Filter和Interceptor混为一谈。它们协调使用:
- Filter是Servlet规范的一部分,作用于Servlet之前、之后。它对静态资源、Servlet、JSP都生效。
- Interceptor是Spring MVC的组件,作用于HandlerMapping找到对应的Controller方法之后、执行前后。它只拦截Controller方法的调用。
准确说,Interceptor发生在Filter之后、Servlet(DispatcherServlet)已经匹配到Controller但还没进入业务方法时。职责更贴近"方法级别"的控制。
从分层来看:
请求 → Filter链(Servlet规范层) → DispatcherServlet(Servlet) → Interceptor链(Spring MVC层) → Controller方法涉及跨域、编码、安全认证等"协议级"控制,用Filter;涉及Controller前后的参数校验、权限判断、日志记录,用Interceptor。两者各司其职,配合使用效果最好。
8. 常见状态码与开发调试经验总结
作为写Servlet的开发者,对HTTP状态码要有比较敏感的直觉。很多问题,看一眼状态码就能锁定问题范围。
8.1 高频状态码的语义和对应处理
| 状态码 | 含义 | 一般在Servlet开发中说明什么 |
|---|---|---|
| 200 | 请求成功 | 正常响应 |
| 302 | 临时重定向 | resp.sendRedirect触发的跳转 |
| 400 | 请求参数错误 | 参数格式、内容类型不合法 |
| 403 | 禁止访问 | 权限不足,Filter拦截后常出现 |
| 404 | 资源不存在 | URL映射配置错误、静态资源路径错误 |
| 405 | 方法不允许 | 表单提交方式和Servlet实现方法不匹配 |
| 500 | 服务器内部错误 | Servlet抛异常,没有捕获处理 |
| 503 | 服务不可用 | 容器过载或维护,池资源耗尽 |
遇到500错误,第一时间去Tomcat的catalina.out或应用日志里翻堆栈。如果日志打了但没有关键信息,多半是异常被吞掉了——检查代码里有没有catch之后不打印的空处理器。
8.2 一组实用的调试方法
Servlet项目调试时,有几个手段比盲目加打印更高效:
使用浏览器开发者工具的Network面板。重点看Request Headers(请求头)、Response Headers(响应头)、状态码、耗时。比如乱码问题,直接在Network里看Content-Type头里的charset有没有生效,比反复改代码试错快得多。
使用curl或Postman构造请求。不太建议直接用浏览器地址栏访问GET请求,因为浏览器会自动处理编码、缓存、Cookie,掩盖问题。用curl可以精确控制请求头和参数:
curl -X POST http://localhost:8080/servlet-demo/login \ -H "Content-Type: application/x-www-form-urlencoded; charset=UTF-8" \ -d "username=admin&password=123456" \ -v-v参数能打印完整的请求/响应头,排查编码、Cookie不生效的问题时特别有用。
- 在关键位置打印参数和Session信息。老派但有效:
System.out.println("paramName=" + req.getParameter("name")); System.out.println("sessionId=" + req.getSession().getId()); System.out.println("sessionAttr=" + req.getSession().getAttribute("userId"));记住一个原则:先确认请求到达了正确的Servlet(打印一个enter日志),再继续往下跟踪。很多时候问题根本不是Servlet内部逻辑,而是请求根本没到达这里——路由映射配错了。
8.3 一套标准的错误页面处理方案
在Servlet层统一捕获异常并返回友好提示,体验会好很多。web.xml中可以配置error-page:
<error-page> <error-code>404</error-code> <location>/error404.html</location> </error-page> <error-page> <error-code>500</error-code> <location>/error500.jsp</location> </error-page> <error-page> <exception-type>java.lang.RuntimeException</exception-type> <location>/error.jsp</location> </error-page>JSP页面中可以通过内置对象拿到异常信息:
<%@ page isErrorPage="true" %> <% exception.printStackTrace(); %>在前后端分离的开发模式下,一般也建议在Servlet或全局异常处理器里统一返回JSON错误结构,保证前端能一致解析。
8.4 我建议的Servlet学习路径
最后给想系统学Servlet的同学一个路径参考,避免东一榔头西一棒子:
- 先把生命周期、请求响应链路摸熟,别急着写复杂逻辑,先能解释清楚"一个URL从浏览器发出后,Tomcat和Servlet各自做了什么"。
- 写几个完整的小案例:注册登录(用到Session)、用户列表(用到参数获取与数据查询)、文件上传(用到Multipart)、文件下载(用到设置响应头)。
- 引入Filter和Listener,把编码、权限、日志、全局配置的代码抽出来,看整个应用怎么被"横切"组织。
- 把上面整理好的项目,从Tomcat手动部署改为与Spring Boot对比:同一个功能,原生的Servlet怎么写、Spring Boot怎么写、中间框架帮你代理了哪些步骤,这样能把技术栈串起来。
- 最后回头读一读Servlet规范文档的关键章节,尤其是请求转发、Cookie、Session、上传机制,遇到边界行为时再回查原规格说明,印象最深。
9. 大数据量分页查询中的Servlet交互设计
分页看起来再常见不过,但在Servlet里手工实现时,有几个细节比框架里容易踩坑。比如页码参数的类型转换、非法参数的处理、分页数据和总条数的查询顺序。
9.1 分页参数的获取与安全处理
页码参数是用户传入的,必须做边界校验。假设每页固定查询20条:
int pageNum = 1; String pageNumStr = req.getParameter("pageNum"); if (pageNumStr != null && pageNumStr.matches("\\d+")) { pageNum = Integer.parseInt(pageNumStr); } if (pageNum < 1) { pageNum = 1; }这里有一个隐蔽问题:如果直接把用户传的pageNum拼进SQL做分页偏移,可能被利用做批量数据的遍历或者参数注入。严谨做法是把pageNum、pageSize都做白名单校验,并限制pageSize的最大值(比如不超过100),避免恶意请求拉走全表数据。
9.2 List数据的写回与前端协作
在传统的Servlet + JSP模式下,分页查询结果通过request域传递:
List<User> userList = userDao.findPage(pageNum, pageSize); int totalCount = userDao.count(); req.setAttribute("userList", userList); req.setAttribute("pageNum", pageNum); req.setAttribute("totalCount", totalCount); int totalPages = (int) Math.ceil(totalCount * 1.0 / pageSize); req.setAttribute("totalPages", totalPages); req.getRequestDispatcher("/userList.jsp").forward(req, resp);在前后端分离模式下,则直接用response写出JSON:
resp.setContentType("application/json;charset=UTF-8"); Map<String, Object> result = new HashMap<>(); result.put("list", userList); result.put("total", totalCount); result.put("pageNum", pageNum); resp.getWriter().write(new ObjectMapper().writeValueAsString(result));前一种要求JSP用JSTL标签来遍历数据,后一种要求前端解析JSON并渲染。这两种协作模式我建议都动手实现一次,这样你对"Servlet处理完数据后,数据到底交给了谁"会有更加落地的感知。
9.3 分页性能层面的两点提醒
如果数据量很大,分页查询的常见性能杀手是"大偏移量查询":
SELECT * FROM user ORDER BY id LIMIT 20 OFFSET 200000;MySQL要扫描并丢弃前面200000条数据,耗时迅速增长。业界更优的做法是"延迟关联"或"记录上一次的ID游标"。在Servlet场景下不展开SQL优化,但如果你自己写了分页逻辑,建议至少给排序字段加上索引,并在数据量较大时考虑基于游标的分页方案。
结束语
分享一点我自己的体会。刚入门时,我一度觉得Servlet又繁琐又无聊,一个Hello World要陪Tomcat、web.xml、注解折腾半天。但随着写过的代码越来越多,回头再看Servlet,反而最喜欢它那种一切逻辑都摊在面前的坦诚感——请求怎么进来、参数怎么取、响应怎么回,每一步都在你眼前,没有魔法。
如果你现在正被各种框架包围,建议抽出哪怕一个下午,用纯Servlet写一个完整的小项目:登录、权限拦截、文件上传、分页列表。写完之后再回去用Spring Boot重写一遍,你会明显感觉到自己从"会调接口"升级成了"知道接口背后发生了什么",排查问题的思路也会清晰很多。