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 的由来。

posted @ 2026-10-23 12:42  呱哒  阅读(8)  评论(0)    收藏  举报