03 · Servlet 基础(Servlet Basics)

📅 预计 45 分钟 | ⭐ = 高频考点 | 📌 中英术语见文末
✍️ 配套练习:网页 quiz shengxia.dev/java?module=03


3.1 Web 容器的职责(以 Tomcat 为例)

酒店前台:守门、登记、派单

把 Tomcat 想成一家酒店,Servlet 是酒店的客房服务人员,浏览器就是住店的客人。前台(容器)替你做四件事:守大门(监听端口)、接待登记(解析 HTTP)、安排人员(管理 Servlet 生命周期)、处理排队(并发)。客人只管提需求,不用自己去配钥匙、找楼层;客房服务也不用操心酒店的安保系统怎么运作。

Tomcat 的具体职责拆开讲:

  1. 监听端口:默认 8080 端口,所有发到这个端口的 TCP 连接都由它接收。想换端口就去改 conf/server.xml 里的 <Connector> 配置。
  2. 解析 HTTP:把浏览器发来的原始 HTTP 报文(就是第 02 讲那种纯文本)拆成 Java 对象。你写代码时拿到的直接是"已解析好的请求对象",不用自己拆字符串。
  3. 管理 Servlet 生命周期:Servlet 是你写的处理请求的 Java 类,什么时候 new、什么时候调方法、什么时候销毁,全由容器安排,你不用手动 new Servlet()
  4. 处理并发:多个用户同时访问,容器为每个请求分配一个线程。线程怎么建、怎么复用、怎么回收,容器全包了,你只管写处理逻辑。
// 伪代码:容器替你做的事,你在 Servlet 里完全不用关心
while (true) {
    Socket 连接 = 8080端口.accept();            // ① 容器监听端口
    String 原始HTTP = 读报文(连接);             // ② 容器读原始报文
    请求 req = HttpServletRequest.解析(原始HTTP); // ③ 容器解析成对象
    线程池.submit(() -> {
        找到对应Servlet().service(req, resp);    // ④ 容器调你的代码
    });
}

💡 记忆口诀:Tomcat 是前台,你写的是客房服务。前台管收信、派单、排队,你只管把活干漂亮。

⚠️ 常见错误

  1. 以为 Servlet 是自己启动的:很多人以为写了 Servlet 它就自动运行。其实它被容器回调,容器不启动、请求不到,你的代码一行都不会执行。为什么容易错?因为"被动回调"这个机制和"main 方法主动启动"完全不同。怎么避免?先启动 Tomcat,再在浏览器访问对应的 URL 路径,代码才会跑起来。
  2. 在容器外直接 new Servlet 当普通类用:Servlet 依赖容器的请求、响应对象,脱离容器单独 new 出来没有意义,也会因为缺少依赖而报错。怎么避免?把它当成"只被容器调用的组件"来理解。

3.2 为什么需要 Servlet

让只会算数的 Java 会"接客"

Java 本身是一套通用编程语言,它不知道 HTTP 是什么——不会监听端口、不会解析 GET /index.html、不会拆请求头。要让 Java 程序能处理 HTTP 请求,就得有人"教会"它。Servlet(服务端小程序) 就是 Java 世界里处理 HTTP 请求的标准组件:一个实现了 javax.servlet.Servlet 接口的 Java 类。

看一个最简单的 Servlet 骨架:

import javax.servlet.*;
import javax.servlet.http.*;
import java.io.*;

public class HelloServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws IOException {
        // 浏览器发 GET 请求时,这个方法会被容器调用
        resp.setContentType("text/html; charset=utf-8");
        resp.getWriter().write("<h1>你好,Servlet!</h1>");
    }
}

关键理解:你只写了"处理逻辑"(上面几行),剩下的"接客流程"(接请求、调方法、发响应)全由容器完成。这就是为什么 Servlet 常被叫做"服务器端的小组件"——它不主动运行,而是被容器回调。类比一下:你不是自己开门揽客,而是客人到了,前台(容器)敲你的门叫你出来接待。

对比 普通 Java 程序 Servlet
入口 main() 主动启动 service() 被容器回调
谁监听端口 自己写 容器(Tomcat)
谁解析 HTTP 自己写 容器
运行环境 任意 必须部署在 Web 容器里

💡 记忆口诀:Servlet = 被容器回调的 Java 类。它自己不跑,容器叫你才跑。没有容器,Servlet 什么都不是。


3.3 Servlet 生命周期 ⭐

员工入职、上班、离职

Servlet 生命周期像员工在公司的一段经历:入职培训(init)、每天上班干活(service)、离职交接(destroy)。容器全程管理,你只负责在对应的方法里写代码。三个阶段、两个时机,这张表是核心:

阶段 方法 调用时机 调用次数
初始化 init() 第一次被请求时(默认懒加载) 整个生命周期只 1 次
服务 service() 每次收到请求时 每次请求 1 次
销毁 destroy() 容器关闭 / 应用卸载时 整个生命周期只 1 次
public class LifeServlet extends HttpServlet {
    @Override
    public void init() {
        // 只执行一次:适合加载配置、连接数据库、初始化全局资源
        System.out.println("① 初始化:只做一次");
    }

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp)
            throws IOException {
        // 每次请求都执行:核心处理逻辑在这
        System.out.println("② 服务:每次请求来一次");
        resp.getWriter().write("生命周期示例");
    }

    @Override
    public void destroy() {
        // 只执行一次:适合关闭数据库连接、释放资源
        System.out.println("③ 销毁:只做一次");
    }
}

为什么 init 是懒加载:默认情况下,Servlet 不是在应用启动时就创建,而是第一次被请求时才创建。这样做的好处是省资源——如果一个 Servlet 几乎没人访问,就没必要提前创建占内存。缺点也很明显:第一个用户会等得稍久一点(因为要执行初始化)。如果希望应用启动时就初始化,可以用 @WebServlet(value="/hello", loadOnStartup=1) 或 web.xml 里的 <load-on-startup>

多线程并发调用 service

容器为每个请求分配一个线程,多个线程可能同时执行同一个 Servlet 的 service()——这意味着 Servlet 实例是多线程共享的。如果多个线程同时读写同一个成员变量,结果可能出错。

请求 A → 线程 1 ─┐
请求 B → 线程 2 ─┼→ 同一个 LifeServlet 实例.service()
请求 C → 线程 3 ─┘

这就带来线程安全问题

  • 成员变量要小心:能被多个线程同时读写,用时要加 synchronized 或用线程安全的数据结构,最好干脆不用。
  • 局部变量安全:方法里的局部变量是每个线程栈里各有一份,天然隔离,随便用。
// 反面教材:成员变量被多线程共享
public class BadServlet extends HttpServlet {
    private int count = 0;   // 多个线程同时 count++ 会丢数据!
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws IOException {
        count++;                             // 非原子操作,会丢更新
        resp.getWriter().write("第 " + count + " 次访问");
    }
}

两个线程同时读到 count=10,各自 +1 后都写回 11,实际访问了两次却只加了一次——这就是"丢数据"。这正是面试里常考的"Servlet 线程安全"陷阱。

再强调一点:同一个 Servlet 在容器中默认只有一个实例,不管多少用户同时访问,用的都是同一个对象。这和你平时"每次 new 一个类就有一个新对象"的直觉正好相反,也正是线程安全问题的根源。理解了"单实例多线程"这六个字,你就明白了为什么成员变量如此危险、局部变量如此安全。

💡 记忆口诀init 一次、service 每次、destroy 一次;单实例多线程,成员变量要当心,局部变量才安全。

⚠️ 常见错误

  1. 以为 init() 每次请求都执行:它整个生命周期只执行一次。为什么容易错?因为 service() 每次请求都跑,初学者容易把所有方法都当成"每次执行"。怎么避免?记住"初始化"本来就该只做一次,想每次请求都做点事就写在 service() 里。
  2. init() 里做耗时操作:默认第一次请求才调用 init(),如果在这里连数据库卡住,第一个用户会等很久。为什么?初始化挡住了第一次请求。怎么避免?用 loadOnStartup 让初始化提前到应用启动时做,或者把耗时操作异步化。
  3. Servlet 里用可变成员变量不加保护:多线程并发改同一个变量,计数会丢、数据会错。为什么?count++ 不是原子操作,线程切换时会互相覆盖。怎么避免?能用局部变量就用局部变量;必须共享就用 AtomicInteger 或加 synchronized
  4. destroy() 里继续写响应:销毁时容器正在收尾,此时再写响应或操作已释放的连接会抛异常。为什么?资源已经处于释放过程中。怎么避免?销毁方法只做"释放资源"这一件事,别干别的。

3.4 URL 映射:注解与 web.xml

给每个 Servlet 挂门牌号

浏览器访问 http://localhost:8080/应用名/hello,容器怎么知道该调哪个 Servlet?靠的是 URL 映射(URL mapping)——给每个 Servlet 挂一个"门牌号",让访问路径对应到具体类。有两种配置方式:

方式一:注解(推荐,现代写法)

import javax.servlet.annotation.WebServlet;

@WebServlet("/hello")     // 注解:访问 /hello 就进这个 Servlet
public class HelloServlet extends HttpServlet {
    // ...处理逻辑
}

方式二:web.xml 配置文件(传统写法)

<web-app>
    <!-- 先给 Servlet 起个别名,再映射 URL -->
    <servlet>
        <servlet-name>hello</servlet-name>
        <servlet-class>com.example.HelloServlet</servlet-class>
    </servlet>
    <servlet-mapping>
        <servlet-name>hello</servlet-name>
        <url-pattern>/hello</url-pattern>
    </servlet-mapping>
</web-app>
对比 注解 @WebServlet web.xml
位置 写在类上 独立 XML 文件
可读性 直观,和类在一起 映射集中管理
修改成本 改代码要重新编译 改配置可能不用重新编译
风格 现代、推荐 传统项目常见

映射规则的细节/hello 是精确匹配,只有完全相等的路径才会命中。还可以用通配符,比如 /* 匹配所有路径、/user/* 匹配 /user/ 下所有路径。精确匹配优先于通配符,这个优先级规则在复杂项目里排错很有用。

💡 记忆口诀:URL 映射 = 门牌号。注解是"门上贴标签",web.xml 是"前台登记表",二选一即可,别重复映射同一路径。

⚠️ 常见错误

  1. 注解和 web.xml 同时配置同一个路径:两边都写了 /hello,容器不知道听谁的,可能启动报错或行为不可预测。为什么?重复映射造成冲突。怎么避免?统一用一种方式,别混用。
  2. 路径少写开头的斜杠:写 @WebServlet("hello")(没有 /)而不是 @WebServlet("/hello")。为什么?url-pattern 必须以 / 开头才是有效路径。怎么避免?写映射时习惯性地先打 /
  3. 改映射不重启:改了注解里的路径,但没重新编译部署,访问的还是旧路径。为什么?类和配置都只在启动时读一次。怎么避免?改完配置一定要重新部署或热重启。

3.5 HttpServletRequest 与 HttpServletResponse ⭐

顾客的订单 + 你的回执

HttpServletRequest 是"顾客的订单"——浏览器带来的所有信息(参数、头、方法、IP)都在里面;HttpServletResponse 是"你的回执"——你想给顾客看的、想设置的格式、想写的状态码都写在里面。一个负责"读进来",一个负责"写出去",分工非常清楚。

HttpServletRequest 常用方法

protected void doGet(HttpServletRequest req, HttpServletResponse resp)
        throws IOException {
    // 拿查询参数:?name=tom&age=20
    String name = req.getParameter("name");     // → "tom"
    String age  = req.getParameter("age");      // → "20"

    // 拿头部、方法和路径
    String ua     = req.getHeader("User-Agent"); // 浏览器类型
    String method = req.getMethod();             // → "GET"
    String uri    = req.getRequestURI();         // → "/hello"
    String ip     = req.getRemoteAddr();         // 客户端 IP
}
方法 作用 返回
getParameter("name") 拿 URL 或表单里的参数 String(没有则 null
getHeader("User-Agent") 拿指定请求头 String
getMethod() 拿请求方法(GET/POST…) String
getRequestURI() 拿请求的路径部分 String

getParameter 有两个来源:GET 请求时它读 URL 的查询字符串,POST 请求时它读请求体里的表单字段。两种来源用同一个方法取,对开发很方便,但要注意编码问题——GET 参数按 URL 编码解码,POST 参数按请求体 Content-Type 声明的编码解码,两边不一致就容易乱码。另外,同一个参数名可以出现多次(如 ?tag=a&tag=b),这时用 getParameterValues("tag") 拿到的才是全部值数组。

HttpServletResponse 常用方法

protected void doGet(HttpServletRequest req, HttpServletResponse resp)
        throws IOException {
    // ① 先设置响应类型和编码(顺序很重要!)
    resp.setContentType("text/html; charset=utf-8");
    // ② 设置状态码(默认 200,一般不用写)
    resp.setStatus(HttpServletResponse.SC_OK);   // SC_OK = 200
    // ③ 拿到输出流,把内容写给浏览器
    resp.getWriter().write("<h1>这是响应内容</h1>");
}
方法 作用
setContentType("text/html; charset=utf-8") 设置响应类型和编码,中文不乱码的关键
setStatus(200) 设置状态码
getWriter() 拿字符输出流,写文本 / HTML 用它
sendRedirect("/login") 发 302 重定向,让浏览器跳转

getWriter()getOutputStream() 的区别getWriter() 返回字符流,适合写文本、HTML;getOutputStream() 返回字节流,适合写图片、文件、下载内容。两者只能选一个用,混用会抛异常——因为响应体只能有一种写法。

完整示例:接收 GET 参数并返回 HTML

把前面的内容串起来,写一个完整的 Servlet——接收 ?name=tom 参数,返回一段带名字的 HTML:

import javax.servlet.*;
import javax.servlet.http.*;
import javax.servlet.annotation.*;
import java.io.*;

@WebServlet("/greet")                      // 映射:/greet
public class GreetServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws IOException {
        String name = req.getParameter("name");   // 拿参数,如 ?name=tom
        if (name == null) {
            name = "陌生人";                       // 没传参数就给个默认值
        }
        resp.setContentType("text/html; charset=utf-8");  // 先设编码
        resp.getWriter().write(                       // 再写 HTML
            "<h1>你好," + name + "!</h1>" +
            "<p>这是 Servlet 动态生成的页面。</p>"
        );
    }
}

访问 http://localhost:8080/应用名/greet?name=国睿,浏览器显示"你好,国睿!"。注意这里 HTML 是程序拼出来的,每次请求参数不同,返回内容就不同——这正是"动态网页"的含义。

💡 记忆口诀:请求对象是"进来的订单",响应对象是"出去的回执";拿到参数先判空,写响应前先设编码。

⚠️ 常见错误

  1. 中文乱码:只写了 setContentType("text/html") 没写 charset=utf-8,浏览器用默认编码解析,中文全变问号。为什么?响应头没有声明编码,浏览器只能猜。怎么避免?统一写 setContentType("text/html; charset=utf-8")
  2. 参数判空:URL 没传 namegetParameter 返回 null,直接 name.length() 会空指针。为什么?参数不存在就是 null。怎么避免?先判断 name == null 再使用,或给默认值。
  3. 顺序写反:先 getWriter().write()setContentType(),编码设置不生效。为什么?输出流一旦开始写,头部就定型了。怎么避免?严格按"先设类型 → 再拿流 → 再写内容"的顺序。
  4. getParameter 拿不到 POST 参数:POST 表单参数也在 getParameter 里,但要注意编码问题。为什么?POST 参数的解码依赖请求体编码,没设对就会乱码或丢失。怎么避免?表单提交时 Content-Typecharset=utf-8,或在过滤器里统一设置。
  5. 混用 getWriter()getOutputStream():两者同时调用会抛 IllegalStateException。为什么?响应体只能由一种流输出。怎么避免?根据内容类型选一个:文本用 getWriter(),二进制用 getOutputStream()

3.6 部署到 Tomcat 的基本流程

搬家入住:打包 → 放进 webapps → 开机

写完 Servlet,要让它"住进" Tomcat 才能被访问。部署流程四步走:

  1. 编译打包:把 .java 编译成 .class,按包结构放好(或用 IDE / 构建工具打成 .war 包)。
  2. 放进部署目录:把编译好的目录或 .war 包放到 Tomcat 的 webapps 目录下。
  3. 启动 Tomcat:运行 bin/startup.bat(Windows)或 bin/startup.sh(Linux)。
  4. 访问验证:浏览器打开 http://localhost:8080/应用名/路径
webapps/                         ← 部署目录
└── myapp/                       ← 应用目录(应用名 = myapp)
    ├── WEB-INF/                 ← 配置文件目录
    │   ├── web.xml              ← 部署描述文件(传统映射写这里)
    │   └── classes/             ← 编译后的 .class 放这里
    │       └── GreetServlet.class
    └── index.html               ← 静态页面放应用根下

关键公式:访问路径 = http://localhost:8080/ + 应用名 + URL 映射。所以 GreetServlet 映射到 /greet,应用叫 myapp,完整地址就是 http://localhost:8080/myapp/greet。应用名就是 webapps 下的目录名,改目录名就等于改访问前缀。

💡 记忆口诀:部署 = 搬家。.war 是行李箱,webapps 是公寓楼,启动 Tomcat = 开门营业,路径 = 楼号 + 门牌号。

⚠️ 常见错误

  1. URL 映射不匹配:访问路径和 @WebServlet 里的值不一致,报 404。为什么?容器按映射找类,映射对不上就找不到。怎么避免?从应用名、路径、大小写三个地方逐项核对。
  2. 端口占用:启动报 Port 8080 was already in use。为什么?每个端口同时只能一个程序监听。怎么避免?用 netstat -ano | findstr 8080 查占用进程并结束,或改 conf/server.xml 的端口。
  3. 中文乱码:请求参数乱码可能是容器没按 UTF-8 解码;响应乱码是没设 charset=utf-8。为什么?编码不一致,两端各按各的解。怎么避免?请求响应两侧都统一 UTF-8,必要时配置 URI 编码。
  4. 改了代码没重新部署:只改 .java 没重新编译,或 .class 没覆盖到 webapps 下,访问的还是旧版本。为什么?Tomcat 加载的是 webapps 里的 .class,不是你的源码。怎么避免?编译后确认 .class 更新了,再重启或热部署。

📌 双语术语表(本讲)

中文 English 记忆点
Web 容器 Web Container Servlet 的"房东"
服务端小程序 Servlet 处理 HTTP 的 Java 组件
生命周期 Lifecycle init → service → destroy
初始化 init() 只执行一次,加载资源
服务 service() 每次请求都执行
销毁 destroy() 只执行一次,释放资源
URL 映射 URL Mapping 给 Servlet 挂门牌号
注解 Annotation @WebServlet("/路径")
部署描述符 web.xml 传统配置文件
请求对象 HttpServletRequest 顾客的订单
响应对象 HttpServletResponse 你的回执
内容类型 Content-Type 类型 + 编码,防乱码
字符集 Charset UTF-8 世界通用
部署 Deploy 把应用放进 webapps
线程安全 Thread Safe 多线程共享变量要小心

⭐ 本讲考点清单

  1. Tomcat 四职责:监听端口、解析 HTTP、管理 Servlet 生命周期、处理并发。
  2. Servlet 是被容器回调的 Java 类,自己不主动运行,脱离容器没有意义。
  3. 生命周期三阶段:init() 只 1 次(懒加载)、service() 每次请求 1 次、destroy() 只 1 次。
  4. Servlet 单实例多线程:多个线程共享同一实例,成员变量要小心、局部变量安全。
  5. URL 映射两种方式:@WebServlet("/hello") 注解 与 web.xml 配置,二选一。
  6. HttpServletRequest 常用:getParameter / getHeader / getMethod / getRequestURI
  7. HttpServletResponse 常用:setContentType / setStatus / getWriter / sendRedirect
  8. 中文乱码解法:先 setContentType("text/html; charset=utf-8") 再拿流写内容。
  9. 部署流程:编译打包 → 放进 webapps → 启动 Tomcat → 按"应用名 + 映射路径"访问。
  10. 访问路径 = http://localhost:8080/应用名/URL映射,映射不匹配、端口占用是高频故障。
  11. getParameter 没传参数时返回 null,用前先判空防空指针。
  12. 多线程下成员变量 count++ 会丢数据,用局部变量或加锁解决。