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> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> </dependencies>scope必须是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从线程池里调度了不同线程来处理你的请求。接着用Ctrl+C关闭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-name>lifecycleDemo</servlet-name> <servlet-class>com.demo.LifecycleDemoServlet</servlet-class> <load-on-startup>1</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-name>lifecycleDemo</servlet-name> <servlet-class>com.demo.LifecycleDemoServlet</servlet-class> <init-param> <param-name>greeting</param-name> <param-value>hello</param-value> </init-param> </servlet>注解方式则用@WebInitParam:
@WebServlet( 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); } }两个线程同时进来,可能都读到count=0,然后各自加1,最后都写回1。实际表现是明明有两个请求,返回的却是count=1。这就是经典的并发安全问题。解决办法不外乎几种:把共享变量改成局部变量;加锁;用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应用本身就是一个Servlet,Web容器创建它并调用它的init方法,Spring容器则是在这个Servlet的初始化过程中启动的。
以Spring Boot内嵌Tomcat为例:Tomcat在启动阶段会加载并初始化DispatcherServlet,这是Spring MVC的前端控制器,本身就是一个Servlet。当Tomcat调用DispatcherServlet的init方法时,DispatcherServlet内部会创建并启动整个Spring应用上下文,这个过程才会触发@PostConstruct、InitializingBean、@PreDestroy这些钩子。
所以两者的调用次序是严格嵌套的:
| 层级 | 生命周期 | 触发方式 |
|---|---|---|
| Servlet层 | Web容器创建DispatcherServlet | new + init |
| Spring容器层 | DispatcherServlet的init触发上下文刷新 | 创建Bean、执行InitializingBean |
| Request层 | 每个请求触发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-complete="true"关闭,或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里埋一套生命周期日志,跑通后再写业务。这个习惯看起来不起眼,却帮我避开了无数次“为什么我的资源没初始化”的坑,也建议你试试。