☰
搞懂Servlet容器:从原理到Spring Boot实战与坑点全记录
2026/10/2 22:15:22 网站建设 项目流程

搞懂 Servlet 容器这一篇就够:原理、Spring Boot 实战和坑点全记录

我先说个结论:绝大多数 Java Web 开发者每天打交道的东西里,被误解最深的其实不是 Spring、不是 MyBatis,而是那个默默蹲在底层干活的老哥——Servlet 容器。你点一个按钮、发一个请求、打开一个页面,背后全是它在调度。但很少有人认真问一句:它到底是什么?为什么 Spring Boot 一跑起来它也跟着起来了?为啥有时候接口明明没问题,运行一段时间就假死?这些问题的根源,十有八九都落在 Servlet 容器的理解和配置上。

这篇文章面向两类人:一是刚接触 Java Web、搞不清楚 Tomcat 和 Spring Boot 关系的初学者;二是已经写了一段业务代码、但遇到容器相关疑难杂症时只能来回搜资料的开发者。我会先讲清楚 Servlet 容器的底层原理,再带你把 Spring Boot 内嵌容器的配置玩明白,最后整理一份我自己这几年踩过的坑和排查思路,尽量帮你绕开那些“明明能跑却不知道为什么”的玄学时刻。

1. Servlet 容器到底是什么:先拆开“Servlet”和“容器”两个词

1.1 从 Servlet 说起:Java Web 领域的老黄牛

在很多人的认知里,项目一启动就是 Spring Boot 在干活,甚至有人觉得“JavaWeb = Spring MVC”。但往前倒十年,当时没有 Spring Boot,甚至连 Spring MVC 都还没成气候,大家写接口靠的是 JSP + Servlet,而 Servlet 就是 Java 处理 HTTP 请求的“根”。

Servlet 本质上是一组接口规范,由 Java EE(后来改叫 Jakarta EE)定义。它规定了一个 Java 类要具备哪些能力才能接收一个 HTTP 请求并返回响应。最核心的接口就叫Servlet,里面定义了init()、service()、doGet()、doPost()、destroy()这几个生命周期方法。你写的每一个 Controller 方法,最终被框架翻译成对 Servlet 的调用;你注册的每一个 Filter、每一个 Listener,本质上也是在 Servlet 规范之上做文章。

但这里有个关键点:Servlet 只是接口和规范,它本身跑不起来。规范是一张图纸,光有图纸盖不了楼,你得有一个环境让它真正运行。这个环境,就是 Servlet 容器。

1.2 容器:Servlet 的宿舍楼和物业公司

如果把 Servlet 比作一个“干活的人”,那 Servlet 容器就是这人的宿舍楼加物业公司。宿舍楼负责给人提供房间(创建实例)、配置水电(初始化参数)、安排门禁(请求路由);物业公司负责定期检查房屋安全(生命周期管理)、协调公共资源(线程池、连接池、内存分配)。

更准确地说,Servlet 容器负责的事情有四件:

  1. 生命周期管理:什么时候创建 Servlet 实例、什么时候调用init()、什么时候回收,全由容器说了算。你写的@PostConstruct、@PreDestroy方法,其实都是在容器的生命周期回调链里挂着的。
  2. 网络通信:容器负责监听端口、接收 TCP 连接、解析 HTTP 请求报文,再把请求封装成一个HttpServletRequest对象,把响应封装成HttpServletResponse。这也是为什么你写 Controller 时根本不用关心 Socket、不用手动解析 HTTP 报文——脏活全被容器干了。
  3. 多线程处理:每个请求通常在独立的线程中执行,而这个线程怎么来、怎么回收、怎么排队,也由容器决定。我们经常讨论的“线程池耗尽”问题,说的就是容器这层出了问题。
  4. 请求路由:根据 URL 和 Servlet 的映射关系找到对应的处理逻辑。在 Spring MVC 里,所有请求先进DispatcherServlet,再由它派发给各个@RequestMapping方法,但“把请求分发到 DispatcherServlet”这件事本身,还是 Servlet 容器干的。

1.3 区分三个“容器”:Servlet 容器、Spring 容器、容器化里的容器

热词里“容器”出现了很多次,而且指向还不一样,这点必须单独拎出来讲清楚,不然很多人会懵。

  • Servlet 容器:本文的主角,典型代表是 Tomcat、Jetty、Undertow,负责运行 Servlet 规范下的 Web 应用。
  • Spring 容器 / IoC 容器:负责管理 Spring Bean 的创建、依赖注入、销毁。它运行在 Servlet 容器内部,两者是嵌套关系。Spring Boot 启动时,先是 Servlet 容器被初始化,然后 Spring 容器被创建并装配,最后DispatcherServlet被注册进 Servlet 容器。
  • Docker 容器:操作系统层面的进程隔离技术,核心是 cgroup 和 namespace。它和前面两个完全不是一个层级的东西——你完全可以把一个跑着 Tomcat 的 JVM 进程再塞进 Docker 容器里。搜索词里的“容器资源隔离”“容器 centos 启动 sshd 失败”“宝塔内某个容器让他使用宿主机的网络环境”,说的都是这一层的东西。

这三者容易混,是因为中文都叫“容器”,但逻辑层次完全不同。你写 Java Web 时,主要面对的是前两个;你搞运维和部署时,面对的是第三个。后面我会专门讲一下 Servlet 容器和 Docker 容器叠加使用时的一些资源分配陷阱。

2. 主流 Servlet 容器选型对比:Tomcat、Jetty、Undertow 各自的脾气

2.1 三足鼎立的格局

市面上符合 Servlet 规范的容器不少,但真正在 Java 后端圈子形成广泛应用的,主要是这三家:Tomcat、Jetty、Undertow。

Tomcat 是资历最老的,由 Apache 软件基金会维护。它从 1999 年走到今天,稳定性经过海量项目验证,文档最多,遇到问题搜到的解决方案也最多。它的结构相对“重”,线程模型是经典的 BIO/NIO 混合,默认配置下功能全、线程参数多,对新手来说配置文件可能稍显得复杂。

Jetty 出身 Eclipse 基金会,设计哲学是“轻、快、嵌入友好”。它特别适合那些需要在一个 JVM 进程里动态启停 Web 服务的场景,比如大数据领域的 Hadoop、Spark 生态里就大量使用 Jetty。启动速度快,内存占用小,但在极端高并发下的绝对吞吐能力一般比 Tomcat 要弱一些。

Undertow 是红帽(Red Hat)主导开发的项目,主打高性能非阻塞。它基于 JBoss 的 XNIO,全异步 I/O,在静态资源服务和低延迟场景下表现相当亮眼。WildFly 应用服务器默认使用 Undertow,很多重视性能的新项目也喜欢把它塞进 Spring Boot 里替换默认容器。

2.2 Spring Boot 为什么默认选 Tomcat

很多人问过:Spring Boot 默认内嵌容器是 Tomcat,是不是因为它性能最好?还真不完全是。Spring Boot 选 Tomcat 做默认,最重要的原因是生态和兼容性。

Tomcat 是最接近“标准实现”的 Servlet 容器,绝大多数 Java Web 项目的生产环境原本就跑在 Tomcat 上。Spring Boot 横空出世的时候,它的目标之一是让老项目平滑迁移,默认选择 Tomcat 能最大程度降低迁移成本。再加上 Tomcat 对 Servlet 规范的实现最保守、最完整,Spring Boot 团队只需要针对它做深度适配和自动化配置,就能覆盖绝大多数使用场景。

如果你不刻意改动,Spring Boot 启动时会在spring-boot-starter-web里把tomcat-embed-core之类的一系列内嵌 Tomcat 依赖拉进来,然后在ServletWebServerApplicationContext里自动创建TomcatServletWebServer。这个过程中,你基本看不到任何配置文件,端口、协议、线程池全都有默认值。

2.3 实战:把 Tomcat 换成 Undertow

如果你的项目对性能和内存占用比较敏感,想从 Tomcat 切到 Undertow,其实非常简单,只需要在 Maven 依赖里做点手脚。

第一件事,排除掉spring-boot-starter-web里的 Tomcat 依赖。第二件事,引入 Undertow 的 starter。第三件事,把原来的server.tomcat.*配置改成server.undertow.*。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <exclusions> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-undertow</artifactId> </dependency>

配置上,比较关键的有几个:

server: port: 8080 undertow: io-threads: 4 worker-threads: 32 direct-buffers: true
  • io-threads:处理 I/O 事件的线程数,一般按 CPU 核数乘以 2 设置。
  • worker-threads:处理业务逻辑的线程数,相当于 Tomcat 里的max-threads。
  • direct-buffers:是否使用堆外内存做缓冲区。开启后能减少垃圾回收压力,但如果系统物理内存紧张,反而容易引发 OutOfMemory。

实测下来,纯静态文件或简单 JSON 接口下,Undertow 的吞吐量比默认配置的 Tomcat 高出一些,内存占用也更低。但要注意,Undertow 的社区资料比 Tomcat 少不少,遇到冷门问题搜索成本高。我的建议是:新项目想尝鲜可以用 Undertow,老项目稳定为主,继续 Tomcat 没毛病。

3. Spring Boot 实战:内嵌容器到底怎么工作,配置怎么调

3.1 内嵌容器的原理:“一键启动”的秘密

以前做 SSM 项目时,部署一个 Web 应用要经历:写代码 -> 打 WAR 包 -> 把它丢进 Tomcat 的 webapps 目录 -> 手动启动 Tomcat -> 检查日志。每一步都容易出幺蛾子,而且对新手特别不友好。

Spring Boot 改变了这一切,核心思路就是把 Servlet 容器从“外部依赖”变成“项目内部的一部分”。你执行的SpringApplication.run()方法内部,会根据当前 classpath 里有哪些容器依赖,自动创建一个对应的内嵌容器实例——有 Tomcat 依赖就创建 Tomcat,有 Undertow 依赖就创建 Undertow。然后,容器会启动在server.port指定的端口上,而 Spring 容器和DispatcherServlet的装配也会全部自动完成。

这背后的关键类叫ServletWebServerFactory。它是一个工厂接口,Spring Boot 为每种容器都提供了一套自动装配实现:

  • TomcatServletWebServerFactory
  • JettyServletWebServerFactory
  • UndertowServletWebServerFactory

当你在 application.properties 或 application.yml 里修改server.port=8081,实际上是在改变这个工厂的端口属性。容器启动后,它负责监听网络端口,把 HTTP 请求交给 Spring Web MVC 处理链。

这个设计带来的直接好处是:你不需要在自己的电脑上安装任何独立的 Web 服务器,只要有 JDK 就能跑 Web 项目。生产环境里,也只需要在服务器上装一个 JRE 然后运行 jar 包——部署流程从一个多小时缩短到几分钟。

3.2 核心配置参数详解:端口、线程池、连接超时

Spring Boot 暴露的容器配置项非常多,但真正跟高并发、稳定性强相关的,其实就那么几个。我按重要程度给大家捋一遍。

首先是端口和协议:

server: port: 8080 address: 0.0.0.0

port没什么好说的,需要注意的是port如果设成 0,容器会自动选一个随机空闲端口启动,这在测试环境里特别有用,避免端口冲突。address一般保持默认,如果你只希望内网访问,可以绑成127.0.0.1。

然后是 Tomcat 的关键线程池参数:

server: tomcat: threads: max: 200 min-spare: 10 accept-count: 100 max-connections: 8192 connection-timeout: 20000

这几个参数的含义分别是:

  • max:最大工作线程数。每个请求进来后,如果目前没有空闲线程处理它,就会排队等待,直到有空闲线程。
  • min-spare:最小空闲线程数。Tomcat 启动时会预创建这么多线程,用来应对突然的流量尖峰。
  • accept-count:等待队列长度。如果所有工作线程都忙着,新来的请求会先进入这个队列等待,队列满了之后连接才会被拒绝。
  • max-connections:最大连接数。这个表示服务器能同时接受的 TCP 连接数量,超过之后的新连接会被拒绝或等待。
  • connection-timeout:连接超时时间,单位是毫秒。如果一个连接建立后在这个时间内没有发送请求数据,就会被关闭。

这里有个常见的理解误区:很多人以为max-connections越大越好,max-threads配个几千就能扛住高并发。但实际上线程数太多反而会导致 CPU 频繁切换上下文,性能直线下降。线程池大小应该结合 CPU 核数和服务的 I/O 密集程度来算。如果服务是 I/O 密集型的(比如大部分时间在查数据库、调远程接口),线程数可以适当大一些;如果是 CPU 密集型比如做大量计算,线程数接近 CPU 核数或略高于核数就够了。

注意:Spring Boot 2.3 之前,Tomcat 的连接器参数用的是server.tomcat.max-threads(注意没有threads层级);2.3 之后调整为server.tomcat.threads.max。如果你在升级 Boot 版本后发现配置没生效,先查这个命名差异。

3.3 结合实战:如何根据压测结果反过来调参数

只看理论参数不落地没意义。我分享一下自己调优时的通用流程。

假设你有一个订单查询接口,单次请求平均耗时 80ms(其中 60ms 花在数据库查询上)。如果你的目标是支撑每秒 500 的 QPS,那工作线程池其实不需要太大。

简单估算一下:每秒需要处理请求数 500,每个请求占一个线程 80ms,那么平均并发线程数 = 500 * 0.08 = 40。考虑到请求分布不可能是均匀的,会有峰值波动,留出一定的余量,把max设为 80 到 100 是比较合理的。如果直接把max设成 500,只会白白消耗内存和 CPU 切换开销。

当然这只是粗略的估算方式,真正上线前一定要做压测。拿 JMeter 或 wrk 压一轮,观察线程池活跃度、CPU 利用率、请求百分位延迟,再反过来微调参数。我见过很多团队把 Tomcat 线程数拉满 1000,结果是接口平均延迟没降下来,反而因为 CPU 耗尽导致 GC 频繁,系统整体吞吐更低。

压测时的另一个实用小技巧:在 Spring Boot 里配置一个TomcatConnectorCustomizer,把连接器的AcceptCount适当调大一点,可以吸收突发流量。比如抢购场景,瞬时来了 2000 个请求,线程池只有 100,如果把等待队列设成 500,就能让大部分请求先排队而不是直接报连接拒绝错误。但注意队列不是越大越好,因为排队的请求最终可能在等待中过期,一旦客户端超时,队列里的请求白占资源。

3.4 WebSocket 场景下容器配置的额外注意点

热词里出现了“Spring Boot 集成 web socket yml 配置”,这块我简单提一个坑。Spring Boot 做 WebSocket 时,如果你用的是 Tomcat,配置上除了基本的 WebSocket 端点之外,有几个跟容器强相关的参数要格外留意。

server: tomcat: max-swallow-size: 2MB

这是控制 Tomcat 垃圾回收已接收数据行的最大大小。在 WebSocket 文件上传或长连接传输大消息时,如果这个值配小了,会出现消息被截断或者连接异常关闭。

更重要的是,WebSocket 长连接会长期占用 Tomcat 的工作线程或 NIO 事件线程。如果你把线程池max设置得很小,同时又有大量 WebSocket 长连接开着,同样会造成线程池耗尽、普通 HTTP 请求全部排队。项目里如果 WebSocket 连接数很大,建议单独把 Tomcat 的maxConnections调高,同时把 WebSocket 的消息处理放在独立的线程池中执行,避免阻塞容器的请求线程。

4. 生产环境实战:从内嵌容器到 Docker 容器,再到资源隔离

4.1 jar 包直接部署 vs WAR 包外置 Tomcat 部署

内嵌容器的最大优势是简单,但“简单”不代表“永远最佳”。在某些场景下,你还是得考虑使用传统的外置 Tomcat 部署 WAR 包方案。

什么时候更适合外置容器?三类典型场景:一是公司运维规范要求所有应用统一用同一个 Tomcat 实例管理,便于集中处理证书和日志;二是你需要在同一个 Tomcat 下部署多个彼此隔离的 Web 应用,共享同一个端口;三是某些遗留系统的监控脚本只认 Tomcat 的 catalina.out 日志格式。

Spring Boot 要打 WAR 包也简单:

  1. 在pom.xml里把打包方式改成war:
<packaging>war</packaging>
  1. 让启动类继承SpringBootServletInitializer:
@SpringBootApplication public class DemoApplication extends SpringBootServletInitializer { @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(DemoApplication.class); } }
  1. 然后正常mvn clean package,生成的 WAR 包丢进 Tomcat 的webapps目录即可。

这样做有一个明显的变化:内嵌容器模式下的main方法启动逻辑不再起作用,Tomcat 会按照标准的 Servlet 规范去加载应用。而且 WAR 包的 JSP 支持、类加载机制跟内嵌 jar 模式有明显差异,如果项目里还在用 JSP,WAR 部署是更稳妥的选择。

注意:Java 9 之后的模块化对 WAR 部署有一些影响,尤其是涉及模块信息不完整或者非法反射访问时。我见过几次外部 Tomcat 部署 Boot 3 项目时出现IllegalAccessError,基本都能通过添加--add-opens参数解决,但比内嵌 jar 模式麻烦不少。

4.2 Docker 部署 Servlet 容器:JVM、容器和资源限制的三方拉扯

现在生产上多是用 Docker 容器跑 Spring Boot 服务。这里有个非常隐蔽的问题:默认情况下 JVM 不感知自己在 Docker 容器里,它傻乎乎地拿宿主机的 CPU 核数和内存来配置自己的堆大小。

你在宿主机上看到 32 核 128G 内存,于是没做任何设置直接跑java -jar app.jar,JVM 会默认把堆大小设置为物理内存的 1/4(32G)。但 Docker 容器实际只能使用 2 核 4G,结果就是容器频繁触发 Full GC,甚至 OOM Killer 直接把 Java 进程干掉。

解决方法是显式设置 JVM 参数。从 JDK 10 开始,JVM 默认开启了UseContainerSupport,会自动识别 cgroup 的 CPU 和内存限制。但如果你用的是 JDK 8,特别注意:JDK 8u191 之前的版本不识别 Docker 限定,必须手动加-XX:MaxRAMPercentage=50之类的参数。

下面是我常用的容器启动参数:

java -XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=75.0 -XX:MaxMetaspaceSize=256m -XX:+UseG1GC -jar app.jar

这里有两个跟 Servlet 容器强相关的点:

一是MaxRAMPercentage=75,给 JVM 堆留出容器内存的 75%,剩下的 25% 留给堆外内存、线程栈、Metaspace。Tomcat 和 Netty 这类库会大量使用堆外内存,如果直接开到 90,很容易因为堆外内存溢出导致容器被内核杀掉。

二是如果你发现请求吞吐不高,但容器频繁被重启,八成不是 Servlet 容器的线程数不够,而是 JVM 堆或线程栈超限。可以先看dmesg或 Docker 的OOMKilled状态,再决定是调线程池还是调内存参数。

4.3 容器资源隔离、镜像安全和容器安全的实际联动

搜索词里反复出现“容器资源隔离”“镜像安全”“容器安全”,这些确实是从 Docker 角度来审视 Servlet 容器部署的重要话题。这里我不展开讲 Docker 底层原理,就说几个跟 Spring Boot 服务直接相关的经验。

资源隔离层面,生产环境建议给每个 Spring Boot 容器加上明确的 CPU 和内存配额:

docker run -d --name orderservice \ --cpus=2 \ --memory=4g \ --memory-swap=4g \ -p 8080:8080 \ orderservice:1.0.0

--memory-swap要和--memory一样大,目的是禁用 swap 使用。对 Java 应用来说,swap 一旦启用,JVM 的响应延迟会急剧恶化,因为你不知道什么时候内存页被换到磁盘上去了。垃圾回收本应该在毫秒级完成,结果变成几百毫秒甚至几秒。

镜像安全层面,有几个容易忽略的点:基础镜像尽量选择官方精简版(比如eclipse-temurin或amazoncorretto),避免把所有依赖都打进最终镜像;容器内运行用户不要用 root,在 Dockerfile 里通过USER指令切换为低权限用户;健康检查不要只探 TCP 端口,应该探应用级的/actuator/health接口,否则会出现容器看起来是活的但应用早就线程池耗尽的情况。

镜像安全方面还有一个细节:很多人做镜像时喜欢把application.yml直接打进镜像里,里面带着数据库密码和其他密钥。这个习惯在生产环境中是致命的,镜像仓库一旦泄露,数据库就等于裸奔。正确的做法是通过环境变量或挂载 Volume 注入配置,镜像本身保持无状态。

5. 常见问题与排查技巧实录:让 Servlet 容器“露出马脚”的瞬间

5.1 端口被占用:开发环境最经典的一天

报错信息一般是:Port 8080 was already in use。绝大多数人第一反应是“我其他进程占用了 8080”,然后开始到处杀进程,结果发现怎么杀都杀不掉。大概率是之前启动的 Spring Boot 进程没被完全终止,只是在 IDE 的控制台里点了“停止”,后台的 Java 进程还在跑。

排查时先找进程再决定怎么处理:

lsof -i :8080 # 或者 netstat -anp | grep 8080

找到 PID 之后,确认是不是自己的 Java 进程,再kill -9处理。如果你不想每次都处理这个麻烦事,可以做一个“失败快速暴露”的设置:在开发环境里使用随机端口server.port=0,启动后日志会直接打印实际的端口号,从根上避开冲突。特别是同一台机器要同时跑多个微服务实例联调的时候,这个方式几乎零成本。

5.2 静态资源 404:Servlet 容器映射规则没搞对

很多次新项目跑起来,前后端没联调,前端人员问“为什么我放在src/main/resources/static里的图片直接访问不到?”。

Spring Boot 默认的静态资源映射路径是/**,它会去这几个地方找资源:classpath:/META-INF/resources/、classpath:/resources/、classpath:/static/、classpath:/public/。你的文件如果放在src/main/resources/static/images/a.png,那么访问路径应该是http://localhost:8080/images/a.png,不是http://localhost:8080/static/images/a.png。

这个配置表面上是 Spring MVC 的东西,其实底层跟 Servlet 容器的默认 Servlet 有关。当请求路径没有匹配到任何@RequestMapping时,请求会落到容器的默认 Servlet 上,再由默认 Servlet 去解析静态资源。如果你自己写了@WebServlet(urlPatterns = "/")覆盖了默认 Servlet 映射,静态资源就会一片 404。这种情况在小团队里很常见,因为前端工程师可能会把某些 SPA 路由处理交给后端解决。

5.3 线程池耗尽:慢接口拖垮整个应用

这是线上最让人头疼的问题。典型症状是:某一天某个接口突然变慢,紧接着整个服务的其他接口也变慢了,再后来健康检查都开始失败,服务不得不重启。

进程里看到的现象是 Tomcat 工作线程全部处于RUNNABLE状态或WAITING状态,jstack里能看到大量线程卡在某个数据库查询或远程调用点。本质上就是因为某个下游服务的慢调用占满了所有容器工作线程,导致新的请求无法处理。

处理思路分几步:

第一步,先找到占线程最多的代码点。用jstack <pid>连续抓几次线程快照,看哪些线程堆积在同一个 Stack Trace 段。通常都是连接池等待或者第三方 HTTP 客户端的超时设置过长。

第二步,给下游调用设置超时。很多人设置 RestTemplate 的connectTimeout和readTimeout都是用默认值,一下就是几十秒,这等于主动把线程池让出去。一般建议connectTimeout=2000,readTimeout=3000,结合重试策略,宁可牺牲少量请求,也不能拖垮整个应用。

第三步,做线程池隔离。如果你的应用里有高优先级低延迟的接口,同时也存在文件导出、报表查询这类慢接口,可以考虑用自定义线程池把慢任务从 Tomcat 工作线程中剥离出去。比如将文件导出接口设置为异步执行,Tomcat 线程立刻释放,用户轮询导出进度。这个方法虽然不改变 Tomcat 的总线程数,但避免了慢任务对常规接口的干扰。

5.4 Spring Boot 3 和 Jakarta 命名空间迁移的坑

Spring Boot 3 是一个大版本,最核心的变化是从javax.servlet迁移到jakarta.servlet。很多人直接把老项目切换到 Boot 3,结果编译报一堆找不到符号的错误,第一反应是“依赖没拉全”,其实问题出在导入路径上。

以前你写:

import javax.servlet.http.HttpServletRequest;

现在必须改成:

import jakarta.servlet.http.HttpServletRequest;

看似只是改个前缀,但影响面很大:所有依赖 Servlet API 的第三方库都需要升级到支持 Jakarta 的版本;自定义的 Filter、Interceptor、ServletContextInitializer 全部都要动;如果你的项目里有老的web.xml描述符,也要确认格式是否正确。

还有一个很隐蔽的地方:OpenAPI 文档库、文件上传库、验证框架里如果反射依赖了javax.servlet字符串,运行时会直接抛NoClassDefFoundError。这种问题不像编译期报错那么显眼,通常在启动或首次调用接口时才暴露。排查时先全面搜索代码里残留的javax.servlet,再把所有用到javax.annotation之类的包也一起替换掉。

5.5 常见问题速查表

现象可能原因排查优先级
启动报端口占用旧进程未关闭、端口冲突先用lsof确认占用进程
接口偶发超时,CPU 不高等待队列过长、下游调用慢抓jstack,检查线程等待状态
容器内内存溢出被重启JVM 未感知 cgroup 限制增加-XX:MaxRAMPercentage参数
静态资源 404覆盖了默认 Servlet 映射、路径错检查静态资源路径和自定义@WebServlet
WebSocket 连接频繁断开线程池耗尽、连接超时过短调大maxConnections,或拆独立线程池
上生产环境后随机性卡顿GC 频繁、堆分配过小观察 GC 日志,调整堆大小
升级 Boot 3 后接口 404Servlet 路径匹配策略变化检查spring.mvc.servlet.path和 Controller 映射

6. 一些值得注意的实操心得:关于容器配置的“反直觉”发现

最后再聊几个我自己在长期实战里发现的不太直觉、但很实用的点,希望能帮大家少走点弯路。

第一个心得很反直觉:默认的最大线程数不是越高越好。Spring Boot 的server.tomcat.threads.max默认值是 200。做性能调优时别一上来就翻倍,很多场景下 200 完全够用,甚至系统 CPU 只有 4 核时,200 已经偏大了。你要是实在不确定,先去压测,而不是拍脑袋调线程池。

第二个心得是:尽量在开发和测试环境就用外置容器的模式跑一跑。虽然 Spring Boot 内嵌容器帮我们省了很多事,但生产环境如果用 TOMCAT 外置部署,开发环境一直用内嵌容器的话,很多因为容器版本差异导致的问题(比如 JSP 支持、Session 处理)会拖到上线才爆出来,那时候排查成本可就高了。至少在测试环境里保持和生产一致的部署形态。

第三个心得:健康检查不要只做 HTTP 端口探测。我见过很多项目挂了负载均衡的 HTTP 探活,探的还是一个静态页面地址,结果线程池都满了,静态页面照样用极少的资源返回 200,负载均衡器以为服务健康,继续往里打流量,最终彻底雪崩。后来我们统一改成探/actuator/health,并且在这个接口里增加了线程池活跃度的判断——当活跃线程数超过最大线程数的 80% 时,健康状态返回不健康,负载均衡器自动摘除节点。这个改动在流量高峰期真的救过我们好几次。

第四点更实际一点:别忽略日志里的Tomcat started on port这类提示。很多新手看到这段日志以为只是一句普通输出,但其实它说明整个 web 容器已经完成初始化。如果项目里需要做“容器启动完成后执行某些逻辑”,正确的做法是实现ApplicationRunner或者监听ServletWebServerInitializedEvent事件,而不是在main方法里直接写。

第五点是给用 IntelliJ IDEA 社区版的读者提个醒:社区版虽然没有 Spring 官方插件的全套功能,但跑 Spring Boot 项目完全没问题。你在 Run Configuration 里直接选Application,主类选xxxApplication,加上 JVM 参数后启动即可。如果你遇到社区版启动后控制台没有彩色日志或者端口显示不出来,多半是spring.output.ansi.enabled配置问题,跟 IDEA 版本关系不大。

写到这里,关于 Servlet 容器、Spring Boot 实战和容器部署的核心内容基本都覆盖了。回想这几年带项目、排查线上故障的经历,说实话,很多让人熬夜的问题到最后都和容器理解不到位有关:要么不知道请求一开始是怎么被分配的,要么不清楚线程池的状态意味着什么,要么把内嵌容器和外置容器混为一谈。搞懂 Servlet 容器不是一个可选加分项,而是 Java Web 开发的底层基本功。希望这篇文章能让你在下次遇到“运行异常”“假死”“内存暴涨”时,脑子里能第一时间浮现出容器的运作逻辑,而不是对着日志干瞪眼。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询