Servlet 的生命周期是什么?Servlet 是线程安全的吗?
简化版
Servlet 的生命周期由容器管理,分四步:加载实例化 → init() 初始化(只调用一次)→ 并发调用 service() 处理请求(多次)→ destroy() 销毁(只调用一次)。Servlet 默认是「单实例多线程」——容器为一个 Servlet 只创建一个实例,多个请求由线程池的不同线程并发调用同一个实例。所以 Servlet 不是线程安全的:如果把请求级的可变状态(如当前用户、请求参数)存到成员变量,会被并发请求互相覆盖。局部变量(在线程栈上)是安全的。
详细版
Servlet 生命周期四阶段:
| 阶段 | 方法 | 次数 | 说明 |
|---|---|---|---|
| 实例化 | 构造方法 | 1 次 | 容器创建实例(默认懒加载,首次访问时;可配 load-on-startup 启动时创建) |
| 初始化 | init() | 1 次 | 读配置、准备共享资源 |
| 服务 | service() → doGet/doPost | 多次 | 每个请求调用一次,多线程并发 |
| 销毁 | destroy() | 1 次 | 应用卸载/关闭时,释放资源 |
线程安全的核心——单实例多线程:
public class BadServlet extends HttpServlet {
private String username; // ❌ 成员变量!多线程共享,会串号
protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
username = req.getParameter("username"); // 线程 A 刚设置
// ... 线程 B 可能在这里把 username 改了
resp.getWriter().write(username); // A 输出的可能是 B 的值!
}
}
public class GoodServlet extends HttpServlet {
protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
String username = req.getParameter("username"); // ✅ 局部变量,线程私有
resp.getWriter().write(username);
}
}
什么安全、什么不安全:
- ✅ 局部变量(方法内,线程栈私有)、不可变对象、无状态的组件、线程安全集合。
- ❌ 成员变量存请求级可变状态、静态可变变量、非线程安全工具类作为字段共享(如
SimpleDateFormat)。
完整版教学
一、生命周期由容器管理,不要自己 new
Servlet 是运行在容器(Tomcat)里的组件,它的创建、初始化、调用、销毁全部由容器负责,业务代码不应该手动 new Servlet()。容器通过部署描述(web.xml)或注解(@WebServlet)发现 Servlet,然后:
- 实例化:容器创建 Servlet 实例。默认是懒加载(第一次被访问时才创建),也可以用
load-on-startup配置成启动时就创建(适合初始化重、希望提前暴露问题的 Servlet)。 init():实例化后立即调用一次,用于初始化——读配置参数、准备共享资源(如连接池、模板引擎)。service():每个请求调用一次,内部根据 HTTP 方法分发到doGet/doPost等。这是唯一会被多次、并发调用的方法。destroy():应用卸载或容器关闭时调用一次,用于释放资源。
开发者只关注这些回调逻辑,不控制对象的创建销毁时机。
二、核心:单实例多线程模型
这是理解 Servlet 线程安全的关键。 容器对一个 Servlet 默认只创建一个实例,然后:
- 多个客户端请求到来时,容器从线程池取不同的 Worker 线程,并发地调用同一个 Servlet 实例的
service()方法。 - 也就是说,同一个 Servlet 对象被多个线程同时执行。
为什么这样设计?减少对象创建开销——不用每个请求都 new 一个 Servlet。但代价是:这个共享的 Servlet 实例,它的成员变量(字段)就成了多线程共享的临界区——多个线程同时读写,就有线程安全问题。
| 位置 | 是否共享 | 是否适合保存请求数据 | 原因 |
|---|---|---|---|
| 方法局部变量 | 否,每个线程栈帧独立 | 适合 | 每个请求线程都有自己的局部变量副本 |
| Servlet 成员变量 | 是,同一个 Servlet 实例共享 | 不适合 | 多个请求会并发读写同一个字段 |
static 变量 | 是,应用级甚至 JVM 级共享 | 更不适合 | 影响范围更大,跨实例共享 |
| 不可变对象字段 | 是,但不可修改 | 可以 | 没有并发写入,不会串号 |
记忆钩子:Servlet 只有一个对象,但有很多线程同时进它的方法;方法栈是各走各的,字段是大家共用的。
三、哪些状态安全、哪些不安全
安全的:
- 方法内的局部变量:每个线程有自己的栈帧,局部变量在各自栈上,线程私有、天然安全。这是最推荐的做法——请求相关的数据用局部变量。
- 不可变对象(final、String 等):不能被修改,共享也安全。
- 无状态的对象:本身不保存可变状态(如无状态的 Service)。
- 线程安全的组件:如
ConcurrentHashMap、原子类。
不安全的:
- 成员变量保存请求级可变状态:把当前用户、请求参数、临时计算结果存到 Servlet 字段——多个请求会互相覆盖。
- 静态可变变量:全局共享,更危险。
- 非线程安全工具类当字段共享:典型是
SimpleDateFormat(它内部有可变状态,多线程共用会解析出错乱结果)、StringBuilder等。
四、典型的线程安全 bug(隐蔽且难复现)
最经典的错误是把请求数据放到成员变量:
private String username; // 字段
doGet() {
username = req.getParameter("username"); // 线程 A 设为 "Alice"
// 时间片切换,线程 B 进来把 username 设为 "Bob"
doSomething();
resp.write(username); // 线程 A 这里输出的却是 "Bob"!—— 串号
}
这种 bug 的可怕之处:低并发下几乎不出现,单机手动测试根本测不出来,只在线上高并发或压测时偶发——用户看到别人的信息、响应内容错乱、数据串号。排查极难。所以从写代码时就要杜绝:请求级状态一律用局部变量,绝不放字段。
SimpleDateFormat 是另一个高频坑——它不是线程安全的,作为 Servlet/Spring Bean 的字段被多线程共用会解析出错误日期。解决:用局部变量、ThreadLocal、或换 java.time.DateTimeFormatter(线程安全)。
T1(Alice): username = "Alice"
T1(Alice): 切出,尚未 write
T2(Bob) : username = "Bob"
T2(Bob) : write("Bob")
T1(Alice): 切回,write(username) -> 读到 "Bob"
这个时间线里没有任何 Tomcat bug,问题完全来自共享字段。假设 1000 次请求里只有 1 次刚好在两行代码之间切换,单测也很难稳定复现,但线上并发越高,概率越大。
五、和 Spring Bean 线程安全的一致性
这个原则可以直接迁移到 Spring:Spring MVC 的 @Controller、@Service 默认也是单例(singleton),多线程共享——和 Servlet 单实例多线程一模一样。所以:
- Controller / Service 应尽量无状态:不要在字段里存请求级可变数据。
- 请求状态放方法参数、局部变量里。
- 如果确实需要「每个请求一个实例」,可以把 Bean 的 scope 改成
prototype或request(但一般不推荐,无状态设计更好)。
一句话:Servlet 单例多线程 → Controller 单例多线程,线程安全的关键都是「不要用共享的可变字段存请求状态」。
六、init 和 destroy 适合做什么
init()适合:读取配置、初始化共享且线程安全的资源(连接池、缓存、模板引擎)。注意不要在 init 里做过长的阻塞操作(会拖慢启动),也不要把请求相关数据初始化成全局状态。destroy()适合:释放资源——关闭连接池、停止后台线程、清理 ThreadLocal(防止前面讲的类加载器泄漏)。
七、排查线程安全问题的思路
遇到偶发的串号、用户信息错乱、响应内容互相污染、日期解析乱等现象:
- 优先怀疑共享的可变状态——检查 Servlet/Controller 的成员变量、静态变量里有没有存请求级数据。
- 检查非线程安全工具类是否被当字段共享(
SimpleDateFormat是重灾区)。 - 记住:线程安全问题的根源不是 Servlet API 本身,而是「共享可变状态」。把状态改成局部变量或线程安全组件,问题就解决了。
八、常见误区与追问
- 误区:Servlet 每个请求都会创建一个新对象。 默认不是,容器通常为一个 Servlet 创建单实例,多请求由线程池并发调用同一个实例。
- 误区:成员变量加
private就线程安全。private只限制访问范围,不改变多个线程共享同一字段的事实。 - 追问:为什么局部变量是安全的? 局部变量存在各线程自己的栈帧中,请求之间没有共享同一个变量槽位。
- 追问:
init()里初始化连接池可以吗? 可以初始化共享且线程安全的资源,但不能把请求级状态放进去,也要避免过长阻塞拖慢启动。 - 误区:
SimpleDateFormat作为字段复用能提升性能且没风险。 它内部有可变状态,多线程共用会错乱,应该用DateTimeFormatter或每次创建/ThreadLocal 并清理。 - 追问:Spring Controller 为什么也要无状态? Controller 默认是单例 Bean,多线程并发访问,和 Servlet 单实例多线程模型的风险一致。
九、加强记忆
Servlet 生命周期由容器管理:实例化 → init()(1 次,初始化共享资源)→ service()(多次、多线程并发,分发到 doGet/doPost)→ destroy()(1 次,释放资源),业务代码别自己 new。核心是**「单实例多线程」——容器只创建一个 Servlet 实例**,多请求用不同线程并发调用同一实例,所以 Servlet 不是线程安全的:成员变量/静态变量存请求级可变状态会被并发覆盖(串号),局部变量在线程栈上才安全。典型坑:字段存请求数据、SimpleDateFormat 当字段共享——低并发测不出、高并发才暴露。原则同 Spring(@Controller/@Service 默认单例多线程):无状态、请求状态放局部变量。排查串号:先查共享可变字段。