JV-03 Servlet基础
03 · Servlet 基础(Servlet Basics)
📅 预计 45 分钟 | ⭐ = 高频考点 | 📌 中英术语见文末
✍️ 配套练习:网页 quizshengxia.dev/java?module=03
3.1 Web 容器的职责(以 Tomcat 为例)
酒店前台:守门、登记、派单
把 Tomcat 想成一家酒店,Servlet 是酒店的客房服务人员,浏览器就是住店的客人。前台(容器)替你做四件事:守大门(监听端口)、接待登记(解析 HTTP)、安排人员(管理 Servlet 生命周期)、处理排队(并发)。客人只管提需求,不用自己去配钥匙、找楼层;客房服务也不用操心酒店的安保系统怎么运作。
Tomcat 的具体职责拆开讲:
- 监听端口:默认 8080 端口,所有发到这个端口的 TCP 连接都由它接收。想换端口就去改
conf/server.xml里的<Connector>配置。 - 解析 HTTP:把浏览器发来的原始 HTTP 报文(就是第 02 讲那种纯文本)拆成 Java 对象。你写代码时拿到的直接是"已解析好的请求对象",不用自己拆字符串。
- 管理 Servlet 生命周期:Servlet 是你写的处理请求的 Java 类,什么时候 new、什么时候调方法、什么时候销毁,全由容器安排,你不用手动
new Servlet()。 - 处理并发:多个用户同时访问,容器为每个请求分配一个线程。线程怎么建、怎么复用、怎么回收,容器全包了,你只管写处理逻辑。
// 伪代码:容器替你做的事,你在 Servlet 里完全不用关心
while (true) {
Socket 连接 = 8080端口.accept(); // ① 容器监听端口
String 原始HTTP = 读报文(连接); // ② 容器读原始报文
请求 req = HttpServletRequest.解析(原始HTTP); // ③ 容器解析成对象
线程池.submit(() -> {
找到对应Servlet().service(req, resp); // ④ 容器调你的代码
});
}
💡 记忆口诀:Tomcat 是前台,你写的是客房服务。前台管收信、派单、排队,你只管把活干漂亮。
⚠️ 常见错误
- 以为 Servlet 是自己启动的:很多人以为写了 Servlet 它就自动运行。其实它被容器回调,容器不启动、请求不到,你的代码一行都不会执行。为什么容易错?因为"被动回调"这个机制和"main 方法主动启动"完全不同。怎么避免?先启动 Tomcat,再在浏览器访问对应的 URL 路径,代码才会跑起来。
- 在容器外直接
newServlet 当普通类用: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一次;单实例多线程,成员变量要当心,局部变量才安全。
⚠️ 常见错误
- 以为
init()每次请求都执行:它整个生命周期只执行一次。为什么容易错?因为service()每次请求都跑,初学者容易把所有方法都当成"每次执行"。怎么避免?记住"初始化"本来就该只做一次,想每次请求都做点事就写在service()里。 - 在
init()里做耗时操作:默认第一次请求才调用init(),如果在这里连数据库卡住,第一个用户会等很久。为什么?初始化挡住了第一次请求。怎么避免?用loadOnStartup让初始化提前到应用启动时做,或者把耗时操作异步化。 - Servlet 里用可变成员变量不加保护:多线程并发改同一个变量,计数会丢、数据会错。为什么?
count++不是原子操作,线程切换时会互相覆盖。怎么避免?能用局部变量就用局部变量;必须共享就用AtomicInteger或加synchronized。 - 在
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 是"前台登记表",二选一即可,别重复映射同一路径。
⚠️ 常见错误
- 注解和 web.xml 同时配置同一个路径:两边都写了
/hello,容器不知道听谁的,可能启动报错或行为不可预测。为什么?重复映射造成冲突。怎么避免?统一用一种方式,别混用。 - 路径少写开头的斜杠:写
@WebServlet("hello")(没有/)而不是@WebServlet("/hello")。为什么?url-pattern必须以/开头才是有效路径。怎么避免?写映射时习惯性地先打/。 - 改映射不重启:改了注解里的路径,但没重新编译部署,访问的还是旧路径。为什么?类和配置都只在启动时读一次。怎么避免?改完配置一定要重新部署或热重启。
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 是程序拼出来的,每次请求参数不同,返回内容就不同——这正是"动态网页"的含义。
💡 记忆口诀:请求对象是"进来的订单",响应对象是"出去的回执";拿到参数先判空,写响应前先设编码。
⚠️ 常见错误
- 中文乱码:只写了
setContentType("text/html")没写charset=utf-8,浏览器用默认编码解析,中文全变问号。为什么?响应头没有声明编码,浏览器只能猜。怎么避免?统一写setContentType("text/html; charset=utf-8")。 - 参数判空:URL 没传
name时getParameter返回null,直接name.length()会空指针。为什么?参数不存在就是null。怎么避免?先判断name == null再使用,或给默认值。 - 顺序写反:先
getWriter().write()再setContentType(),编码设置不生效。为什么?输出流一旦开始写,头部就定型了。怎么避免?严格按"先设类型 → 再拿流 → 再写内容"的顺序。 getParameter拿不到 POST 参数:POST 表单参数也在getParameter里,但要注意编码问题。为什么?POST 参数的解码依赖请求体编码,没设对就会乱码或丢失。怎么避免?表单提交时Content-Type带charset=utf-8,或在过滤器里统一设置。- 混用
getWriter()和getOutputStream():两者同时调用会抛IllegalStateException。为什么?响应体只能由一种流输出。怎么避免?根据内容类型选一个:文本用getWriter(),二进制用getOutputStream()。
3.6 部署到 Tomcat 的基本流程
搬家入住:打包 → 放进 webapps → 开机
写完 Servlet,要让它"住进" Tomcat 才能被访问。部署流程四步走:
- 编译打包:把
.java编译成.class,按包结构放好(或用 IDE / 构建工具打成.war包)。 - 放进部署目录:把编译好的目录或
.war包放到 Tomcat 的webapps目录下。 - 启动 Tomcat:运行
bin/startup.bat(Windows)或bin/startup.sh(Linux)。 - 访问验证:浏览器打开
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 = 开门营业,路径 = 楼号 + 门牌号。
⚠️ 常见错误
- URL 映射不匹配:访问路径和
@WebServlet里的值不一致,报 404。为什么?容器按映射找类,映射对不上就找不到。怎么避免?从应用名、路径、大小写三个地方逐项核对。 - 端口占用:启动报
Port 8080 was already in use。为什么?每个端口同时只能一个程序监听。怎么避免?用netstat -ano | findstr 8080查占用进程并结束,或改conf/server.xml的端口。 - 中文乱码:请求参数乱码可能是容器没按 UTF-8 解码;响应乱码是没设
charset=utf-8。为什么?编码不一致,两端各按各的解。怎么避免?请求响应两侧都统一 UTF-8,必要时配置 URI 编码。 - 改了代码没重新部署:只改
.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 | 多线程共享变量要小心 |
⭐ 本讲考点清单
- Tomcat 四职责:监听端口、解析 HTTP、管理 Servlet 生命周期、处理并发。
- Servlet 是被容器回调的 Java 类,自己不主动运行,脱离容器没有意义。
- 生命周期三阶段:
init()只 1 次(懒加载)、service()每次请求 1 次、destroy()只 1 次。 - Servlet 单实例多线程:多个线程共享同一实例,成员变量要小心、局部变量安全。
- URL 映射两种方式:
@WebServlet("/hello")注解 与 web.xml 配置,二选一。 HttpServletRequest常用:getParameter/getHeader/getMethod/getRequestURI。HttpServletResponse常用:setContentType/setStatus/getWriter/sendRedirect。- 中文乱码解法:先
setContentType("text/html; charset=utf-8")再拿流写内容。 - 部署流程:编译打包 → 放进
webapps→ 启动 Tomcat → 按"应用名 + 映射路径"访问。 - 访问路径 =
http://localhost:8080/应用名/URL映射,映射不匹配、端口占用是高频故障。 getParameter没传参数时返回null,用前先判空防空指针。- 多线程下成员变量
count++会丢数据,用局部变量或加锁解决。