第02篇 · Servlet核心原理:一切Web框架的起点

如果你打开了这篇,说明你已经知道------Spring MVC 的底层,站着一个叫 Servlet 的东西。但你可能没想过:为什么 Spring 不自己从头写一套 Web 处理逻辑,而非要"寄生"在 Servlet 规范之上?

答案很简单:因为 Servlet 是 Java Web 世界的"通用语言"。Tomcat、Jetty、Undertow 这些 Web 容器都"说" Servlet 这套语言。Spring 只要"说"同样的语言,就能跑在任何容器上------这就是规范的力量。

这一篇,我们把 Servlet 彻底搞清楚。

学习目标

  • 理解 Servlet 的本质:它是一个处理网络请求的 Java 接口规范
  • 掌握 Servlet 的完整生命周期(加载→实例化→初始化→服务→销毁)
  • 理解 Servlet 容器(Tomcat) 如何管理 Servlet 实例
  • 理解为什么说 Spring MVC 的 DispatcherServlet 本质上也是一个 Servlet

正文

一、从 HTTP 到 Servlet:一个请求的"旅行"

想象一下这个场景:你在浏览器输入 https://example.com/hello,回车。

背后发生了什么?我们一步步拆解:

第一步:浏览器 → Web 服务器

浏览器根据域名找到对应的服务器 IP,发起一个 HTTP 请求。Web 服务器(比如 Nginx 或 Apache)收到请求后,发现这是一个需要动态处理的请求(而不是静态的 HTML 或图片),于是将请求转发给 Servlet 容器

第二步:Web 服务器 → Servlet 容器

Servlet 容器(比如 Tomcat)是真正"干活"的地方。它收到请求后,需要做三件事:

  1. 解析 HTTP 请求 :把原始的 HTTP 报文解析成 Java 对象(HttpServletRequest
  2. 找到对应的 Servlet :根据 URL 路径(/hello)找到匹配的 Servlet
  3. 调用 Servlet :把请求交给 Servlet 处理,然后把响应(HttpServletResponse)写回浏览器

第三步:Servlet 容器 → Servlet 实例

容器找到对应的 Servlet 后,调用它的 service() 方法。你的业务逻辑就在这个方法(或其衍生方法 doGet/doPost)中执行。

整个流程可以简化为:

复制代码
浏览器 → Web服务器 → Servlet容器 → Servlet实例 → 你的业务代码

关键认知Servlet 不是一个"东西",而是一个"接口" 。它定义了一个 Java 类应该如何与容器协作来处理网络请求。任何实现了 jakarta.servlet.Servlet 接口的类,都可以被 Servlet 容器管理并处理请求。

二、Servlet 接口剖析:五个方法,三个核心

我们来看 Servlet 接口的定义(Jakarta Servlet 6.0):

java 复制代码
package jakarta.servlet;

public interface Servlet {
    // 初始化方法------容器在创建 Servlet 实例后调用
    void init(ServletConfig config) throws ServletException;
    
    // 服务方法------容器每次收到请求都会调用
    void service(ServletRequest req, ServletResponse res) 
            throws ServletException, IOException;
    
    // 销毁方法------容器在卸载 Servlet 前调用
    void destroy();
    
    // 获取 Servlet 配置信息
    ServletConfig getServletConfig();
    
    // 获取 Servlet 的基本信息(作者、版本等)
    String getServletInfo();
}

这五个方法中,init()service()destroy() 是生命周期方法,由容器在特定时机调用。

实际开发中,我们几乎不会直接实现 Servlet 接口,而是继承 HttpServlet 抽象类:

复制代码
Servlet(接口)
    ↑
GenericServlet(抽象类,实现了 Servlet 接口)
    ↑
HttpServlet(抽象类,继承了 GenericServlet,增加了 HTTP 协议支持)
    ↑
你的 Servlet(继承 HttpServlet,重写 doGet/doPost)

HttpServlet 已经帮我们实现了 service() 方法------它会自动判断请求方法是 GET 还是 POST,然后分别调用 doGet()doPost()。所以我们只需要重写这两个方法即可。

三、生命周期详解:一个 Servlet 的"生老病死"

Servlet 的生命周期由 Servlet 容器全权管理。我们来看看每个阶段容器到底做了什么。

阶段一:加载与实例化

容器通过反射 加载 Servlet 类,并调用无参构造方法创建实例。

触发时机(二选一):

  • 懒加载(默认) :第一次收到请求时创建
  • 预加载 :容器启动时创建(通过 @WebServlet(loadOnStartup = 1) 配置)

关键点一个 Servlet 类在容器中只有一个实例------这就是所谓的"单例模式"。

阶段二:初始化

实例创建后,容器立刻调用 init(ServletConfig) 方法。

init() 在整个生命周期中只执行一次。你可以在这里做一次性的初始化工作,比如加载配置文件、建立数据库连接池等。

阶段三:服务

每次请求到来,容器都会创建一个新的线程 ,在该线程中调用 service() 方法。

service() 会被调用多次------每收到一次请求就调用一次。

HttpServletservice() 会自动路由到 doGet()/doPost() 等方法。

阶段四:销毁

容器关闭或应用卸载时,容器调用 destroy() 方法。

destroy() 在整个生命周期中只执行一次。你可以在这里释放资源(关闭连接池、保存状态等)。

完整顺序

复制代码
容器启动(或首次请求)
    ↓
【加载】反射创建 Servlet 实例(1次)
    ↓
【初始化】调用 init()(1次)
    ↓
【服务】每次请求调用 service()(多次)← 多线程并发执行
    ↓
容器关闭
    ↓
【销毁】调用 destroy()(1次)

四、单例多线程模型:一个实例,多个线程

这是 Servlet 最容易被误解的地方,我们单独拿出来说。

核心事实一个 Servlet 类只有一个实例,但可以同时服务多个请求

容器是这样做的:

  1. 创建一个 Servlet 实例(单例)
  2. 每个请求到来时,创建一个新的线程
  3. 所有线程共享同一个 Servlet 实例
  4. 每个线程独立调用 service() 方法

这个设计的好处是:不需要频繁创建销毁对象,性能高。

这个设计的风险 是:如果 Servlet 有成员变量,多个线程同时修改会导致数据错乱

举个例子

java 复制代码
// ❌ 错误:在 Servlet 中定义可变的成员变量
@WebServlet("/counter")
public class CounterServlet extends HttpServlet {
    private int count = 0;  // 所有线程共享!
    
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
        count++;  // 多个线程同时执行,结果不可预测!
        resp.getWriter().write("Count: " + count);
    }
}

两个用户同时访问,count 的值可能从 1 跳到 3(丢失了一次自增),也可能两个线程都读到同一个值然后各自加 1------你永远不知道结果是什么

正确做法

java 复制代码
// ✅ 正确:使用局部变量
@WebServlet("/counter")
public class CounterServlet extends HttpServlet {
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
        int count = 0;  // 每个线程独立,互不干扰!
        count++;
        resp.getWriter().write("Count: " + count);
    }
}

或者使用 AtomicInteger 等线程安全的类。

五、从 Servlet 到 Spring MVC:DispatcherServlet 的"继承链"

现在我们来回答一个关键问题:Spring MVC 和 Servlet 到底是什么关系?

答案:DispatcherServlet 就是一个 Servlet。

我们来看 DispatcherServlet 的继承关系:

复制代码
Servlet(接口)
    ↑
GenericServlet(抽象类)
    ↑
HttpServlet(抽象类)        ← 这就是我们熟悉的 HttpServlet
    ↑
HttpServletBean(抽象类)    ← Spring 对 HttpServlet 的扩展
    ↑
FrameworkServlet(抽象类)   ← Spring Web 的基类
    ↑
DispatcherServlet(类)     ← Spring MVC 的核心!

DispatcherServlet 直接或间接继承了 HttpServlet,所以它本质上就是一个 Servlet。

那么问题来了:既然 DispatcherServlet 只是一个普通的 Servlet,它凭什么能驱动整个 Spring MVC?

答案是 "委派模式" (Delegation Pattern)。

DispatcherServletservice() 方法(实际是 doDispatch())并不自己处理业务逻辑,而是把工作"委派"给一系列组件:

  1. HandlerMapping :根据 URL 找到对应的 @Controller 方法和拦截器
  2. HandlerAdapter :适配不同的 Handler 类型(@ControllerHttpRequestHandler 等)
  3. ViewResolver:根据逻辑视图名找到真正的视图
  4. HandlerExceptionResolver:统一处理异常

这就是"前端控制器模式"(Front Controller Pattern)------一个 Servlet 接收所有请求,然后分发给不同的处理器

所以你可以这样理解:Servlet 是"地基",Spring MVC 是在地基上盖的"大楼"。没有 Servlet,Spring MVC 连请求都收不到。

代码示例

示例一:手写一个最简单的 Servlet(观察生命周期)

功能 :创建一个 Servlet,在 init()service()destroy() 中打印日志,观察生命周期的执行顺序。

环境:Spring Boot 3.x(内置 Tomcat 10.x,支持 Servlet 6.0)

java 复制代码
package com.example.servletdemo.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;
import java.io.PrintWriter;
import java.time.LocalDateTime;

/**
 * 最简单的 Servlet------用于演示生命周期方法的调用顺序
 * 
 * @WebServlet 注解替代了传统的 web.xml 配置(Servlet 3.0+ 支持)
 * urlPatterns 指定了访问路径:http://localhost:8080/lifecycle
 */
@WebServlet(
    name = "LifecycleServlet",
    urlPatterns = "/lifecycle",
    loadOnStartup = 1  // 设置为1表示容器启动时立即加载和初始化
)
public class LifecycleServlet extends HttpServlet {

    /**
     * 构造方法:容器通过反射调用
     * 注意:每次容器启动只调用一次
     */
    public LifecycleServlet() {
        System.out.println("[1] 构造方法被调用 ------ Servlet 实例被创建");
    }

    /**
     * init 方法:实例创建后立即调用
     * 注意:整个生命周期只调用一次
     */
    @Override
    public void init() throws ServletException {
        System.out.println("[2] init() 被调用 ------ Servlet 初始化");
        // 可以在这里做一次性的初始化工作
        // 比如:加载配置文件、建立数据库连接池等
    }

    /**
     * service 方法:每次请求都会调用
     * HttpServlet 的 service 会自动根据请求方法路由到 doGet/doPost
     * 注意:会被多次调用,且在不同线程中并发执行
     */
    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) 
            throws ServletException, IOException {
        System.out.println("[3] service() 被调用 ------ 处理请求,线程ID: " 
                + Thread.currentThread().threadId());
        // 调用父类的 service,让它自动路由到 doGet 或 doPost
        super.service(req, resp);
    }

    /**
     * doGet 方法:处理 GET 请求
     */
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) 
            throws ServletException, IOException {
        System.out.println("[4] doGet() 被调用 ------ 执行业务逻辑");
        
        // 设置响应内容类型
        resp.setContentType("text/html;charset=UTF-8");
        PrintWriter out = resp.getWriter();
        out.println("<html><body>");
        out.println("<h1>Hello, Servlet!</h1>");
        out.println("<p>访问时间: " + LocalDateTime.now() + "</p>");
        out.println("<p>当前线程ID: " + Thread.currentThread().threadId() + "</p>");
        out.println("</body></html>");
    }

    /**
     * destroy 方法:容器关闭或应用卸载时调用
     * 注意:整个生命周期只调用一次
     */
    @Override
    public void destroy() {
        System.out.println("[5] destroy() 被调用 ------ Servlet 销毁,释放资源");
        // 可以在这里释放资源
        // 比如:关闭数据库连接池、保存状态等
    }
}

如何运行验证

  1. 在 Spring Boot 项目中创建这个类(确保项目使用 jakarta.servlet 包)

  2. 启动应用,观察控制台输出:

    复制代码
    [1] 构造方法被调用 ------ Servlet 实例被创建
    [2] init() 被调用 ------ Servlet 初始化
  3. 在浏览器访问 http://localhost:8080/lifecycle,观察控制台:

    复制代码
    [3] service() 被调用 ------ 处理请求,线程ID: 28
    [4] doGet() 被调用 ------ 执行业务逻辑
  4. 多次刷新页面,观察 service() 被多次调用,且线程 ID 可能不同

  5. 停止应用,观察控制台:

    复制代码
    [5] destroy() 被调用 ------ Servlet 销毁,释放资源

关键观察

  • 构造方法和 init() 只执行一次
  • service()doGet() 每次请求都执行
  • 如果快速发起多个请求,你会看到 service()不同线程同时调用

示例二:演示 Servlet 的线程安全问题

功能:创建一个有成员变量的 Servlet,模拟多线程并发访问时的数据错乱。

java 复制代码
package com.example.servletdemo.servlet;

import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.concurrent.atomic.AtomicInteger;

@WebServlet("/threadsafe")
public class ThreadSafetyServlet extends HttpServlet {

    // ❌ 不安全的成员变量------所有线程共享
    private int unsafeCounter = 0;
    
    // ✅ 线程安全的计数器------使用 AtomicInteger
    private AtomicInteger safeCounter = new AtomicInteger(0);

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) 
            throws IOException {
        
        // ❌ 错误做法:直接修改成员变量
        unsafeCounter++;  // 这不是原子操作!读取→加1→写入,三步可能被中断
        
        // ✅ 正确做法:使用线程安全的类
        int safeValue = safeCounter.incrementAndGet();
        
        // ✅ 正确做法:使用局部变量(每个线程独立)
        int localVar = 0;
        localVar++;
        
        resp.setContentType("text/html;charset=UTF-8");
        PrintWriter out = resp.getWriter();
        out.println("<html><body>");
        out.println("<h2>Servlet 线程安全演示</h2>");
        out.println("<p>❌ 不安全计数器 (成员变量): " + unsafeCounter + "</p>");
        out.println("<p>✅ 安全计数器 (AtomicInteger): " + safeValue + "</p>");
        out.println("<p>✅ 局部变量 (每次请求独立): " + localVar + "</p>");
        out.println("<p>当前线程ID: " + Thread.currentThread().threadId() + "</p>");
        out.println("</body></html>");
    }
}

如何验证

  • 使用 JMeter 或 Postman 并发发送多个请求
  • 观察 unsafeCounter 的值是否出现了"跳跃"(比如从 3 直接跳到 5,丢失了 4)
  • safeCounter 始终保持连续

新手错误 vs 正确姿势

错误表象 根本原因 正确姿势
在 Servlet 中定义了 private int count 成员变量来计数,结果数值总是乱跳 未理解 Servlet 是单例多线程,成员变量会被所有线程共享 使用局部变量AtomicInteger 等线程安全类
认为每次请求都会创建新的 Servlet 实例 混淆了 Servlet 实例HttpServletRequest 对象的生命周期 理解 Servlet 实例只有一个,request 对象每次请求新建
在 Servlet 构造方法中做初始化操作,发现某些依赖为 null 构造方法执行时容器尚未完成 ServletConfig 的注入 init() 方法中做初始化,此时 ServletConfig 已就绪

疑难深度追问

Q1:Servlet 是单例的,那么每次请求的用户数据如何隔离?

通过 HttpServletRequest 对象 。每个请求独立创建一个 HttpServletRequest 实例,它包含了该次请求的所有数据(参数、头信息、Session 等)。这个对象只在当前请求的线程中存活,请求结束后就被回收------所以不存在线程安全问题。

Q2:如果 Servlet 容器(Tomcat)重启,正在处理的请求会怎样?

容器会先调用所有 Servlet 的 destroy() 方法,等待正在执行的 service() 方法完成(有超时机制),然后再关闭容器。这就是所谓的"优雅关闭"(Graceful Shutdown)。如果请求在超时时间内未完成,容器会强制中断。

Q3:@WebServlet 注解中的 loadOnStartup 值越大越好还是越小越好?

值越小,优先级越高 (启动时越先加载)。loadOnStartup = 1 最先加载,loadOnStartup = 2 次之。如果多个 Servlet 之间有依赖关系(比如 A 初始化时需要 B 已经就绪),可以通过设置不同的值来控制加载顺序。

思考与延伸

  1. 动手验证 :用 JMeter 对 ThreadSafetyServlet 发起 100 个并发请求,观察 unsafeCounter 的最终值是不是 100。如果不是,思考为什么。

  2. 思考题 :如果在 Servlet 中定义了一个 private static 的成员变量,线程安全问题会更严重还是更轻微?为什么?

  3. 延伸思考 :Spring MVC 的 @Controller 默认是单例还是多例?如果是单例,为什么我们可以在 @Controller 中定义成员变量(比如注入的 Service)而不担心线程安全问题?

参考与延伸阅读

  • Eclipse Foundation. Jakarta Servlet Specification, Version 6.0. Jakarta EE Specifications, 2022-05-12
  • Apache Tomcat. Servlet 6.0 API Documentation. Tomcat 10.1.x
  • Spring Framework. DispatcherServlet Documentation. Spring.io
  • Baeldung. Introduction to Java Servlets. Baeldung, 2024
  • Oracle. Java Servlet Technology. Java EE 8 Tutorial