2026-10-23 Servlet 生命周期实测
课上讲 Servlet 生命周期就四个词:构造、init、service、destroy。但"什么时候"发生,我其实没搞清楚。于是在 Tomcat 上写了两个 Servlet,把每个阶段都打上时间戳跑了一遍。
一、第一个坑是自己撞出来的
我想在构造函数里打一行日志,证明"实例是什么时候被 new 出来的"。写完一跑,500:
java.lang.NullPointerException
at java.nio.file.Path.of(Path.java:148)
at LifeCycleServlet.log(LifeCycleServlet.java:74)
at LifeCycleServlet.<init>(LifeCycleServlet.java:29)
原因是我把日志路径写在了 init-param 里,在构造函数里去 getInitParameter("logPath") 拿——拿到的是 null。
ServletConfig 是容器在 init 之前注入的。构造函数跑的时候,容器还没把配置交给它。所以构造函数里能做的只有最基础的初始化,任何依赖配置、依赖 ServletContext 的事情,都必须在 init 里做。
这一点比我原来"构造函数先跑、init 后跑"的理解具体得多:不是顺序问题,是能拿到什么的问题。
二、两个 Servlet,一个懒一个不懒
web.xml 里配两个:
<servlet>
<servlet-name>lazy</servlet-name>
<servlet-class>LifeCycleServlet</servlet-class>
<init-param><param-name>name</param-name><param-value>lazy</param-value></init-param>
</servlet>
<servlet>
<servlet-name>eager</servlet-name>
<servlet-class>LifeCycleServlet</servlet-class>
<init-param><param-name>name</param-name><param-value>eager</param-value></init-param>
<load-on-startup>1</load-on-startup>
</servlet>
区别只有一行 <load-on-startup>1</load-on-startup>。
init() 里故意 Thread.sleep(500),模拟"加载配置、连数据库"这类重活,这样耗时在日志里看得见。
三、日志记下来的顺序
12:37:37.630 CONSTRUCT 实例 @229919394
12:37:38.136 INIT name=eager 实例 @229919394 线程=main 耗时=503ms
12:37:49.768 CONSTRUCT 实例 @1717102735
12:37:50.271 INIT name=lazy 实例 @1717102735 线程=http-nio-8080-exec-1 耗时=503ms
12:37:50.271 SERVICE name=lazy 实例 @1717102735 线程=http-nio-8080-exec-1 第 1 次
12:37:50.297 SERVICE name=lazy 实例 @1717102735 线程=http-nio-8080-exec-3 第 2 次
12:37:50.314 SERVICE name=lazy 实例 @1717102735 线程=http-nio-8080-exec-2 第 3 次
12:37:50.322 SERVICE name=eager 实例 @229919394 线程=http-nio-8080-exec-10 第 1 次
12:37:50.331 SERVICE name=eager 实例 @229919394 线程=http-nio-8080-exec-4 第 2 次
12:37:50.343 SERVICE name=eager 实例 @229919394 线程=http-nio-8080-exec-6 第 3 次
12:38:28.542 DESTROY name=eager 实例 @229919394
12:38:28.543 DESTROY name=lazy 实例 @1717102735
读出来四件事。
第一,eager 在 Tomcat 启动时就构造了,线程是 main。 12:37:37 构造,12:37:38 init,那时候我还没发任何请求。
第二,lazy 一直到 12:37:49 第一次请求才被构造。 注意它的 init 跑在 http-nio-8080-exec-1 上——就是处理这个请求的那个线程,不是 main。
第三,第一次请求 586ms,第二次 10ms,第三次 9ms。 多出来的那 500 多毫秒就是 init 的开销,摊在了第一个访问的人头上。eager 那三个请求分别是 9ms、13ms、10ms,因为它早初始化完了。
第四,实例 hash 始终不变,线程每次都变。 lazy 三次都是 @1717102735,eager 三次都是 @229919394,但从 exec-1 换到 exec-3、exec-2。这就是"Servlet 是单例,容器给每个请求发一个线程"的直接证据。也意味着 int count 这种成员变量是共享的,多线程下必须自己保证线程安全。
四、destroy 没那么容易触发
我先用 shutdown.bat 停 Tomcat,等了 15 秒没停掉,日志里也没有 DESTROY。
后来改用 8005 端口直接发 SHUTDOWN 命令,瞬间停住,两行 DESTROY 这才出现。
这件事本身比日志更值得记:destroy() 只在容器正常关闭时才调用。直接 kill 进程、机器断电,它就根本不会跑。所以清理资源、刷缓冲这类事情不能只押在 destroy() 里,得配合别的手段(比如连接池自己有空闲回收)。
顺带一个细节:DESTROY 的顺序是 eager 先、lazy 后,正好和 init 反着来。
五、结论
load-on-startup 决定的是"谁承担 init 的 500 毫秒"——不设就是第一个用户,设了就是启动时的 main 线程。线上环境通常希望启动慢一点、请求快一点,所以重资源的 Servlet 该设。
init() 只执行一次,service() 每个请求执行一次,destroy() 只在正常关闭时执行一次。三个"一次"里,只有 service 的"一次"是你可以不管的。
单实例这件事最容易被忽略:你能在 Servlet 里写成员变量,但它是所有人共享的。
附:Servlet 代码
public class LifeCycleServlet extends HttpServlet {
private String name = "unknown";
private int count = 0;
public LifeCycleServlet() {
log("CONSTRUCT", "实例 @" + System.identityHashCode(this));
}
@Override
public void init() {
this.name = getInitParameter("name");
long t0 = System.currentTimeMillis();
try { Thread.sleep(500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
log("INIT", "name=" + name + " 实例 @" + System.identityHashCode(this)
+ " 线程=" + Thread.currentThread().getName()
+ " 耗时=" + (System.currentTimeMillis() - t0) + "ms");
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
count++;
log("SERVICE", "name=" + name + " 实例 @" + System.identityHashCode(this)
+ " 线程=" + Thread.currentThread().getName() + " 第 " + count + " 次");
resp.setContentType("text/plain;charset=UTF-8");
resp.getWriter().println("instance=" + Integer.toHexString(System.identityHashCode(this))
+ " thread=" + Thread.currentThread().getName() + " count=" + count);
}
@Override
public void destroy() {
log("DESTROY", "name=" + name + " 实例 @" + System.identityHashCode(this));
}
}
日志路径走系统属性
-Dlifecycle.log=...,不能走init-param—— 构造函数里拿不到。
这就是第一节那个 NPE 的由来。
浙公网安备 33010602011771号