
写这篇文章的起因很简单最近又在帮一个刚转 Java 的朋友调 Web 项目他把 Spring Boot 玩得挺溜但一提到 Servlet 程序就发怵接口报错也只会重启。我陪他理了一遍 Servlet 的生命周期和底层处理流程之后他很多一直没想明白的问题当场就通了。这篇文章就把我平时带人梳理 Servlet 的那套思路整理出来从生命周期到手写实现再到那些框架不会替你背的坑一次性讲清楚。1. Servlet 程序到底是什么——先别急着写代码1.1 它在 Java Web 生态里的位置很多人一上来就学 Spring MVC、Spring Boot结果 Servlet 就被当成一个过时概念草草跳过。实际上Spring MVC 的 DispatcherServlet 本身就是一个 Servlet 程序Spring Boot 内嵌的 Tomcat 也是靠 Servlet 规范来接收和分发 HTTP 请求的。你天天用的那些注解、过滤器、拦截器底层全部绕不开 Servlet 这套模型。Servlet 是 Java EEJakarta EE规范里用于处理请求的核心组件。通俗地讲它就是一段运行在 Web 容器Tomcat、Jetty、Undertow 这类里的 Java 类专门负责接收 HTTP 请求、处理业务逻辑、生成 HTTP 响应。你可以把它理解为 Web 服务器和你的 Java 业务代码之间的“接线员”请求进来由它接处理完由它回整个过程高度标准化。Servlet 程序能解决什么问题最核心的就是把“接收请求”和“处理业务”这两件事从原始的 socket 编程里解放出来。没有 Servlet你需要自己用 ServerSocket 监听端口、解析 HTTP 报文、管理连接线程有 Servlet 之后容器帮你完成这些脏活你只需要写业务逻辑剩下的事情交给规范。1.2 学了 Spring Boot 还有必要啃 Servlet 吗有必要而且非常重要。我见过不少工作两三年的后端遇到诡异问题完全找不到方向就是因为不懂 Servlet 程序的工作机制。举个例子过滤器为什么能拦截请求因为 Filter 是 Servlet 规范里的组件容器在把请求交给目标 Servlet 之前会先过一遍过滤器链。拦截器为什么拿不到最终返回的响应体因为它本质上是基于 Spring MVC 的 HandlerInterceptor执行时机在 Servlet 处理之后、渲染之前压根不在一层。这些差别不懂 Servlet 生命周期背再多框架源码也没用。另外排查线上问题的时候Servlet 层面的知识能帮你快速定位“到底是谁把请求改掉了”。是容器是过滤器是监听器还是业务代码本身如果不清楚请求从 Tomcat 接收到进入 Servlet 之间路过哪些“关卡”排查起来就只能到处打印日志碰运气。1.3 一个 Servlet 程序的最小闭环先看一个最简单的 Servlet 程序长什么样import jakarta.servlet.ServletException; import jakarta.servlet.annotation.WebServlet; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; WebServlet(/hello) public class HelloServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType(text/html;charsetUTF-8); resp.getWriter().write(h1Hello, Servlet!/h1); } }这段代码构成了 Servlet 程序的最小闭环一个继承HttpServlet的类通过WebServlet(/hello)注册访问路径重写doGet生成响应。把它丢进 Tomcat 的 webapps 目录启动容器后访问http://localhost:8080/项目名/hello就能看到输出。就是这么简单。但简单背后藏着一整套运行机制容器什么时候创建这个 Servlet 实例init()方法什么时候调用每次请求是不是都会重新 new 一个对象这些问题的答案就是 Servlet 生命周期。2. Servlet 生命周期拆解——从加载到销毁2.1 五个关键节点一张图都给你说清Servlet 程序的生命周期可以拆成五个阶段加载与实例化、初始化、请求处理、服务中、销毁。我习惯把它类比成一家餐厅的运作加载与实例化餐厅开业前老板把厨师Servlet 类招进来分配工位实例化对象。初始化厨师上岗前进行培训、换上工服、熟悉菜单init()方法。请求处理顾客点菜厨师按流程做菜service()方法。服务中容器一直运行厨师持续接单。销毁餐厅要关门了厨师交接设备、清理卫生、离职destroy()方法。关键点在于一个 Servlet 类在容器中默认只有一个实例。也就是说所有并发请求共享同一个 Servlet 对象容器不会为每个请求都 new 一个新的 Servlet 实例。这是面试高频问点也是写代码经常踩坑的源头。ServletContextListener和HttpSessionListener这类监听器则属于生命周期外部的观察者它们能感知容器启动、会话创建等事件是扩展 Servlet 程序能力的重要手段后面实战部分会提到。2.2 init() 为什么只执行一次init()方法是 Servlet 生命周期的起点由容器在 Servlet 实例创建后调用。它只被调用一次适合做资源加载、数据库连接池初始化、读取配置文件等一次性操作。public class ConfigServlet extends HttpServlet { private Properties config; Override public void init() throws ServletException { // 只在第一次加载时执行一次 try (InputStream in getClass().getClassLoader() .getResourceAsStream(app.properties)) { config new Properties(); config.load(in); } catch (IOException e) { throw new ServletException(配置文件加载失败, e); } } }注意两个细节第一init()有重载。无参版本是init()还有一个init(ServletConfig config)容器调用的是后者默认实现里会把ServletConfig存起来然后调用无参版本。所以如果你要拿配置参数重写无参版本即可但需要操作ServletConfig时建议重写带参版本并记得super.init(config)。第二加载时机默认是首次请求时。也就是说容器启动后如果没人访问init()根本不会执行。如果想让 Servlet 在容器启动时就完成初始化可以在web.xml里配置load-on-startup1/load-on-startup或者在WebServlet注解里写loadOnStartup 1。数字越小优先级越高这个机制在需要提前预热的场景很实用。2.3 service() 与 doGet/doPost 的分发秘密service()方法才是真正接收每个请求的入口。HttpServlet基类里已经实现了service(HttpServletRequest, HttpServletResponse)方法的默认逻辑根据 HTTP 请求方法把请求分发给对应的doGet、doPost、doPut、doDelete等方法。所以你去重写service()的时候要非常小心。如果直接把业务写在service()里而不调用super.service()那么所有请求无论 GET 还是 POST 都会走你的方法同时doGet、doPost里的逻辑就完全不会执行。这不是特性是坑。Override protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 如果是自定义分发逻辑注意别丢掉基类的分发包 super.service(req, resp); }有个常见问题是为什么有时候同时重写了doGet和doPost但 POST 请求没走下去多半是因为service()被意外重写了或者请求被过滤器提前拦截返回。2.4 destroy() 与容器卸载destroy()是 Servlet 生命周期的最后一步容器在卸载 Servlet 时会调用它同样只执行一次。适合做资源释放、连接池关闭、线程池 shutdown 等收尾工作。真正需要留意的是destroy()的调用时机并不总是你预期的那样。Tomcat 平滑关闭时会调用它但如果直接 kill -9 强杀进程容器根本来不及执行任何销毁逻辑。所以不要用destroy()去做“必须保证写入磁盘”这种强一致性任务它只是给容器一个优雅收尾的机会。还有一个更隐蔽的问题Servlet 实例在被销毁时可能还有请求正在执行。Tomcat 会尽量等已接收的请求处理完但如果你在高并发下直接卸载依然可能碰到“请求进行到一半对象开始清理”的尴尬局面。容器的具体策略由版本和配置决定所以写代码时不要把关键共享状态的清理放在destroy()尽量放在容器外部管理。2.5 生命周期实测日志打印验证纸上谈兵没有用直接看日志最直观。写一个覆盖全生命周期的 Servlet打印每个阶段的线程名和时间点WebServlet(value /lifecycle, loadOnStartup 1) public class LifecycleServlet extends HttpServlet { public LifecycleServlet() { System.out.println([构造函数] 实例创建 Thread.currentThread().getName()); } Override public void init() throws ServletException { System.out.println([init] 初始化执行 Thread.currentThread().getName()); super.init(); } Override protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { System.out.println([service] 请求处理开始 Thread.currentThread().getName()); super.service(req, resp); System.out.println([service] 请求处理结束 Thread.currentThread().getName()); } Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType(text/plain;charsetUTF-8); resp.getWriter().write(lifecycle ok); } Override public void destroy() { System.out.println([destroy] 销毁执行 Thread.currentThread().getName()); super.destroy(); } }因为我配置了loadOnStartup 1所以容器一启动就能在控制台看到构造函数和init()的日志。连续请求多次后你会发现构造函数和init()只打印一次而service()每次请求都会打印。这里还有一个很有意思的细节同一个 Servlet 实例可能同时处理多个请求从线程名就能看出来。3. 手写第一个 Servlet 程序——从零到能跑3.1 环境准备与依赖我默认你用 Maven 管理项目这是现在最主流的方式。创建一个普通的 Java Web 工程pom.xml里引入 Servlet APIdependency groupIdjakarta.servlet/groupId artifactIdjakarta.servlet-api/artifactId version6.0.0/version scopeprovided/scope /dependency注意scope是provided因为 Servlet API 是 Tomcat 这类容器自带的打包进 WAR 反而可能引起类冲突。如果你用的是老版本 Tomcat 9 及以下依赖坐标应该是javax.servlet:javax.servlet-api:4.0.1包名也相应是javax.servlet。这个细节我见过不少人在切换 JDK 版本之后踩坑明明代码没问题就是编译不过一查发现 jakarta 和 javax 混了。JDK 建议用 8 以上Tomcat 版本跟着 Servlet 规范走Tomcat 9 对应 Servlet 4.0Tomcat 10 对应 Servlet 5.0Tomcat 10.1 对应 Servlet 6.0。版本不匹配轻则功能异常重则直接启动报错。3.2 两种注册方式注解和 web.xmlServlet 3.0 之后支持WebServlet注解写起来干净利落WebServlet( name userServlet, urlPatterns {/user, /user/*}, loadOnStartup 1, initParams { WebInitParam(name pageSize, value 20) } ) public class UserServlet extends HttpServlet { // ... }urlPatterns支持精确匹配/user、路径匹配/user/*和扩展名匹配*.do匹配优先级从精确匹配到最长路径匹配再到扩展名匹配。这个规则很容易被忽略我之前遇到一个接口突然不走预期 Servlet 的问题排查半天发现是另一个 Servlet 的urlPatterns写成了/user/*把精确路径给拦截了。传统项目或者需要动态配置的场合仍然用web.xmlservlet servlet-nameuserServlet/servlet-name servlet-classcom.example.servlet.UserServlet/servlet-class init-param param-namepageSize/param-name param-value20/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-nameuserServlet/servlet-name url-pattern/user/url-pattern /servlet-mapping注解和web.xml同时存在时同名映射会导致容器在启动时直接报错。所以项目里最好明确一个约定要么全注解要么全 XML别混着来。Spring Boot 内嵌 Tomcat 场景下WebServlet需要配合ServletComponentScan才能被扫到否则你的 Servlet 注册了但根本没生效很多人初始化项目时会在这个地方卡住。3.3 部署与启动打包成 WAR 丢进 Tomcat 的 webapps 目录是最原始的方式。启动 Tomcat 后它会自动解压 WAR 文件然后根据部署描述符和注解完成上下文初始化。用 Maven 打包mvn clean package然后把目标目录下的xxx.war复制到 Tomcat 的webapps/ROOT如果你希望直接通过根路径访问可以命名为 ROOT.war如果命名为 demo.war访问路径就是/demo。启动 Tomcat# Linux/Mac ./bin/startup.sh # Windows bin\startup.bat访问http://localhost:8080/demo/hello就能看到效果。不过我强烈建议开发阶段别这么折腾用 IDE 的集成部署能力比如 IntelliJ IDEA 里配置 Tomcat Server Artifact改完代码热部署效率高非常多。生产环境再考虑 WAR 包或 Docker 镜像。3.4 从零手写时最常见的报错新手第一次跑 Servlet 程序基本逃不过这几个错报错信息原因解决方法404 Not Found访问路径不对或 Servlet 没注册成功检查WebServlet的路径和部署上下文核对大小写500空指针request.getParameter()返回 null 后直接调用方法加判空或用默认值兜底ClassNotFoundException: javax.servlet.*依赖包没引入或 scope 错误确认provided作用域确认 Tomcat 版本对应的包名Failed to start component [StandardEngine]web.xml里有重复的 servlet 映射排查web.xml和注解是否重复注册访问后浏览器下载文件响应没有设置Content-Type容器当作二进制输出设置resp.setContentType(text/html;charsetUTF-8)这些坑的本质其实都指向一个核心认知Servlet 程序的运行依赖容器的协作。路径解析、类加载、生命周期回调都是由容器掌控的你的代码只是挂在它上面的处理器。4. Servlet 程序的高频实战细节——框架不会替你背的坑4.1 请求参数处理与中文乱码Servlet 程序接收参数很简单String name request.getParameter(name); String[] hobbies request.getParameterValues(hobby);但中文乱码这个问题藏得比你想的深。乱码的根子在于编码不一致浏览器发送数据的编码、容器解析请求的编码、响应时写出的编码三个环节只要一个不一致就会乱。POST 请求的直接解决方法是request.setCharacterEncoding(UTF-8); // 必须在读取参数之前调用 response.setContentType(text/html;charsetUTF-8);GET 请求的情况更麻烦一点。GET 参数的编码方式由 Tomcat 默认使用 ISO-8859-1 解析 URL 中的请求行所以光靠setCharacterEncoding没用得去 Tomcat 的server.xml里给 Connector 配置URIEncodingUTF-8。说实话这个老 CVE 已经折腾过好多代开发者了。虽然高版本 Tomcat 里URIEncoding默认已经是 UTF-8但老项目的坑依然存在遇到 GET 乱码先查这个配置。在容器层面设置了request.setCharacterEncoding(UTF-8)之后实体内容会被正确解码但如果你已经调用了getParameter()方法就来不及生效了。所以这一行代码必须在任何参数读取之前执行。最好的做法是写一个 Filter在过滤链最开始统一设置编码。4.2 转发与重定向的区别这两个动作长得像本质天差地别。RequestDispatcher.forward()是服务器内部跳转整个请求生命周期内 URL 不变且 Servlet 之间共享同一个request和response。response.sendRedirect()则是服务器返回一个 302告诉浏览器去请求一个新地址浏览器地址栏会变化。我用一个点单场景帮你记转发是你在前台点餐后后厨内部把你的订单转给另一个厨师你在外面看不到内部流转重定向是前台告诉你“隔壁店才有这道菜”你自己走过去重新点。从 Servlet 程序的角度转发可以携带请求属性到另一个 Servletrequest.setAttribute(user, userInfo); request.getRequestDispatcher(/user/detail).forward(request, response);重定向则往往用于 POST/Redirect/GET 模式避免用户刷新时重复提交表单response.sendRedirect(/demo/user/list);这里有一个很多人踩过的坑forward 之后不能再去操作 response否则会抛IllegalStateException。因为 forward 内部已经 commit 响应了再试图写内容就是跟容器抢操作权。而 sendRedirect 同样要注意重定向之前如果已经写了内容跳转就不会生效。4.3 线程安全与单例陷阱前面提到 Servlet 程序默认是单例多线程的这意味着你的实例字段是所有请求共享的。看这个经典坑例public class UnsafeServlet extends HttpServlet { private int count 0; Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { count; resp.getWriter().write(count count); } }高并发下这个count会因为多线程同时操作而出现数据错乱。类似的情境还有把局部的业务数据存在 Servlet 字段里、把复用对象存在字段里然后被多请求同时修改。解决办法有几个层次尽量不写实例字段局部变量天然线程安全。必须共享的状态使用ConcurrentHashMap等线程安全容器。像数据库连接池这种重量级资源不在每个请求里创建而是在init()里初始化并对外只读访问。另外注意HttpSession的线程安全问题。同一个会话的请求在大多数容器实现里是串行处理的但跨会话依然并发。你在 Session 里存对象时最好只存不可变对象或者保证对象内部有同步机制。4.4 异步 Servlet——请求处理的新姿势Servlet 3.0 引入的异步处理是很多人忽略的能力。传统模型里Servlet 线程要一直占着直到响应写完异步模型里你可以先把请求挂起释放当前线程等业务完成后再从容器借一个线程把响应写完。开头获取异步上下文AsyncContext asyncContext request.startAsync();之后把耗时的任务扔给一个线程池执行任务完成后再调用asyncContext.complete()。这样做能显著提高容器的吞吐量适合处理像长轮询、第三方接口对接这类耗时操作。但异步 Servlet 的坑也不少第一必须把async-supported设置为 trueWebServlet(asyncSupported true)或web.xml里配置async-supportedtrue/async-supported第二如果业务线程里没注意处理异常请求可能一直挂起直到超时第三与 Filter 搭配时Filter 也要支持异步否则有时候请求走不到过滤链的收尾逻辑。不要因为 Spring MVC 通常自带异步支持就觉得 Servlet 层的这套机制跟你没关系遇到手写底层接口或者做网关层时还是会碰到。5. 常见问题与排查技巧实录——都是实战里淌过的水5.1 状态码速查404、500、405状态码背后对应的问题是后端排查最常规的起点我直接整理成速查表分享给你。状态码典型原因排查路径404Servlet 未匹配、部署路径不对、上下文错误先确认 URL 路径再curl -v看实际请求点进 Tomcat manager 看部署列表500Servlet 代码抛异常看 catalina.out 和 localhost.log 的堆栈定位具体行号405请求方法不受支持确认doGet/doPost是否重写确认前端请求方法是不是写错了400请求参数格式错误检查请求体是否 JSON、Content-Type 是否匹配302被重定向看 Location 头判断是业务逻辑还是登录拦截遇到 404 不要第一时间怀疑代码先用浏览器开发者工具或 curl 看完整请求路径。我至少有一半的 404 是因为上下文路径没算对比如没打 WAR 包直接跑源码目录访问路径里多了一段target之类的目录。5.2 生命周期相关坑位清单生命周期听懂了是一回事实际踩坑又是另一回事。这里我列几条高频坑依赖了init()里才加载的资源但loadOnStartup没配第一次请求时并发压力大多个线程同时尝试初始化导致资源重复加载。在构造函数里干活。实例化时容器还没完成依赖注入构造函数里调用init相关方法必然失败。重写service()后请求全乱因为默认分发逻辑被覆盖了。把destroy()当作业务钩子服务器进程被 kill 时根本不会执行导致资源没释放。多个 Servlet 共享同一个全局状态生命周期不同步一个先销毁另一个还在用。其中“构造函数里干活”这个我特别想展开说。有些人以为构造函数就是初始化于是在构造函数里读配置文件、连数据库结果在 IDEA 里本地跑没问题IDE 的热部署触发了完整初始化部署到线上容器就遇到NullPointerException或者资源初始化失败。因为容器的加载顺序不保证构造函数调用时其他基础设施已经就绪。把初始化逻辑统一放init()里是最稳妥的做法。5.3 调试 Servlet 程序的独门心法想快速定位 Servlet 层的问题光靠System.out.println够用但低效。我通常的做法分三步走第一开远程调试。Tomcat 启动脚本里加上调试参数JPDA_ADDRESS8000 ./bin/catalina.sh jpda start然后在 IDE 里配置 Remote JVM Debug监听 8000 端口。这样可以在init()、service()、过滤器里直接断点看状态不用反复部署重启。第二用 Filter 打印请求全链路。开发环境可以写一个调用链日志过滤器记录每个请求的 URI、参数、耗时、响应状态。它能帮你快速判断问题到底出在 Servlet 之前还是 Servlet 内部。第三善用 Tomcat 的 AccessLog。Tomcat 默认会把请求记录下来通过 access log 能看到请求是否到达容器。如果 access log 里有记录但你的 Servlet 断点没命中那问题就出在过滤链或者监听器里如果连 access log 都没有说明请求根本没过网络层或容器接收层。这套排查顺序能覆盖绝大多数 Servlet 层面的诡异问题别一上来就跑去看业务堆栈。5.4 一个真实的线上事故复盘最后分享一个我印象很深的线上事故。某个服务在请求高峰期偶尔出现部分请求返回 500重启就好隔几天又复发。排查日志时发现堆栈指向一个SimpleDateFormat的并发问题。这个对象的parse()方法不是线程安全的而它恰好被放在 Servlet 的实例字段里所有线程共用同一个实例高并发下就会偶尔抛出NumberFormatException。表面看是日期解析报错实质就是 Servlet 单例多线程模式下共享可变对象导致的经典事故。改成ThreadLocalSimpleDateFormat或者直接用DateTimeFormatter之后问题彻底消失。事后我们把代码整体梳理了一遍凡是在 Servlet 字段里出现的非线程安全对象全部替换或加锁压测再也没复现过。这件事给我最大的教训是写 Servlet 程序时每次往类字段里加东西都要下意识问一句——“这个对象被多个请求同时用安全吗” 养成这个习惯很多坑根本不会踩到。6. 后续扩展从 Servlet 到 Spring MVC 的迁移理解6.1 Spring MVC 中的 DispatcherServlet 扮演什么角色很多人从 Servlet 直接跳到 Spring MVC容易被各种概念绕晕。其实从 Servlet 角度看Spring MVC 的核心入口就是一个 Servlet名字叫DispatcherServlet。它接管了所有匹配到的请求然后做解析 Handler、执行拦截器、调用 Controller、渲染视图这一整套动作。理解了它是个 Servlet再去看 Spring Boot 的自动配置就清晰了Spring Boot 所做的无非是帮你注册了一个DispatcherServlet并把它映射到根路径/你写的Controller只是被这个 Servlet 内部调用的组件而不是直接由 Tomcat 调用的 Servlet。这个认知的价值体现在调试上用CrossOrigin配置跨域有时候不生效根源不在 Controller可能在过滤器和 Servlet 映射那一层用HandlerInterceptor拦截请求但静态资源照样被访问根源还是映射规则和 Servlet 处理顺序。6.2 把 Servlet 生命周期映射到 Spring 容器生命周期Spring MVC 的DispatcherServlet也继承 Servlet 生命周期。它的init()方法里会初始化 Spring 容器、装配 HandlerAdapter、初始化视图解析器等而destroy()会触发 Spring 容器的关闭钩子销毁单例 Bean。这里有一个非常实用的排查经验如果你的 Spring Boot 应用里某段逻辑在启动时没执行但在容器关闭时才报错往往就是生命周期回调的时机问题。比如你把自己的 Bean 初始化逻辑写在PostConstruct里而它依赖的某个资源还在初始化中就会出现“启动没报错、运行报错”的怪现象。反过来如果你想知道哪些组件在 Servlet 容器关闭时会执行清理看DispatchedServlet的destroy()调用链就知道了。6.3 从手写 Servlet 到生产级方案的演进思考刚开始手写 Servlet 程序时你可能会觉得它原始又繁琐业务代码和请求处理逻辑挤在一个类里没有参数校验、没有事务管理、没有依赖注入。这些问题正是随之而来的 MVVM 分层框架要解决的但 Servlet 从未消失它作为 Web 模型的基础设施一直存在。生产环境里让我选的话框架照用但 Servlet 层的基础知识必须储备。遇到以下场景框架反而容易“帮着藏问题”比如在网关层做统一鉴权需要非常清楚 Filter 和 Servlet 的执行优先级在非 Spring 生态中用纯 Servlet 写轻量接口需要清楚映射规则和生命周期做高可用架构时需要手动控制连接池和线程池的初始化释放时机。理解了 Servlet 程序等于拿到了一把打开 Java Web 世界底层大门的钥匙。框架千变万化Servlet 规范只有一个掌握它的生命周期和运行机制再去看任何上层框架都会轻松很多。