
Servlet生命周期这个词干Java Web的没人不认识。可每次面试别人我问到“容器启动之后Servlet的init方法一定马上执行吗”“如果init抛异常会发生什么”能答得干脆的候选人确实不多。我自己也曾因为初始化时机没搞明白在线上吃过亏所以这篇就把从加载、初始化、服务到销毁的整条链路从头拆一遍重点讲清楚每个阶段的触发时机、由谁调用、出了状况怎么排查。读这篇文章的读者我默认有两种一种是把Servlet当黑盒只会在doGet/doPost里写业务代码想弄清楚背后原理的开发者另一种是工作两三年的Java后端写框架多了反而把最底层这一层容器行为忘得差不多的人。如果你属于这两种里的任何一种这篇文章会很对胃口。代码和环境部分我也会把VSCode里的配置步骤一并整理保证能跟着跑起来。这里要先把一个重要观点放在前面Servlet的生命周期不是由业务代码驱动的而是由Web容器比如Tomcat控制的。什么时候new对象、什么时候执行init、请求到来时如何调用service、关闭时如何执行destroy这四条线全都不在你的Servlet类里而在容器的一整套调度逻辑中。把这层关系理顺后面所有细节都能串起来。1. 一图看懂Servlet生命周期从加载到卸载1.1 五个阶段分别是谁在干活一个标准的Servlet生命周期可以拆成五个阶段加载与类装载、实例化、初始化、服务、销毁与卸载。网上很多文章把前三步统统塞进“初始化”里严格来说是不准确的。容器启动时会根据部署描述符web.xml里的配置或者注解扫描结果找到Servlet类。这个阶段做的核心事情是加载类把Servlet的class文件交给JVM让类元数据进入方法区。这个时候还没有对象产生。真正创建对象是在实例化阶段容器通过反射调用类的无参构造方法得到一个对象。注意这个阶段只是new了一个对象对象里的业务资源一个都还没准备好。对象创建完成后容器会调用init方法这才是我们口中常说的“初始化”。Servlet规范建议在这个方法里做资源准备比如读取配置、建立数据库连接池、初始化线程池。init只会被调用一次一种情况是容器启动时由load-on-startup触发另一种情况是第一次请求到达时触发。之后的service阶段就不一样了每次请求进来容器都会从线程池里挑一个线程来调用service所以service可能被并发执行而且同一个Servlet实例要承受所有请求。最后是销毁容器关闭或应用卸载时会先调用destroy让你释放资源之后对象变成垃圾等待GC回收。这里用一个生活化的比喻帮助理解Servlet类就像一张菜谱实例化是厨师把菜谱翻出来摆到操作台上init是提前把食材洗好切好service是顾客点菜后开火炒菜destroy则是餐厅打烊后关火洗锅、清理灶台。一张菜谱能被反复用来炒同一个菜但提前备菜和打烊收工一个营业日里各只做一次。1.2 从代码角度理解每个方法的调用时机下面这个Servlet写出来基本就是生命周期最直观的标本import javax.servlet.*; import javax.servlet.http.*; import javax.servlet.annotation.*; import java.io.IOException; WebServlet(name lifecycleDemo, urlPatterns /life) public class LifecycleDemoServlet extends HttpServlet { public LifecycleDemoServlet() { System.out.println(1. 实例化构造函数被调用); } Override public void init(ServletConfig config) throws ServletException { System.out.println(2. 初始化init被调用); super.init(config); } Override protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { System.out.println(3. 服务service被调用线程名 Thread.currentThread().getName()); resp.getWriter().write(lifecycle demo); } Override public void destroy() { System.out.println(4. 销毁destroy被调用); } }把这段代码丢进Tomcat跑起来你会看到日志的输出顺序和上面标注的序号完全一致。有个细节值得注意构造方法打印的时机不一定是容器刚启动如果你的Servlet没有配置loadOnStartup那构造方法和init都会等到第一次访问 /life 时才被触发。这也是初学者最容易误解的地方——“我启动Tomcat了为什么没看到init日志”原因就是请求还没来。容器对生命周期的控制力体现在一个很关键的设计上你没有办法手动控制Servlet实例的创建和销毁连new都不行。Servlet实例由容器创建你只负责继承HttpServlet、重写对应方法然后等待容器回调。这种模式叫控制反转跟Spring的IoC理念是同源的。你写的Servlet类本质上是一堆贴在固定时间点的回调函数。2. 让生命周期可见环境配置与最小Demo跑通2.1 VSCode里跑Servlet环境到底怎么配网上搜“vscode编写servlet代码需要怎么配置环境”能翻到一堆答案但很多都讲得云里雾里。我直接说结论VSCode只是一个编辑器它本身不运行Servlet你需要一套独立的Servlet容器来承担运行和编译打包的工作。最轻量的组合是VSCode Java Extension Pack Tomcat Maven。Java Extension Pack提供语言支持Maven负责拉依赖和打包Tomcat是你的Servlet容器。有人会问为什么不直接用Eclipse或IDEA当然可以但用VSCode的好处是轻、启动快而且配置过程能逼着你把Servlet运行机制搞清楚对于学习阶段反而有好处。具体配置时建议放弃手动拷贝servlet-api.jar的方式改用Maven依赖。在pom.xml里加这段dependencies dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency /dependenciesscope必须是provided原因很简单Tomcat自己就带了一份Servlet API实现。如果你用compile作用域把jar打进最终产物部署到Tomcat后很可能出现类冲突报一些莫名其妙的NoSuchMethodError或LinkageError。使用provided就能让编译器认识Servlet接口打包时又把Servlet API排除在外运行阶段完全交给Tomcat提供。依赖弄好后项目的标准目录结构长这样src/main/java └── com/demo/LifecycleDemoServlet.java src/main/webapp └── WEB-INF/web.xml也可以不用 pom.xml用Maven打成war包放进Tomcat的webapps目录下启动。如果不想用Maven也可以直接编译class文件到项目的WEB-INF/classes目录把整个项目作为一个exploded目录丢给Tomcat。两种方式都能跑但Maven方式更贴近真实项目建议一步到位。2.2 写一个能看到init/service/destroy的Servlet环境准备好后把上面的LifecycleDemoServlet编译打包部署到Tomcat。启动Tomcat观察控制台日志大概率会看到两种情况如果没有任何访问日志上可能一条生命周期输出都没有这可能让人困惑但其实是默认懒加载在起作用。你在浏览器里访问一次 http://localhost:8080/你的项目名/life 控制台立刻会打印1. 实例化构造函数被调用 2. 初始化init被调用之后每刷新一次页面控制台只会多一条service日志而且多次刷新后你会发现线程名经常不同说明Tomcat从线程池里调度了不同线程来处理你的请求。接着用CtrlC关闭Tomcat控制台尾部会出现“4. 销毁destroy被调用”。整个过程完整跑一遍生命周期从理论变成了肉眼可见的东西。我建议你把这一段日志截图存下来以后跟人聊Servlet生命周期时直接拿证据说话。很多人学了一辈子只知道“有这几个方法”但从来没亲眼看过它们的调用顺序。真正动手跑一遍比看十篇文章都管用。3. 初始化阶段深挖时机、参数与失败处理3.1 init的触发时机与loadOnStartup容器决定什么时候调用init依据是Servlet的加载策略。默认情况下容器采用的是懒加载策略构造对象和调用init都在第一次请求到达时才触发。这种设计的初衷是节省资源——一个Web应用可能注册了几十个Servlet但真正被访问的可能只有少数几个没必要在启动时就全部初始化。但懒加载有时会带来问题。比如某个Servlet的init里要初始化数据库连接池一次连接池创建可能耗时500毫秒甚至更久那第一个请求就会被这500毫秒拖累表现为“第一次打开页面特别慢刷新后就正常了”。为了规避这种问题Servlet规范提供了load-on-startup配置。web.xml里可以写成servlet servlet-namelifecycleDemo/servlet-name servlet-classcom.demo.LifecycleDemoServlet/servlet-class load-on-startup1/load-on-startup /servlet注解方式对应WebServlet里的loadOnStartup属性WebServlet(name lifecycleDemo, urlPatterns /life, loadOnStartup 1)load-on-startup的数值代表启动顺序正整数越小越先初始化。如果你在web.xml里配置了多个Servlet想严格控制初始化顺序就给它们分别标注1、2、3这样的优先级。负数和没配置效果一样都表示懒加载等请求来了再初始化。这里有个坑要提醒把load-on-startup设了却不代表启动时正经初始化一定成功。如果init方法内部抛出了ServletException且该Servlet配置了启动加载那么Tomcat在启动阶段就会报错甚至导致应用启动失败。反过来如果是懒加载模式init抛异常不会影响容器启动但第一个请求到达时会直接以500错误返回。所以init方法里到底放什么必须想清楚放太重拖慢启动或首次请求放太轻初始化意义减半。3.2 init的参数注入与重载方法很多人在Servlet里读不到硬编码的配置原因就是用了错误的方式。Servlet规范专门提供了一套初始化参数机制让部署人员可以在不修改代码的情况下调整Servlet行为。web.xml方式servlet servlet-namelifecycleDemo/servlet-name servlet-classcom.demo.LifecycleDemoServlet/servlet-class init-param param-namegreeting/param-name param-valuehello/param-value /init-param /servlet注解方式则用WebInitParamWebServlet( name lifecycleDemo, urlPatterns /life, initParams { WebInitParam(name greeting, value hello) } )在init(ServletConfig config)里通过config.getInitParameter(greeting)就能取到。但是这里有一个非常容易踩的细节日常代码里我更建议重写无参的init()而不是带ServletConfig参数的init(ServletConfig)。原因是GenericServlet在实现init(ServletConfig)时会先把ServletConfig保存起来然后内部再调用一个无参init()作为钩子方法。如果你重写了带参版本又忘记调用super.init(config)那后面调用getServletConfig()或getInitParameter()时就会出现空指针或拿不到配置的情况。而直接重写无参init()就不会有这个问题。一个实用范例是这样的Override public void init() { this.greeting getServletConfig().getInitParameter(greeting); }getServletConfig()和getInitParameter()都是从GenericServlet继承来的便捷方法底层依赖已经保存好的ServletConfig实例。所以前提还是那句话如果非要重写带参init千万别省掉super.init(config)。理论讲再多不如把这个约定记牢能帮你避开很多莫名其妙的初始化Bug。4. 运行阶段剖析单例复用与并发模型4.1 为什么一个实例要服务所有请求Servlet默认是单例多线程模型。容器只会为每个Servlet创建一个实例这个实例要同时被多个请求线程共享。正因为如此doGet、doPost里才能直接访问Servlet的成员变量也正因为如此成员变量一旦被并发写入就可能出问题。用一个典型例子说明。假设你在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); } }两个线程同时进来可能都读到count0然后各自加1最后都写回1。实际表现是明明有两个请求返回的却是count1。这就是经典的并发安全问题。解决办法不外乎几种把共享变量改成局部变量加锁用AtomicInteger或者放进ThreadLocal。具体选哪种要看你到底想共享什么。这个设计看起来有点危险却是性能和复杂度之间的权衡。为每个请求都new一个Servlet实例固然线程安全了但对象创建和GC的压力会被放大无数倍。现在的容器选择了实例复用让每个请求只新建request和response对象这两个对象用完即弃而Servlet本身保持单例从而平衡了吞吐量和开销。值得花时间理解的是request和response的生命周期极短。它们随着请求到达而创建随着响应结束而销毁哪怕是同一个用户连续发起的两个请求拿到的也是两个完全不同的request对象。你无法在一个请求的实例里保存另一个请求的数据Session和Cookie机制才是跨请求存取数据的正确渠道。搞清楚这些边界能少走很多弯路。4.2 Servlet生命周期与Spring Bean生命周期的嵌套关系现在很多项目都是Spring Boot Spring MVC很多开发者会疑惑Servlet生命周期和我用的Spring Bean生命周期到底什么关系一句话总结Servlet生命周期由Web容器管理Spring Bean生命周期由Spring IoC容器管理而Spring Boot应用本身就是一个ServletWeb容器创建它并调用它的init方法Spring容器则是在这个Servlet的初始化过程中启动的。以Spring Boot内嵌Tomcat为例Tomcat在启动阶段会加载并初始化DispatcherServlet这是Spring MVC的前端控制器本身就是一个Servlet。当Tomcat调用DispatcherServlet的init方法时DispatcherServlet内部会创建并启动整个Spring应用上下文这个过程才会触发PostConstruct、InitializingBean、PreDestroy这些钩子。所以两者的调用次序是严格嵌套的层级生命周期触发方式Servlet层Web容器创建DispatcherServletnew initSpring容器层DispatcherServlet的init触发上下文刷新创建Bean、执行InitializingBeanRequest层每个请求触发DispatcherServlet.doDispatch调用Controller方法、拦截器如果你问我排查问题时的启发是什么那就是Spring Bean初始化失败时你往往会看到整个应用启动失败因为DispatcherServlet的init方法抛异常会让Tomcat认为应用没起来但Servlet层自己的初始化失败表现可能是请求才报错、启动略过。这两种表现完全不一样定位时先要分清是哪一层出了问题。5. 销毁阶段与重载陷阱最后一个容易被忽视的角落5.1 destroy到底做了什么destroy的触发时机比很多人想象得要少。只有应用被卸载、容器正常关闭、或者应用重新加载reload时容器才会调用所有已初始化Servlet的destroy方法。如果你只是让服务器继续跑着destroy永远都不会被调用。反过来如果你开发时修改了Servlet源码并触发热部署就会看到旧实例的destroy被调用新实例的init随之启动。destroy方法的标准写法是把你在init或其他阶段打开的资源统一收尾释放数据库连接、关闭线程池、取消定时任务、关闭文件流。注意顺序问题也很重要。如果你在destroy里先关闭了数据源但其他对象还在等结果就会出现“临死前的最后一次报错”看起来特别诡异。有一种老代码习惯把资源释放塞进finalize方法里这是错误示范。finalize在JVM中根本不被保证调用时机甚至可能不被调用。Servlet规范给出的destroy是容器确保会调用的生命周期方法只要容器在正常关闭destroy一定执行。所以在Servlet类里做清理工作唯一正确的保证手段就是destroy。还有一个细节destroy抛出的异常会被容器捕获并记录但不影响其他Servlet的destroy继续执行。也就是说一个Servlet的destroy崩溃并不会拖累整个应用停止。所以在写destroy时尽量别抛受检异常而要把异常捕获处理用日志记录清楚。5.2 热部署、重载与ClassLoader泄漏这个话题跟生命周期强相关但容易被忽略。Tomcat的热部署本质上是丢弃旧的应用上下文用一个新的类加载器重新加载应用。旧class被卸载的前提是没有人继续持有旧ClassLoader的引用。但现实往往是某些静态集合、第三方库缓存、未关闭的连接池会死死拽住旧的ClassLoader导致旧类无法卸载满屏的Metaspace OOM随之而来。从生命周期角度看热部署过程中每个Servlet都会走一遍destroy但如果destroy里该释放的资源没有释放干净就会为ClassLoader泄漏埋下雷。典型的泄漏源头包括在静态Map里缓存了当前类的Class或ClassLoader启动的后台线程没有被停止JDBC驱动把自己注册到全局DriverManager后没有反注册。排查这种问题最直接的办法是打开JVM的类加载器日志或者用内存分析工具抓dump看持有ClassLoader引用的对象链。但治本还是在destroy里把线程停掉、把连接关掉、把静态引用清空。不要偷懒。5.3 Servlets与上下文监听器如何配合正当的全局资源管理其实有更专业的入口——ServletContextListener。它不属于Servlet但监听ServletContext的创建和销毁所以能在应用启动时做全局初始化、关闭时做全局清理。如果你要初始化的资源是整个应用共享的不应该在某一个Servlet里做因为Servlet加载顺序不好控制销毁顺序也不好控制。简单示例WebListener public class AppListener implements ServletContextListener { Override public void contextInitialized(ServletContextEvent sce) { System.out.println(应用启动全局资源开始初始化); } Override public void contextDestroyed(ServletContextEvent sce) { System.out.println(应用停止全局资源开始清理); } }监听器的调用时机和Servlet是不同维度的Servlet的初始化是按需或按load-on-startup触发的而ServletContextListener的contextInitialized在应用启动阶段必然执行且在所有Servlet初始化之前。把全局资源交给它管理比塞在某个具体Servlet里更符合生命周期的划分逻辑。6. 高频问题排查与经验记录6.1 常见问题速查表平时在开发群里见到的Servlet相关问题很多都能落脚到生命周期某个环节出了问题。我整理了一份速查表按现象排查很方便。现象可能的根因排查方向启动Tomcat看不到init日志未配置load-on-startup懒加载未触发访问一次对应URL看是否触发初始化首次请求特别慢后续正常init里做了耗时初始化把耗时操作迁移到loadOnStartup或用异步初始化init里读不到init-param重写了带参init且没调用super.init(config)改为重写无参init或补上super.init(config)请求时抛出ClassNotFoundException编译后的class没有同步到运行目录检查target/classes或WEB-INF/classes下的class文件时间WebServlet不生效扫描被metadata-completetrue关闭或Servlet版本不支持注解检查web.xml的metadata-complete确认Servlet版本3.0以上停止Tomcat时destroy没打印应用崩溃或使用kill -9强杀使用正常关闭脚本确认当前没有发生OOM成员变量并发写错乱Servlet单例被多线程共享改用局部变量、加锁、或AtomicInteger热部署时Metaspace持续上涨旧ClassLoader被引用内存泄漏检查静态集合、后台线程、JDBC驱动反注册这张表里我自己踩得最多的就是第二行首次请求慢。线上第一次访问页面会卡几秒观看后台日志才发现所有初始化都压在init里面。后来把数据库连接池和缓存预热都挪到loadOnStartup问题立刻消失。6.2 我的三条实测建议第一不要在类加载阶段做业务初始化。有人习惯在Servlet的静态代码块里建立连接池这样做不是不行但静态代码块执行时机在类加载阶段比init还早而且完全脱离容器管理。一旦需要重新初始化你就得靠类卸载这种不可控手段还不如老老实实用init。第二生命周期日志比断点好用得多。调Servlet生命周期问题时别用IDE断点因为断点会阻塞容器线程很容易造成假象。正确的做法是在构造方法、init、service、destroy四个位置分别打印日志然后观察完整输出流程。阶段之间是否卡住、哪里抛异常日志一目了然。第三如果项目使用了注解配置Servlet建议确认LoadOnStartup不会和web.xml的servlet-mapping打架。注解方式能让代码更聚拢但遇到复杂的部署环境时web.xml的优先级更高两者同时存在时容易让人晕。我个人的做法是新项目用注解老项目维护web.xml切换时先统一口径再排查问题。讲到这里Servlet生命周期从初始化到销毁的整条链路算是完整过了一遍。说实话生命周期看起来是Java Web里最基础的内容但越往深处挖越发现很多线上故障的根源都藏在那些“被忽略的基础阶段”里。我个人现在写任何Java Web项目都会先在核心Servlet里埋一套生命周期日志跑通后再写业务。这个习惯看起来不起眼却帮我避开了无数次“为什么我的资源没初始化”的坑也建议你试试。