第5章 Tomcat 与 Servlet
难度:★★★☆☆ | 前置知识:第4章 HTTP 协议与响应状态码 | 本章解决:把“浏览器发来的一段 HTTP 报文”变成一个能运行的 Java 对象,并且搞清 Tomcat 是什么、Servlet 怎么写、为什么这么写。
本章导读
- 第4章我们把一条 HTTP 报文拆开看过:请求行、请求头、请求体,响应行、响应头、响应体。但报文是“字节”,总得有个东西把它读出来,变成 Java 能用的对象。Tomcat 就是干这件事的软件:它监听端口、收字节、按 HTTP 规范解析成请求对象,再把你的响应对象拼回字节发回浏览器。
- 所以整条链路是这样的:浏览器 →(HTTP 报文)→ Tomcat →(ServletRequest / ServletResponse 对象)→ Servlet →(JDBC)→ 数据库,回来时原路反向。Servlet 就站在这条链路正中间,是唯一一段由你亲手写的 Java 代码,它的职责只有一条:接手一个请求,交回一个响应。
- 第4章讲的是“信封长什么样”,本章讲的是“谁能拆信封、拆完交给谁”。等你学到第6章会发现,
service(ServletRequest req, ServletResponse res)这两个参数,正是下一章要逐条讲透的主角。 - 学完本章你应该能做到:独立解压启动 Tomcat、看懂它的七个目录、把项目部署上去并用浏览器访问到;能默写 Servlet 的五个方法、说清四个生命周期阶段;能区分实现 Servlet 接口、继承 GenericServlet、继承 HttpServlet 三种写法;能背出 urlPattern 的四条匹配规则和优先级;能用注解和 web.xml 两种方式配置同一个 Servlet。
- 三个常见误解先破掉:其一,以为 Servlet 是 Tomcat 的一部分。这个想法不对:Servlet 是 JavaEE 规范里的一个接口,Tomcat 只是“实现了这个规范”的容器;其二,以为每来一次请求就 new 一个 Servlet。这个想法也不对:一个 Servlet 类在容器里只有一个实例,所有请求共用它,这正是线程安全问题的根源;其三,以为注解和 web.xml 随便配一个就行。两者同时配同一个路径,项目会直接起不来。
- 本章的难度集中在“看不见的那部分”:对象是谁创建的、什么时候创建的、创建了几个、被几个线程同时用。把这几问答清楚,Servlet 就没有难点了。
5.1 Web 服务器与 Tomcat
5.1.1 什么是 Web 服务器
Web 服务器的定义:一个能听懂 HTTP 协议的应用程序,它替程序员把协议细节全部挡在门外。
Web服务器是一个应用程序(软件),对 HTTP协议的操作进行封装,使得程序员不必直接对协议进行操作,让Web开发更加便捷。主要功能是“提供网上信息浏览服务”。
这句话是本章的基础,逐字拆开看:
- “应用程序(软件)”:它就是个普通软件,和你装的 QQ、IDEA 没有本质区别,双击就能运行,关掉就没了。它不是硬件,也不构成操作系统的一部分。
- “对 HTTP 协议的操作进行封装”:第4章你自己拼一个请求行
GET / HTTP/1.1是很痛苦的,要算 Content-Length、要处理 chunked、要管连接复用。Web 服务器把这些全做了,只把“解析好的结果”递给你。 - “提供网上信息浏览服务”。它的本职工作是:监听端口 → 收到字节流 → 解析成请求 → 找到对应资源 → 把资源写回响应。
所以 Web 服务器在链路里的位置非常明确:它是浏览器和你的 Java 代码之间那道“协议翻译层”。浏览器只会说 HTTP,你的 Java 代码只会说方法调用,中间必须有人翻译。
【补充】Web 服务器分两类,考试常拿来对比。静态 Web 服务器只负责把磁盘上的文件原样吐出去,比如 Nginx、Apache HTTP Server;动态 Web 服务器(也叫 Web 容器、Servlet 容器)除了吐文件,还能加载并运行你的 Java 类,把请求交给代码处理,比如 Tomcat、Jetty、Undertow、WebLogic。Tomcat 属于后者,所以它才能“运行 Servlet”,这是它和 Nginx 的根本区别。实际生产环境常见组合是 Nginx 在前挡静态资源和反向代理,Tomcat 在后跑 Java 逻辑。
Web 服务器的作用可以压缩成两句话,直接背下来即可:
- 封装 HTTP 协议操作,简化开发。
- 可以将 web 项目部署到服务器中,对外提供网上浏览服务。
5.1.2 Tomcat 简介
Tomcat 是 Apache 软件基金会一个核心项目,是一个开源免费的轻量级 Web 服务器,支持 Servlet/JSP 少量 JavaEE 规范。
三个定语都要会解释:
- 开源免费:源码公开,商用不收费。这是它碾压 WebLogic、WebSphere 的根本原因:后两者功能更强,但是要授权费。
- 轻量级:解压即用,整个目录几十 MB,启动一两秒。对比 WebLogic 动辄几个 GB、启动几分钟。
- 支持 Servlet/JSP 少量 JavaEE 规范:注意“少量”两个字。JavaEE 一共 13 项规范,Tomcat 只实现了其中和 Web 相关的一小部分,不是全部。
JavaEE:Java Enterprise Edition,Java 企业版。指 Java 企业级开发的技术规范总和。包含 13 项技术规范:JDBC、JNDI、EJB、RMI、JSP、Servlet、XML、JMS、Java IDL、JTS、JTA、JavaMail、JAF。
这 13 项不用背全,但要建立“规范”这个概念:规范是一堆接口和文档,不是实现。JavaEE 规定“Servlet 接口必须长这样”,Tomcat 负责“按这个样子把它运行起来”。所以:
- Tomcat 也被称为 Web 容器、Servlet 容器。Servlet 需要依赖于 Tomcat 才能运行。
“容器”这个词很关键。容器 = 能装东西、能管理里面东西生老病死的运行环境。Servlet 对象由 Tomcat new,不由你自己 new;方法由 Tomcat 调用,不由你调用;销毁也由 Tomcat 执行。你只负责写一个类,剩下的全交给容器,这就是“容器管理”的含义,也是第 5.3 节生命周期的由来。
Tomcat 官网:https://tomcat.apache.org/。
【补充】Tomcat 版本和 Servlet 规范的对应关系,是选择题和“环境搭不起来”类问题的第一现场。Servlet 规范版本决定了你能用哪些 API,Tomcat 版本决定了它支持哪个规范版本,两者必须对得上:
| Tomcat 版本 | 支持的 Servlet 规范 | JSP 规范 | 最低 JDK | 包名前缀 |
|---|---|---|---|---|
| Tomcat 7.0.x | Servlet 3.0 | JSP 2.2 | JDK 6 | javax.servlet |
| Tomcat 8.0.x | Servlet 3.1 | JSP 2.3 | JDK 7 | javax.servlet |
| Tomcat 8.5.x | Servlet 3.1 | JSP 2.3 | JDK 7 | javax.servlet |
| Tomcat 9.0.x | Servlet 4.0 | JSP 2.3 | JDK 8 | javax.servlet |
| Tomcat 10.0.x | Servlet 5.0 | JSP 3.0 | JDK 8 | jakarta.servlet |
| Tomcat 10.1.x | Servlet 6.0 | JSP 3.1 | JDK 11 | jakarta.servlet |
读表要点:Tomcat 9 及以前是 javax.servlet 包,Tomcat 10 开始因为 JavaEE 移交 Eclipse 基金会并改名 Jakarta EE,包名整体换成了 jakarta.servlet。所以你从网上抄的 import javax.servlet.* 代码,放到 Tomcat 10 上会直接编译不过(找不到符号)。本课程用的是 Tomcat 8/8.5/9 这一代,所以全程 javax。
5.1.3 下载、安装与卸载
下载、安装、卸载这三件事各只有一条要求,每一条背后都有容易出错的地方:
- 下载:官网下载。官网首页左侧有版本列表,选
Tomcat 9之类的版本号进入下载页,Windows 选32-bit/64-bit Windows Service Installer之外的zip(压缩包) 版本即可。注意别下成 Installer,那是安装成系统服务的,卸载麻烦。 - 安装:绿色版,直接解压即可。所谓“绿色版”,就是不写注册表、不装服务、不往系统目录塞文件,解压到哪就在哪运行。
- 卸载:直接删除目录即可。这正是绿色版的好处:删干净了就等于没装过。
- 启动:双击
bin\startup.bat。- 控制台中文乱码:修改
conf/logging.properties。
- 控制台中文乱码:修改
- 关闭:
- 直接 × 掉运行窗口:强制关闭。
bin\shutdown.bat:正常关闭。Ctrl+C:正常关闭。
【易错点】解压路径里不要出现中文和空格。Tomcat 的启动脚本、
catalina.bat里有大量没加引号的路径拼接,路径一带空格(比如C:\Program Files\tomcat)或中文(D:\我的项目\tomcat),就会出现“一闪而过”“找不到 classpath”这类玄学错误,而且在日志里看不出原因。正确做法是解压到D:\dev\tomcat9这种纯英文无空格短路径。同理,项目所在路径也建议如此。【补充】 三种关闭方式的区别在语义,不在“方便程度”。
shutdown.bat和Ctrl+C走的是正常关闭流程:容器会先停止接收新请求,再依次调用每个已加载 Servlet 的destroy()方法,最后释放资源,会话数据有机会被持久化。而直接 × 掉窗口相当于杀进程,destroy()根本不会执行。如果你的 Servlet 在destroy()里写了“把计数器存回数据库”之类的收尾逻辑,这一下就全丢了。所以开发时随手 × 掉窗口没事,但凡涉及资源释放,必须用正常关闭。
5.1.4 目录结构:七个目录各管什么
解压完打开 Tomcat 目录,会看到一堆文件夹。考试爱考“XX 文件放在哪个目录”,所以这里必须逐个说清。
apache-tomcat-9.0.x
├── bin 可执行脚本:启动、关闭、注册服务
├── conf 配置文件:端口、虚拟主机、日志格式、用户
├── lib Tomcat 运行所需的 jar 包
├── logs 运行日志
├── temp 临时文件
├── webapps 项目部署目录(默认的站点根目录)
├── work JSP 编译后的中间文件
├── LICENSE
├── NOTICE
└── README.mdbin:存放启动和关闭脚本。Windows 下是 startup.bat、shutdown.bat、catalina.bat,Linux/Mac 下是对应的 .sh。startup.bat 本身很短,它实际做的是设置环境变量后调用 catalina.bat start。所以“一闪而过”时,直接去命令行里跑 catalina.bat run 就能看到完整报错,这比双击窗口消失强得多。
conf:所有配置都在这里,四个文件最重要:
| 文件 | 作用 |
|---|---|
server.xml | 主配置。端口号(<Connector port="8080">)、虚拟主机(<Host>)、应用上下文(<Context>)都在这 |
web.xml | 全局部署描述符。里面默认配置了 DefaultServlet(处理静态资源)和 JspServlet(处理 .jsp),这个文件是 Tomcat 自己的,不是你项目的 |
context.xml | 所有应用的默认上下文配置,比如会话持久化、JNDI 数据源 |
logging.properties | 日志级别和控制台编码:中文乱码就改这里 |
lib:Tomcat 运行需要的 jar,比如 servlet-api.jar、jsp-api.jar、el-api.jar、tomcat-util.jar。注意:这里的 jar 只对 Tomcat 自己可见,不会自动进你项目的 classpath。所以你在 IDEA 里写 Servlet 必须自己引 javax.servlet-api 依赖,并且要设成 provided,因为运行时容器已经提供了一份,打成 war 包时不该重复带。
logs:运行日志。常见的有 catalina.yyyy-MM-dd.log(容器主日志,启动报错首选)、localhost.yyyy-MM-dd.log(应用级异常栈)、localhost_access_log.yyyy-MM-dd.txt(访问日志,记录每个请求的 IP、路径、状态码、耗时)。项目起不来、报 500,第一件事就是看这里。
temp:临时目录,Tomcat 运行期间的中间文件放这,可以随时清空。
webapps:默认的部署目录。放进去的文件夹或 war 包会被自动识别成一个 Web 应用,文件夹名(或 war 名)就是访问路径的第一段。刚解压时里面有 ROOT(根应用,访问 http://localhost:8080/ 看到的那个 Tomcat 欢迎页就是它)、docs(自带文档)、examples(示例代码)、manager 和 host-manager(管理后台)。
work:JSP 编译后的产物。JSP 需要先被翻译成 .java 再编译成 .class 才能执行,这些中间文件就放在 work\Catalina\localhost\<项目名>\ 下。这也解释了一个现象:JSP 第一次访问特别慢,之后飞快,因为第一次要翻译加编译。改完 JSP 不生效时,删掉 work 下对应目录强制重编译,是标准操作。
【补充】
work目录还有一个常被忽略的作用:它是判断“JSP 到底有没有被编译”的证据。如果访问 JSP 报 500,而work下压根没生成对应的.java文件,说明请求根本没走到 JSP(可能是路径错了、被过滤器拦了);如果生成了.java但编译报错,错误行号在.java里能直接定位到 JSP 的哪一行,这比看 JSP 源码猜要准得多。【易错点】 把
lib和WEB-INF/lib搞混是高频错误。TOMCAT_HOME/lib是容器的类库,你的项目看不见里面的类(除了容器按规范暴露的 Servlet API);项目/WEB-INF/lib是你项目的类库,只对你这个项目可见。所以“我明明把 mysql 驱动放到 Tomcat 的 lib 里了,为什么还报ClassNotFoundException”。如果驱动没配成容器级共享,正确位置就是WEB-INF/lib。
5.1.5 启动、关闭与常见故障
启动就是双击 bin\startup.bat,窗口里刷出一串日志,最后出现类似 Server startup in [xxxx] milliseconds 就成功了,此时浏览器访问 http://localhost:8080/ 能看到 Tomcat 欢迎页。
控制台中文乱码:修改 conf/logging.properties。原理是 Windows 控制台默认用 GBK 解码,而 Tomcat 日志按 UTF-8 输出,两边编码对不上,中文就变成“锟斤拷”。把 logging.properties 里负责控制台输出的那行编码改成 GBK(或把控制台代码页改成 UTF-8,就是 chcp 65001)即可。
启动时可能出现的问题:
- 端口号冲突:找到对应程序,将其关闭掉。
- 启动窗口一闪而过:检查
JAVA_HOME环境变量是否正确配置。
这两个问题值得展开,因为它们是初学者耗时间最多的地方。
【易错点】端口冲突的典型报错是
java.net.BindException: Address already in use: bind,意思是 8080 已经被别人占了,可能是你上次的 Tomcat 没关干净、IDEA 里还跑着一个、也可能是 Oracle 数据库(它默认也爱占 8080)。查凶手的方法:Windows 命令行执行netstat -ano | findstr :8080,最后一列是 PID,再执行taskkill /PID <那个PID> /F干掉它。别一遇到冲突就改端口,先确认占用的到底是不是自己上次没关的进程,否则改完端口还会撞下一个。【易错点】 窗口一闪而过几乎是
JAVA_HOME的问题,但要注意两个细节:第一,JAVA_HOME必须指向 JDK 的根目录(如C:\Program Files\Java\jdk1.8.0_202),bin目录和 JRE 目录都不对;第二,改了环境变量必须重开命令行窗口才生效,在旧窗口里怎么试都没用。验证方法:新开一个 cmd,执行echo %JAVA_HOME%和java -version,两个都对再启动 Tomcat。如果JAVA_HOME确实配好了还闪退,就在bin目录下开 cmd 手动执行catalina.bat run,让报错留在屏幕上。【考点】 三类启动故障的区分:一闪而过 = 环境变量问题(找不到 Java);端口冲突 = 报 BindException;启动成功但访问 404 = 项目/路径问题。三者排查方向完全不同,先按现象分类,再动手,比乱改配置快十倍。
5.1.6 配置:修改启动端口号
修改启动端口号:conf/server.xml。
打开 conf/server.xml,找到 <Connector> 标签(大约在文件后半部分),把 port 属性改掉:
<Connector port="80" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443" />注:HTTP 协议默认端口号为 80,如果将 Tomcat 端口号改为 80,则将来访问 Tomcat 时,将不用输入端口号。
这句话要理解透:浏览器访问 http://localhost:8080/web-demo/demo1 时,:8080 是显式指定端口;而 http://localhost/web-demo/demo1 里没写端口,浏览器就按协议默认值补上:HTTP 默认 80,HTTPS 默认 443。所以把 Tomcat 端口改成 80 后,地址里的 :8080 就可以省掉,看起来和访问正式网站一样。
【补充】改 80 端口有两个副作用要知道。一,权限问题:Linux/Mac 下 1024 以下的端口是特权端口,非 root 用户绑定会报
Permission denied,所以生产上通常不让 Tomcat 直接占 80,而是用 Nginx 监听 80 再反向代理到 8080。二,冲突问题:80 端口往往已经被 IIS、Apache、Nginx 或某些国产软件占了,改之前先netstat -ano | findstr :80看一眼。Windows 下如果 80 被System进程(PID 4)占用,多半是 IIS 或 WinRM 在作怪。
5.1.7 部署项目的三种方式
Tomcat 部署项目:将项目放置到 webapps 目录下,即部署完成。
一般 JavaWeb 项目会被打成 war 包,然后将 war 包放到 webapps 目录下,Tomcat 会自动解压缩 war 文件。
这两句话是“最简部署”,也是考试标准答案。但实际开发中部署方式不止一种,补充如下。
【补充】Tomcat 部署项目有三条路,按推荐程度排序:
方式一:拷贝到
webapps(最常用、考试答案)。把整个项目文件夹或xxx.war丢进TOMCAT_HOME/webapps,启动(或等它自动扫描)即可。文件夹名就是上下文路径:放web-demo文件夹,访问地址就是http://localhost:8080/web-demo/;放web-demo.war,Tomcat 启动时自动解压成web-demo文件夹,效果一样。这也是唯一不需要改任何配置文件的方式。方式二:在
conf/server.xml里配<Context>。在<Host name="localhost" ...>标签内部加一行:<Context docBase=“D:/projects/web-demo” path=“/web-demo” reloadable=“true” />
docBase是项目在磁盘上的真实路径,path是访问路径(虚拟目录)。缺点是必须重启 Tomcat 才生效,而且改的是全局配置,项目多了server.xml会非常乱,所以不推荐。方式三:在
conf/Catalina/localhost/下建一个 xml 文件(推荐用于外部目录部署)。新建web-demo.xml,内容只有一行<Context docBase="D:/projects/web-demo" />。文件名就是访问路径,不用写path。这种方式支持热部署(Tomcat 检测到文件变化会重新加载应用),也不会污染server.xml。另外还有一条“开发时的路”:在 IDEA 里配 Artifact 直接部署,见 5.1.9,它等同于 Tomcat 的虚拟目录映射,不落盘到
webapps。【对比】 文件夹部署 vs war 包部署:文件夹部署改一个文件立即生效,适合开发调试;war 包是一个压缩文件,传输、备份、版本管理都方便,适合交付和上线,Tomcat 启动时自动解压,但解压出来的目录如果被删,重启后又会从 war 重新解压(想禁止这个行为要改
unpackWARs和autoDeploy属性)。结论是:开发阶段用文件夹部署,上线阶段用 war 包部署。
5.1.8 IDEA 中创建 Maven Web 项目
先理解两种“项目结构”的区别,这是本节的核心。
Maven Web 项目结构:开发中的项目。
web-demo 项目名称
├── pom.xml Maven 配置文件(打包方式、依赖坐标)
└── src
├── main 主目录
│ ├── java Java 代码
│ ├── resources 资源文件
│ └── webapp Web 项目特有目录
│ ├── index.html HTML 文件目录(可自定义)
│ └── WEB-INF Web 项目核心目录(必须叫这个名称)
│ └── web.xml Web 项目配置文件
└── test 测试目录部署的 JavaWeb 项目结构:开发完成,可以部署的项目。
web-demo 项目访问路径(虚拟目录)
├── index.html HTML 文件目录(可自定义)
└── WEB-INF Web 项目核心目录(必须叫这个名称)
├── classes Java 字节码文件
├── lib 项目所需 jar 包
└── web.xml Web 项目配置文件两者的差异,就是 Maven 到部署之间“多出来的那一步”:
- 编译后的 Java 字节码文件和 resources 的资源文件,放到 WEB-INF 下的 classes 目录下。
- pom.xml 中依赖坐标对应的 jar 包,放入 WEB-INF 下的 lib 目录下。
src/main/java 编译出的 .class 和 src/main/resources 里的配置文件,最终都进 WEB-INF/classes;Maven 依赖的 jar 最终都进 WEB-INF/lib。这个搬运工作是 IDEA 构建 Artifact 时自动完成的,你只需要理解结果。
WEB-INF 这个名字必须一字不差,而且它有一个非常重要的安全特性下面会讲。
在 IDEA 里创建 Maven Web 项目有两条路。
路线一:使用项目模板。 archetype 就是 Maven 的项目模板。
- 选择 web 项目模板,创建项目。在
New Project → Maven里勾选Create from archetype,选org.apache.maven.archetypes:maven-archetype-webapp。 - 删除
pom.xml中多余的坐标。模板会写入一些示例依赖和过时的插件配置,留着只会拖慢构建。 - 补齐缺失的目录结构。模板默认没有
src/main/java和src/main/resources,要手动建,否则你连 Java 类都放不进去。
路线二:不使用项目模板。
- 创建一个普通 Maven 项目(不勾选 archetype)。
pom.xml中添加打包方式为 war:
<packaging>war</packaging>- 补齐缺失的目录结构:手动创建
src/main/webapp,再在webapp下建WEB-INF,最后在WEB-INF下建web.xml。缺了webapp目录,IDEA 不会把它识别成 Web 项目(目录图标上不会出现小圆点)。
【易错点】用
maven-archetype-webapp模板生成的项目,web.xml的版本是 2.3(<!DOCTYPE web-app PUBLIC "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN" ...>)。而 Servlet 3.0 才开始支持注解,2.3 版本的部署描述符会被容器按“元数据完整(metadata-complete)”处理,你写的@WebServlet会被完全忽略,访问一律 404。正确做法是把web.xml的头换成 3.1 的 schema(见 5.6.2 的完整写法),或者干脆删掉web.xml(Servlet 3.0 之后 web.xml 不是必需的)。这是“代码没错但就是 404”最常见的原因之一。【补充】
WEB-INF目录的两条铁律,考试和实战都爱考:
- 浏览器不能直接访问
WEB-INF下的任何文件。访问http://localhost:8080/web-demo/WEB-INF/web.xml一定 404,这是 Servlet 规范强制的安全限制,所以web.xml、classes、lib天然受保护,配置和字节码不会被直接下载。WEB-INF下的资源只能由服务端转发(forward)访问,不能由浏览器直接请求,也不能用重定向(redirect)跳进去,因为重定向是让浏览器再发一次请求,而浏览器无权访问WEB-INF。这个区别在第6章讲转发和重定向时会再次用到。
5.1.9 IDEA 中使用 Tomcat
有两种集成方式。
方式一:集成本地 Tomcat。 将本地 Tomcat 集成到 IDEA 中,然后进行项目部署即可。操作路径是 Run → Edit Configurations → + → Tomcat Server → Local,选中你解压的 Tomcat 目录;再切到 Deployment 页签,点 + → Artifact 选中项目的 war exploded,把 Application context 设成 /web-demo。之后点绿色三角启动,IDEA 会用它的方式拉起 Tomcat 并把项目挂上去。
【补充】集成时有两个设置项要说明。
On 'Update' action决定你改完代码按Ctrl+F10时容器做什么:选Update classes and resources可以只重载改动的类,不用重启,开发时快很多;选Restart server就是整机重启,慢但干净。Application context就是访问路径的第一段,设成/web-demo后访问地址是http://localhost:8080/web-demo/demo1;设成/就变成http://localhost:8080/demo1,很多“路径少写一段”的 404 就是这里设的值和你想的不一样。
方式二:Tomcat Maven 插件。 不用装 Tomcat,Maven 插件自带一个内嵌的 Tomcat,一条命令就能运行。
第 1 步,pom.xml 添加 Tomcat 插件:
<build>
<plugins>
<!--Tomcat 插件-->
<plugin>
<groupId>org.apache.tomcat.maven</groupId>
<artifactId>tomcat7-maven-plugin</artifactId>
<version>2.2</version>
<configuration>
<port>80</port><!--访问端口号-->
<path>/</path><!--项目访问路径-->
</configuration>
</plugin>
</plugins>
</build>第 2 步,使用 Maven Helper 插件快速启动项目:选中项目,右键 → Run Maven → tomcat7:run。如果需要断点调试,选择 Debug Maven。
上面这段配置的意思很直白:<port>80</port> 让内嵌 Tomcat 监听 80 端口;<path>/</path> 把项目挂在根路径上,所以访问地址直接是 http://localhost/demo1,连项目名都省了。
【易错点】这个插件名字叫
tomcat7-maven-plugin,它内置的是 Tomcat 7,最高只支持到 Servlet 3.0。用它可以跑@WebServlet(3.0 就有了),但用不了 Servlet 3.1 的新特性;更要命的是它的默认 JSP 引擎版本也老,某些 EL 语法会报错。所以它适合“快速验证一个 Servlet”,不适合当作正式运行环境。另外<path>/</path>配成根路径后,如果你同时开了别的 80 端口程序,一样会端口冲突,插件启动失败时先查 80 端口。
5.2 Servlet 入门
5.2.1 Servlet 是什么
Servlet 是 Java 提供的一门动态 web 资源开发技术。
Servlet 是 JavaEE 规范之一,其实就是一个接口,将来我们需要定义 Servlet 类实现 Servlet 接口,并由 web 服务器运行 Servlet。
这两句话要拆成三层来理解,缺一层就会在后面的生命周期、执行流程上卡住:
第一层:它是“动态 Web 资源开发技术”。 第1章讲过,Web 资源分静态和动态:HTML、CSS、JS、图片是静态资源,谁访问都是同一份内容;Servlet、JSP 是动态资源,负责逻辑处理,同一个 URL 不同参数能返回不同结果。所以 Servlet 的定位是“能算的 Web 资源”。
第二层:它是一个接口。 打开 javax.servlet.Servlet 源码,里面只有五个方法(init、service、destroy、getServletConfig、getServletInfo)。规范只规定“长这样”,不规定“怎么实现”:Tomcat 负责实现容器,你负责实现业务类。
第三层:它由 web 服务器运行。 这句话是整个 Servlet 体系的分水岭。Servlet 对象不是你 new 的,Servlet 方法不是你调的。你写的类只是“一张图纸”,Tomcat 是“施工队 + 调度员”:它按图纸造对象、按请求调方法、按关闭时机销毁对象。
【对比】Servlet 和普通 Java 类的区别在于生命周期由谁控制:普通类的生命周期由调用方控制(你
new你close),Servlet 的生命周期由容器控制。所以 Servlet 里不能写main方法(写了也不会被执行),也不能指望“每次请求都执行构造函数”(构造函数只执行一次)。这个差异直接引出了 5.3 的生命周期和 5.7 的线程安全。【对比】 Servlet 和 JSP 的关系是“底层与表层”,两者并不并列。JSP 就是一个写起来像 HTML 的 Servlet:容器在第一次访问时把 JSP 翻译成
.java(一个继承HttpServlet的类),再编译成.class执行,产物就放在 5.1.4 说的work目录。所以 JSP 能做的事 Servlet 都能做,只是 Servlet 拼 HTML 太痛苦(要一行行out.write),JSP 才被发明出来。第8章会详细讲这个翻译过程。
5.2.2 Servlet 快速入门
快速入门一共四个步骤,一步一步来,每一步都不能省。
第 1 步:创建 web 项目,导入 Servlet 依赖坐标。
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>3.1.0</version>
<scope>provided</scope>
</dependency>这里 scope 必须是 provided,理由在下面易错点里说。
第 2 步:定义一个类,实现 Servlet 接口,并重写接口中所有方法,并在 service 方法中输入一条输出语句。
package com.itheima.web;
import javax.servlet.Servlet;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.annotation.WebServlet;
import java.io.IOException;
@WebServlet("/demo1")
public class ServletDemo1 implements Servlet {
@Override
public void init(ServletConfig config) throws ServletException {
System.out.println("init ...");
}
@Override
public void service(ServletRequest req, ServletResponse res)
throws ServletException, IOException {
System.out.println("service ...");
}
@Override
public void destroy() {
System.out.println("destroy ...");
}
@Override
public ServletConfig getServletConfig() {
return null;
}
@Override
public String getServletInfo() {
return null;
}
}第 3 步:配置。在类上使用 @WebServlet 注解,配置该 Servlet 的访问路径。
@WebServlet("/demo1")
public class ServletDemo1 implements Servlet {第 4 步:访问。启动 Tomcat,浏览器输入 URL 访问该 Servlet。
http://localhost:8080/web-demo/demo1这个 URL 要会拆:http 是协议,localhost 是主机,8080 是 Tomcat 端口,web-demo 是项目访问路径(上下文路径),demo1 是Servlet 的 urlPattern。四段错任何一段都是 404。
【易错点】接口方法签名必须一模一样,这是
implements的硬要求。service的完整签名是void service(ServletRequest req, ServletResponse res) throws ServletException, IOException,两个参数一个都不能少。很多人手写时写成public void service(){}或public void service(ServletRequest req){},编译器会报“类必须实现抽象方法 service(ServletRequest,ServletResponse)”,或者你加了@Override就直接报错。同理init必须带ServletConfig config参数,destroy()必须无参。三个方法各错一个参数,就是三个编译错误。【易错点】
<scope>provided</scope>不是可选项。它的含义是“编译时需要,打包时不要”:编译期你需要javax.servlet.*这些类,所以依赖必须有;但运行时 Tomcat 的lib目录里已经有servlet-api.jar了,如果你把这一份也打进WEB-INF/lib,就会同一个类被两个类加载器各加载一次,典型症状是启动时报ClassCastException或莫名其妙的NoSuchMethodError。结论是:Servlet API 永远 provided。【补充】 第 2 步里
getServletConfig()和getServletInfo()直接return null是能跑的,因为快速入门阶段用不到它们。但严格说getServletConfig()返回null会在容器内部某些调用链上埋雷(比如getServletContext()的实现依赖 config)。规范做法是由容器在init时把ServletConfig传进来,你自己存成成员变量再返回。等你看 5.3.2 的方法介绍时,把这两句和“获取 ServletConfig 对象”对上就明白了。
5.2.3 Servlet 执行流程
这是本章第一个“必须理解而不是背”的点,两个自问自答就能把它讲清楚。
问题 1:Servlet 由谁创建?Servlet 方法由谁调用?
答:Servlet 由 web 服务器创建,Servlet 方法由 web 服务器调用。
问题 2:服务器怎么知道 Servlet 中一定有 service 方法?
答:因为我们自定义的 Servlet,必须实现 Servlet 接口并复写其方法,而 Servlet 接口中有 service 方法。
第二个回答是理解整个体系的钥匙。Tomcat 里有一大段代码大概是这么写的:
// 伪代码:容器内部的调度逻辑
Servlet servlet = 找到urlPattern对应的Servlet对象();
servlet.service(request, response); // 直接调用,不关心你写了什么Tomcat 编译时并不知道你的类叫什么,但它知道你实现了 Servlet 接口,所以它敢直接调 service(...),因为接口就是契约,是实现方和调用方之间的约定。这就是“面向接口编程”在框架里最典型的落地:容器面向接口编程,你面向接口实现。
完整的执行流程可以串成一条线:
浏览器请求 http://localhost:8080/web-demo/demo1
│
▼
① Tomcat 监听 8080,收到字节流,按 HTTP 规范解析出请求行/请求头/请求体
│
▼
② 取出 URL 中的 /web-demo/demo1,去掉上下文路径 /web-demo,剩下 /demo1
│
▼
③ 拿 /demo1 去匹配所有 Servlet 的 urlPattern,命中 ServletDemo1
│
▼
④ 容器创建(或复用已有)ServletDemo1 对象,依次调用 init → service
│
▼
⑤ service 里你写的代码执行,把结果写进 response
│
▼
⑥ 容器把 response 对象按 HTTP 规范拼成响应报文,写回浏览器【考点】“Servlet 由谁创建、由谁调用、由谁销毁” 是必考题,标准答案只有一句:全部由 Web 服务器(Servlet 容器)负责。理由:Servlet 规范把生命周期管理权交给了容器,你只提供实现类。追问“那我能自己
new ServletDemo1()吗”:能,但那只是个普通对象,request/response/ServletConfig全是空的,调用它没有任何意义。【补充】 第 ③ 步“匹配 urlPattern”是有优先级的,不是随便命中一个就行,精确匹配、目录匹配、扩展名匹配、
/*、/依次优先,详见 5.5。这里先记住“URL 里的路径段要和 urlPattern 精确对上”,就能解释大部分 404。
5.2.4 一个 Servlet 处理多个请求:从“一个功能一个类”到“一个模块一个类”
快速入门写完,你很快会发现一个问题:一个项目几十个功能,难道要写几十个 Servlet 类?每个类里只有一行 System.out.println?这就是“一个 Servlet 只处理一个请求”的写法,教学阶段清晰,真实项目里无法维护。
【补充】真实框架的解法是“一个 Servlet 处理一类请求”,也就是 DispatcherServlet 模式(前端控制器模式)。核心思想:只写一个 Servlet,让它匹配一大片 URL,进入
service后先解析出“这次到底要干什么”,再分发给对应的处理方法。Spring MVC 的DispatcherServlet就是这个思路的极致实现。实现它有两条路:
思路一:用 urlPattern 的目录匹配,从路径里取“动作”。
@WebServlet("/user/*")
public class UserServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
// 请求 /user/add 时,getPathInfo() 返回 "/add"
// 请求 /user/delete 时,getPathInfo() 返回 "/delete"
String path = req.getPathInfo();
if ("/add".equals(path)) {
add(req, resp);
} else if ("/delete".equals(path)) {
delete(req, resp);
} else {
resp.sendError(HttpServletResponse.SC_NOT_FOUND); // 404
}
}
private void add(HttpServletRequest req, HttpServletResponse resp) {
System.out.println("新增用户 ...");
}
private void delete(HttpServletRequest req, HttpServletResponse resp) {
System.out.println("删除用户 ...");
}
}思路二:用请求参数区分动作。 所有请求都打到同一个路径,靠 ?method=add 这样的参数决定走哪个分支:
@WebServlet("/user")
public class UserServlet2 extends HttpServlet {
@Override
protected void service(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
// 注意:这里重写的是 HttpServletRequest 版本的 service
String method = req.getParameter("method");
if ("add".equals(method)) {
add(req, resp);
} else if ("delete".equals(method)) {
delete(req, resp);
} else {
resp.sendError(HttpServletResponse.SC_NOT_FOUND);
}
}
private void add(HttpServletRequest req, HttpServletResponse resp) {
System.out.println("新增用户 ...");
}
private void delete(HttpServletRequest req, HttpServletResponse resp) {
System.out.println("删除用户 ...");
}
}两条路的取舍:目录匹配的 URL 更 RESTful(/user/add 一眼看懂),但要处理路径解析和 404 兜底;参数区分实现最简单,缺点是所有请求长一个样,不利于缓存和 SEO。真实项目里 Spring MVC 用的是“路径 + 注解”的组合,本质仍是这两条路的进化版。
【考点】问:“一个 Servlet 能不能配置多个 urlPattern?”答:能。
@WebServlet({"/demo1", "/demo2", "/user/*"})或 web.xml 里写多个<url-pattern>都可以。这是“一个 Servlet 处理多个请求”的最基础形式,也是最容易被忽略的一条,很多人以为一个 Servlet 只能有一个路径。
5.3 Servlet 生命周期
5.3.1 四个阶段
对象的生命周期指一个对象从被创建到被销毁的整个过程。
Servlet 运行在 Servlet 容器(web 服务器)中,其生命周期由容器来管理,分为 4 个阶段:
- 加载和实例化:默认情况下,当 Servlet 第一次被访问时,由容器创建 Servlet 对象。
- 初始化:在 Servlet 实例化之后,容器将调用 Servlet 的
init()方法初始化这个对象,完成一些如加载配置文件、创建连接等初始化的工作。该方法只调用一次。 - 请求处理:每次请求 Servlet 时,Servlet 容器都会调用 Servlet 的
service()方法对请求进行处理。 - 服务终止:当需要释放内存或者容器关闭时,容器就会调用 Servlet 实例的
destroy()方法。
四个阶段的“次数”是考点,必须记准:
| 阶段 | 触发时机 | 调用次数 |
|---|---|---|
| 加载和实例化 | 第一次被访问时(默认),或容器启动时(配了 load-on-startup) | 1 次 |
初始化 init() | 实例化之后,立即 | 1 次 |
请求处理 service() | 每次请求 | N 次(每个请求一次) |
服务终止 destroy() | 容器关闭或应用卸载时 | 1 次 |
结论是:init 一次、service 多次、destroy 一次。这个“1 : N : 1”的结构,正是 5.7 节线程安全问题的直接来源:既然 service 被调用 N 次而对象只有一个,那这 N 次调用必然是多个线程同时发生在同一个对象上。
【补充】“第一次被访问时才创建”是有代价的:第一个用户要等容器实例化 + 初始化,如果
init()里干了重活(读大配置文件、建数据库连接池),首屏会明显卡顿。解决办法就是 5.3.3 的load-on-startup,把这个成本从“第一个用户”转移到“服务器启动时”。所以生产环境的重要 Servlet 一般都会配load-on-startup。
5.3.2 Servlet 接口的五个方法
Servlet 接口就这五个方法,全部要能说出作用:
- 初始化方法,在 Servlet 被创建时执行,只执行一次。
void init(ServletConfig config)- 提供服务方法,每次 Servlet 被访问,都会调用该方法。
void service(ServletRequest req, ServletResponse res)- 销毁方法,当 Servlet 被销毁时,调用该方法。在内存释放或服务器关闭时销毁 Servlet。
void destroy()- 获取 ServletConfig 对象。
ServletConfig getServletConfig()- 获取 Servlet 信息。
String getServletInfo()前三个是生命周期方法,由容器在特定时刻调用,你重写它们是为了“在特定时刻做特定的事”。后两个是普通取值方法,getServletConfig() 返回容器在 init 时注入的配置对象(能进一步拿到 ServletContext 和初始化参数),getServletInfo() 返回作者、版本之类的描述字符串,实际项目里几乎不用,返回 null 或空串都行。
【考点】
ServletConfig和ServletContext的区别,面试很爱问。ServletConfig是“一个 Servlet 的配置”,每个 Servlet 一个,能取<init-param>和 Servlet 名字;ServletContext是“整个应用的配置”,一个应用只有一个(全局唯一),能取<context-param>、读真实路径、存全局属性。记忆点:Config 是 Servlet 级,Context 是应用级。ServletContext后面在第7章讲作用域时还会出场。【易错点】
destroy()里不要写“必须执行”的逻辑。规范只保证“容器正常关闭时调用”,但进程被强杀、断电、OOM 崩溃、kill -9,destroy()都不会执行。所以像“把内存里的数据刷回数据库”这种不能丢的操作,应该放在每次请求结束时做(或者用事务),而不是指望destroy()兜底。
5.3.3 load-on-startup:控制创建时机
默认是“第一次访问才创建”,用 loadOnStartup 可以改成“服务器启动就创建”:
@WebServlet(urlPatterns = "/demo", loadOnStartup = 1)
public class ServletDemo extends HttpServlet {
// ...
}取值规则如下:
- ① 负整数:第一次被访问时创建 Servlet 对象。
- ② 0 或正整数:服务器启动时创建 Servlet 对象,数字越小优先级越高。
loadOnStartup 的取值实际分成两类语义:负数 = 懒加载(默认行为,等价于不写),非负数 = 饿加载(启动即建)。多个 Servlet 都配了正数时,容器按数字从小到大依次实例化和初始化,1 比 2 先。
web.xml 里对应的标签是 <load-on-startup>:
<servlet>
<servlet-name>demo</servlet-name>
<servlet-class>com.itheima.web.ServletDemo</servlet-class>
<!--0 或正整数:服务器启动时创建;负整数或不写:第一次访问时创建-->
<load-on-startup>1</load-on-startup>
</servlet>【对比】负数 vs 正数的区别在错误暴露时机,不在“快慢”。配负数(默认):
init()里写错了代码,要等到第一个用户访问才在日志里报错,你在测试时可能根本没访问那个页面,上线才发现。配正数:启动时就把所有 Servlet 初始化一遍,写错的、依赖缺失的、配置错误的,全部在启动阶段暴露,服务起不来总比“起来了但某个功能 500”好排查。所以生产环境的 Servlet 建议配正数,这也是“快速失败(fail fast)”思想的体现。【易错点】
loadOnStartup是@WebServlet的属性名,web.xml 里是<load-on-startup>标签,大小写和连字符不一样,别写混。另外注意@WebServlet里如果只写loadOnStartup不写路径,仍然必须给value或urlPatterns,否则启动报错 “Servlet 没有指定 url-pattern”。
5.3.4 destroy 到底什么时候被调用
这是最容易被一笔带过、但考试特别爱问的一个点。
【考点】
destroy()的四个“会”和三个“不会”:会调用:① 正常关闭 Tomcat(
shutdown.bat、Ctrl+C、IDEA 的 Stop 按钮);② 从webapps里热部署删除应用(容器卸载应用);③ 应用重新加载(reloadable="true"检测到 class 变化);④ 内存不足时容器决定回收。不会调用:① 直接 × 掉启动窗口(强杀进程);②
kill -9或任务管理器结束进程;③ 断电、蓝屏、JVM 崩溃。还有一条容易忽略的:
destroy()执行完之后,对象才可以被 GC 回收,它只是在“临死前”给你一次清理机会。
理解了这一点,就能回答“为什么我的连接池没关”这类问题:如果你把 dataSource.close() 写在 destroy() 里,而测试时每次都是 × 掉窗口,那连接就是没关。代码没有错,关闭方式不对。
5.4 Servlet 体系结构与三种实现方式
5.4.1 体系结构:三层继承
Servlet 的体系结构是一条直线,三句话就能概括三个层次:
- Servlet 体系根接口:
Servlet接口,定义五个方法,最抽象。 - Servlet 抽象实现类:
GenericServlet,把Servlet接口里那些“每个实现都一样”的方法先实现掉。 - 对 HTTP 协议封装的 Servlet 实现类:
HttpServlet,在GenericServlet基础上针对 HTTP 协议做了增强。
我们将来开发 B/S 架构的 web 项目,都是针对 HTTP 协议,所以我们自定义 Servlet,会继承 HttpServlet。
所以三者关系是:
Servlet(接口)
▲
│ implements
GenericServlet(抽象类)
▲
│ extends
HttpServlet(抽象类)
▲
│ extends
你自己写的 ServletDemoGenericServlet 帮你省了什么?它把 getServletConfig()、getServletInfo()、init()、log() 这些“和协议无关”的方法都实现好了,只留下 service(ServletRequest, ServletResponse) 一个抽象方法给你。所以继承 GenericServlet 时,你只需要写一个方法。
HttpServlet 又帮你省了什么?它把 service(ServletRequest, ServletResponse) 实现好了:先判断请求方式是 GET 还是 POST,再分别调用 doGet 或 doPost。所以继承 HttpServlet 时,你连 service 都不用写,只写 doGet/doPost 就行。
【对比】三种实现方式的“要写几个方法”,直接决定了它们的实用价值:
| 实现方式 | 需要重写的方法数 | 能否直接拿 HttpServletRequest | 实际使用 |
|---|---|---|---|
implements Servlet | 5 个全写 | 不能,只有 ServletRequest | 教学演示、理解接口 |
extends GenericServlet | 1 个(service) | 不能,参数仍是 ServletRequest | 极少用,非 HTTP 场景才考虑 |
extends HttpServlet | 1~2 个(doGet/doPost) | 能,参数是 HttpServletRequest | 99% 的实际项目 |
关键差异在最后一行:HttpServlet 的 doGet/doPost 参数类型是 HttpServletRequest/HttpServletResponse,这两个接口里有 getHeader()、getSession()、getCookies() 这些 HTTP 专属方法。而 ServletRequest 只有最基础的 getParameter()、getInputStream()。所以要处理 HTTP 细节,必须继承 HttpServlet,这就是“我们自定义 Servlet 会继承 HttpServlet”的原因,不只是“少写方法”。
5.4.2 方式一:实现 Servlet 接口
这就是 5.2.2 快速入门的写法,五个方法全写,一个不能少。它最大的价值是让你看清接口的完整面貌:原来生命周期就是这五个方法,原来 init 要接 ServletConfig,原来 service 的两个参数是请求和响应。
@WebServlet("/demo1")
public class ServletDemo1 implements Servlet {
@Override
public void init(ServletConfig config) throws ServletException { }
@Override
public void service(ServletRequest req, ServletResponse res)
throws ServletException, IOException { }
@Override
public void destroy() { }
@Override
public ServletConfig getServletConfig() { return null; }
@Override
public String getServletInfo() { return null; }
}【易错点】用这种方式时,
service的参数是ServletRequest,没有getMethod()、没有getHeader()、没有getSession()。如果你在service里写req.getMethod(),编译直接报“找不到符号”。要拿这些方法得强转:HttpServletRequest httpReq = (HttpServletRequest) req;。这个强转是合法的(容器传进来的实际就是HttpServletRequest的实现类),但既然都要强转了,为什么不直接继承HttpServlet?这就是它被淘汰的原因。
5.4.3 方式二:继承 GenericServlet
@WebServlet("/demo3")
public class ServletDemo3 extends GenericServlet {
@Override
public void service(ServletRequest req, ServletResponse res)
throws ServletException, IOException {
System.out.println("service ...");
}
}就一个方法,其余四个由父类实现好了。
【补充】
GenericServlet里init(ServletConfig config)的实现有个细节:它内部调用了无参的init()。GenericServlet提供了两个 init:带参的(来自接口,final语义上不建议重写)和无参的(给你重写用的)。你如果继承GenericServlet想写初始化逻辑,应该重写无参的init(),而不是带参的那个,重写带参的会丢掉父类保存ServletConfig的逻辑,导致getServletConfig()返回null。这是“模板方法模式”的经典用法:父类控制流程,子类填内容。
5.4.4 方式三:继承 HttpServlet(推荐)
HttpServlet 使用步骤:
- 继承 HttpServlet。
- 重写 doGet 和 doPost 方法。
package com.itheima.web;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebServlet("/demo4")
public class ServletDemo4 extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
System.out.println("get ...");
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
System.out.println("post ...");
}
}注意两个细节:方法修饰符是 protected(父类就是 protected,写成 public 也能编译,但扩宽访问权限不是好习惯);返回类型是 void,参数是两个 HttpServletRequest/HttpServletResponse。
HttpServlet 原理:HTTP 协议中,GET 和 POST 请求方式的数据格式不一样,将来要想在 Servlet 中处理请求参数,得在 service 方法中判断请求方式,并且根据请求方式的不同,分别进行处理。
两个问题就能引出这层原理:
思考 1:HttpServlet 中为什么要根据请求方式的不同,调用不同方法?
因为 GET 和 POST 取参数的位置不一样(第4章讲过:GET 的参数在请求行 URL 里,POST 的参数在请求体里),处理逻辑也不同:GET 一般是查询,POST 一般是提交。如果都写在同一个方法里,你每次都要先 if ("GET".equals(req.getMethod())) 判断一遍,重复且容易漏。
思考 2:如何调用?
HttpServlet 的 service(HttpServletRequest, HttpServletResponse) 内部大概是这样:
// HttpServlet 源码的简化版,面试常问
protected void service(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String method = req.getMethod(); // 拿到请求方式:"GET" / "POST" / ...
if ("GET".equals(method)) {
doGet(req, resp); // 分发给 doGet
} else if ("POST".equals(method)) {
doPost(req, resp); // 分发给 doPost
} else if ("PUT".equals(method)) {
doPut(req, resp);
} else if ("DELETE".equals(method)) {
doDelete(req, resp);
} else {
resp.sendError(HttpServletResponse.SC_NOT_IMPLEMENTED); // 501
}
}总结:HttpServlet 原理 = 获取请求方式,并根据不同的请求方式,调用不同的 doXxx 方法。
【考点】“我只写了 doPost,用浏览器地址栏直接访问为什么报 405?” 因为地址栏敲回车永远发 GET 请求,
HttpServlet.service判断出是 GET,就去调doGet,而HttpServlet里doGet的默认实现是resp.sendError(405)(“方法不允许”)。你只重写了doPost,等于doGet还是父类那个“直接报 405”的版本。正确做法:要么两个都重写,要么在doGet里直接调用doPost(req, resp)(doGet委托给doPost,这是最常见的省事写法)。【补充】
doGet和doPost的默认实现都是返回 405 Method Not Allowed,不是 404。这个区别要分清:404 = 找不到这个 URL(路径问题);405 = 找到了 Servlet,但这个 HTTP 方法没被支持(方法问题)。看到 405 就别再查路径了,去看请求方式和doXxx是否对得上。
5.4.5 重写 service 与重写 doGet 的区别
这是本章最容易被忽略、又最能拉开分差的一个点。
【对比】重写
servicevs 重写doGet/doPost:
| 对比项 | 重写 service(HttpServletRequest, HttpServletResponse) | 重写 doGet / doPost |
|---|---|---|
| 谁调用它 | 容器直接调用 | HttpServlet.service 内部调用 |
| 能否区分请求方式 | 不能,所有方式都进这个方法 | 能,GET 进 doGet,POST 进 doPost |
| 是否需要自己判断 method | 需要,自己 if ("GET".equals(...)) | 不需要,容器已分好 |
| 是否会绕过 doGet/doPost | 会,一旦重写且不调 super.service(),doGet/doPost 永远不执行 | 不会,它就是被 service 调起来的 |
| 适用场景 | 需要统一处理所有请求方式(如统一鉴权、统一日志) | 常规业务开发 |
关键在第四行:如果你重写了 service,却忘了写 super.service(req, resp),那么 doGet/doPost 就变成了死代码。方法写得再对也永远不会被调用,现象是“访问没反应,什么都不打印”。这是新手最崩溃的 bug 之一,因为代码看起来完全正确。
@WebServlet("/demo5")
public class ServletDemo5 extends HttpServlet {
@Override
protected void service(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
System.out.println("统一入口,请求方式 = " + req.getMethod());
// 关键一行:不写这句,下面的 doGet/doPost 永远不会执行
super.service(req, resp);
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
System.out.println("get ...");
}
}另外还有一个更隐蔽的问题:HttpServlet 有两个 service 方法:service(ServletRequest, ServletResponse)(来自接口,public)和 service(HttpServletRequest, HttpServletResponse)(protected)。你重写哪个,行为不一样:
【易错点】如果你写的是
public void service(ServletRequest req, ServletResponse res)(参数是父接口),那你就覆盖掉了HttpServlet的总入口,doGet/doPost的分发逻辑被彻底切断,不管什么请求方式都不会走doXxx。而如果你写的是protected void service(HttpServletRequest req, HttpServletResponse resp)(参数是 HTTP 子接口),你是覆盖了“分发器”本身,同样需要super.service()才能继续分发。两种重写都会让 doGet 失效,区别只是签名不同。所以结论还是那句:老老实实重写 doGet/doPost,别碰 service。
5.5 Servlet urlPattern 配置
5.5.1 四条匹配规则
Servlet 要想被访问,必须配置其访问路径(urlPattern)。
urlPattern 的配置有两条总纲和四条规则:
- 一个 Servlet,可以配置多个 urlPattern。
- urlPattern 配置规则:① 精确匹配 ② 目录匹配 ③ 扩展名匹配 ④ 任意匹配。
四条规则的“配置路径”和“实际能匹配的访问路径”最容易混,下面这张表可以直接照着抄(假设项目访问路径是 /web-demo):
| 匹配方式 | 配置路径 | 能匹配的访问路径 | 说明 |
|---|---|---|---|
| ① 精确匹配 | /demo1 | http://localhost:8080/web-demo/demo1 | 一模一样才行,多一个字符都不行 |
| ② 目录匹配 | /user/* | /web-demo/user、/web-demo/user/add、/web-demo/user/a/b | * 之前的部分是固定前缀,之后任意 |
| ③ 扩展名匹配 | *.do | /web-demo/demo1.do、/web-demo/user/add.do | 只看后缀,不看前面 |
| ④ 任意匹配 | /* 或 / | 所有未被更精确规则命中的路径 | 两个都能“全匹配”,但语义不同 |
配置写法(注解版):
// 一个 Servlet 配多个 urlPattern
@WebServlet({"/demo1", "/user/*", "*.do"})
public class ServletDemo6 extends HttpServlet {
// ...
}【补充】扩展名匹配有两条硬规则,考试爱考:第一,
*.do前面不能加斜杠,写成/*.do是非法的,Tomcat 启动会报Invalid <url-pattern> /*.do in servlet mapping;第二,扩展名匹配的优先级低于目录匹配,所以/user/*和*.do同时存在时,/user/add.do会走/user/*那个 Servlet。另外,/和/*必须单独一个字符,不能写成/demo/*/x这种中间带通配的形式,Servlet 规范只允许“末尾一个*”。
5.5.2 优先级
优先级顺序如下:
精确路径 > 目录路径 > 扩展名路径 > /* > /
这个顺序的含义是:当一个请求能同时被多条规则匹配时,选最“具体”的那条。举一个例子,假设四个 Servlet 分别配了 /user/add、/user/*、*.do、/*,那么请求 /user/add 会命中第一个(精确),请求 /user/add.do 会命中 /user/*(目录优先于扩展名),请求 /other/x.do 会命中 *.do(扩展名优先于 /*),请求 /anything 命中 /*。
【考点】优先级不需要死记,理解原则即可:越具体越优先。精确路径是“点名”,目录是“点科室”,扩展名是“按工种”,
/*是“全收”,/是“兜底”。容器永远先找最具体的那个。
5.5.3 / 与 /* 的区别
这两个长得像、作用完全不同,是必考对比题,结论先摆出来:
- 当我们的项目中的 Servlet 配置了
/,会覆盖掉 tomcat 中的 DefaultServlet,当其他的 url-pattern 都匹配不上时都会走这个 Servlet。 - 当我们的项目中配置了
/*,意味着匹配任意访问路径。
要理解第一句,得先知道 DefaultServlet 是什么:它是 Tomcat 在 conf/web.xml 里预置的一个 Servlet,映射路径就是 /,职责是读取并返回静态资源(HTML、CSS、JS、图片)。你访问 http://localhost:8080/web-demo/index.html 能拿到页面,就是它在干活。
所以配了 / 之后,你的 Servlet 顶替了 DefaultServlet 的位置:所有“没被精确/目录/扩展名规则命中”的请求,包括所有静态资源请求,全都进你的 Servlet。如果你的 Servlet 里没有“读文件并写回响应”的逻辑,那么页面里的图片、CSS、JS 全部 404。这是最典型的故障现象。
【对比】
/与/*的区别:/是“替补”,/*是“通吃”(展开如下):
对比项 //*优先级位置 最低(排在最后) 倒数第二(高于 /)是否覆盖 DefaultServlet 是,静态资源请求会进你的 Servlet 是,但静态资源也会被拦截 精确/目录/扩展名匹配的请求 不受影响,仍走原来那个 Servlet 同样不受影响(精确匹配仍优先) 典型后果 静态资源 404 静态资源 404,且所有“无映射”的请求都被吞 实际用途 自己实现一个“静态资源处理器”或全局兜底 做统一入口(如 DispatcherServlet)、全局过滤 记忆口诀:斜杠一个,只做替补;星号跟上,全部通吃。
【易错点】 别在
web.xml里给同一个 Servlet 同时配<url-pattern>/</url-pattern>和<url-pattern>/*</url-pattern>之外的“想当然”组合。另外要特别注意:/配在一个 Servlet 上之后,如果你还想要静态资源能访问,就得在那个 Servlet 里自己实现“找不到就转发给 DefaultServlet”的逻辑,否则整个项目的图片、样式全废。这也是为什么 Spring MVC 的DispatcherServlet默认映射是/,但它额外配置了“静态资源放行”,因为它知道自己顶替了 DefaultServlet。
5.6 两种配置方式:注解与 web.xml
5.6.1 注解配置:@WebServlet
@WebServlet("/demo1")
public class ServletDemo1 implements Servlet { }一个注解搞定,value 就是 urlPattern。等价写法:
@WebServlet(urlPatterns = "/demo1")【补充】
@WebServlet的常用属性一共四个,考试可能让你填:
属性 作用 示例 value/urlPatterns访问路径,二者只能用一个,不能同时写 @WebServlet("/demo1")nameServlet 名称,等价于 web.xml 的 <servlet-name>,默认是类的全限定名@WebServlet(name = "demo1", urlPatterns = "/demo1")loadOnStartup创建时机,负数=首次访问,非负=启动时创建 @WebServlet(urlPatterns = "/demo", loadOnStartup = 1)initParams初始化参数,等价于 <init-param>@WebServlet(urlPatterns = "/demo", initParams = {@WebInitParam(name = "encoding", value = "UTF-8")})【易错点】 路径必须以
/开头(除非是*.do这种扩展名匹配)。写成@WebServlet("demo1")启动时会抛IllegalArgumentException: Invalid <url-pattern> demo1 in servlet mapping,项目直接起不来。这个错误信息很好认:看到Invalid <url-pattern>就去查注解里的字符串。
5.6.2 XML 配置方式
Servlet 从 3.0 版本后开始支持使用注解配置,3.0 版本前只支持 XML 配置文件的配置方式。
步骤:
- 编写 Servlet 类。
- 在 web.xml 中配置该 Servlet。
第 1 步和注解方式完全一样,只是类上不写 @WebServlet。第 2 步的 web.xml 完整写法如下(放在 src/main/webapp/WEB-INF/web.xml):
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
version="3.1">
<!--1. 配置 Servlet 类:名字 + 全限定类名-->
<servlet>
<servlet-name>demo1</servlet-name>
<servlet-class>com.itheima.web.ServletDemo1</servlet-class>
<!--0 或正整数:服务器启动时创建;负整数或不写:第一次访问时创建-->
<load-on-startup>1</load-on-startup>
</servlet>
<!--2. 配置 Servlet 的访问路径-->
<servlet-mapping>
<servlet-name>demo1</servlet-name>
<url-pattern>/demo1</url-pattern>
</servlet-mapping>
</web-app>配置的核心是两个 <servlet-name> 必须一模一样,它是把“类”和“路径”连起来的唯一纽带。<servlet> 里写“这个 Servlet 叫什么、是哪个类”,<servlet-mapping> 里写“这个名字对应哪个访问路径”。
多个路径就写多个 <url-pattern>:
<servlet-mapping>
<servlet-name>demo1</servlet-name>
<url-pattern>/demo1</url-pattern>
<url-pattern>/user/*</url-pattern>
</servlet-mapping>【易错点】
web.xml里元素的顺序是有约束的(由 XSD 定义)。<servlet>必须写在<servlet-mapping>之前,<servlet-name>必须写在<servlet-class>之前。顺序写反,IDEA 会标红,Tomcat 启动报The content of element type "web-app" must match ...。另外<servlet-class>必须是全限定类名(带包名),只写ServletDemo1会报ClassNotFoundException。【补充】 “Servlet 3.0 后开始支持注解”,这句话是版本分界线,但它有个前提容易被漏掉:注解能被扫描到,要求
web.xml的版本也是 3.0 及以上,或者干脆没有web.xml。如果web.xml还是 2.3/2.4 的旧版本头,容器会认为“这个应用的元数据已经完整声明在 XML 里了”(metadata-complete生效),注解一律不扫描。这就是 5.1.8 那个“模板项目 404”的根因。反过来,如果你想强制只用 XML、禁止注解生效,可以在<web-app>上加metadata-complete="true"。
5.6.3 两种方式冲突了会怎样
【易错点】三种冲突场景,后果完全不同,一定要分清:
场景一:同一个
url-pattern配给两个不同的 Servlet。 比如注解写了@WebServlet("/demo1"),web.xml里又把/demo1映射给另一个类。启动直接失败,报:java.lang.IllegalArgumentException: The servlets named [a] and [b] are both mapped to the url-pattern [/demo1] which is not permitted这个错误信息非常明确,看到
both mapped to the url-pattern就去全局搜这个路径,一定有两处。场景二:同一个
servlet-name同时出现在注解和 web.xml。 这时 web.xml 优先:web.xml里配的<url-pattern>会覆盖注解里的urlPatterns;如果web.xml里只声明了<servlet>却没写<servlet-mapping>,则注解里的路径仍然生效(两者合并)。所以“我注解改了路径怎么不生效”:先去web.xml搜有没有同名 Servlet。场景三:
web.xml设了metadata-complete="true"。 注解全部失效,但不报错,只是 404。这是最麻烦的一种,因为没有任何错误提示。【对比】 注解 vs web.xml,项目里该用哪个?
对比项 @WebServlet注解web.xml出现版本 Servlet 3.0+ 一直都有 配置位置 紧贴代码,改路径不用跳文件 集中在一个文件,一眼看全 修改后是否要重启 改注解通常要重启(类变了) 改 web.xml一般也要重启(除非配了热部署)可维护性 类多了以后“路径散落各处” 所有映射集中,适合大项目审查 优先级 低(被 web.xml 覆盖) 高 现状 新项目主流,代码简洁 老项目、需要集中管控时使用 结论:学习阶段用注解(少写一堆 XML),维护老项目必须会看 web.xml,考试两种都要会写。
5.7 Servlet 的线程安全问题
5.7.1 为什么会有线程安全问题
把 5.3 的两个事实拼起来,问题就出来了:
- 事实一:一个 Servlet 类在容器里只有一个实例(单例)。
init()只调一次,destroy()只调一次,说明对象从头到尾就一个。 - 事实二:
service()每个请求调用一次,而 Tomcat 是用线程池处理请求的,几十个请求就是几十个线程同时在跑。
两者一相乘:多个线程,同时调用同一个对象的方法。这就是“单例多线程”模型。
【补充】严格说,Servlet 的单例是“每个 Servlet 声明一个实例”,不是“每个类在全 JVM 只有一个”。如果你在
web.xml里用两个不同的<servlet-name>声明了同一个类,容器会创建两个实例。但这个细节不影响结论:同一个 Servlet 的多个请求,一定共享同一个实例。
5.7.2 成员变量为什么危险
@WebServlet("/count")
public class CountServlet extends HttpServlet {
// 危险:实例变量被所有线程共享
private int count = 0;
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws IOException {
count++; // 非原子操作
resp.getWriter().write("count = " + count);
}
}这段代码有两个层次的问题:
第一层:count++ 不是原子操作。 它实际是“读 → 加一 → 写回”三步,两个线程可能同时读到 count = 5,各自加一写回 6,两次自增只生效一次,结果偏小。这叫“丢失更新”。
第二层:即使 count++ 是原子的,count 本身也不该存在这里。 因为它是“所有用户共享”的:A 用户访问一次、B 用户访问一次,C 用户看到的是 2。如果这个计数代表“C 用户自己访问了几次”,那就彻底错了。数据该放哪,取决于它的作用域:请求级放方法内局部变量,会话级放 HttpSession,应用级放 ServletContext,持久级放数据库。
正确的写法有三种:
// 写法一:用局部变量(请求级数据,最推荐)
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws IOException {
int count = 0; // 每个线程一份,栈上独立,绝对安全
count++;
resp.getWriter().write("count = " + count);
}
// 写法二:确实要全局计数,用原子类
private final AtomicInteger count = new AtomicInteger(0);
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws IOException {
int now = count.incrementAndGet(); // 原子操作,线程安全
resp.getWriter().write("count = " + now);
}
// 写法三:必须用普通成员变量时,加锁(性能差,慎用)
private int count = 0;
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws IOException {
synchronized (this) { // 锁住当前 Servlet 实例
count++;
resp.getWriter().write("count = " + count);
}
}【补充】哪些东西是线程安全的、哪些不是,列个清单最好记:
安全:
service/doGet/doPost方法里的局部变量(每个线程独立的栈帧);方法参数req、resp(每次请求新建,各线程各一份);AtomicInteger等原子类。不安全:Servlet 的实例变量(成员变量);
ServletContext的属性(全局共享,所有用户可见);HttpSession的属性(同一用户多标签页并发时也不安全);静态变量(JVM 级共享,比实例变量更危险)。原则是:能放局部变量就别放成员变量;要共享就用线程安全的容器或加锁。
【考点】 “Servlet 是单例的,所以它线程不安全” 这句话要会说完整:Servlet 容器为每个 Servlet 声明只创建一个实例,多个请求由不同线程并发调用该实例的
service方法,因此如果 Servlet 持有可变的实例状态,就会产生竞态条件;而只使用局部变量和请求参数的 Servlet 是线程安全的。这也解释了为什么 Servlet 规范里那个SingleThreadModel接口被废弃了:它让容器为每个请求排队或新建实例,性能和内存都不可接受。
5.8 本章故障速查表
把前面散落的排错点集中一下,考前扫一遍比翻十页笔记有用。
| 现象 | 最可能的原因 | 排查动作 |
|---|---|---|
| 启动窗口一闪而过 | JAVA_HOME 没配或指向 JRE | 新开 cmd 跑 echo %JAVA_HOME%,再到 bin 下跑 catalina.bat run 看报错 |
启动报 BindException | 8080 被占用 | netstat -ano | findstr :8080 找到 PID 后 taskkill |
启动报 Invalid <url-pattern> | 注解路径没以 / 开头 | 检查 @WebServlet("...") 里的字符串 |
启动报 both mapped to the url-pattern | 同一路径配了两个 Servlet | 全局搜该路径,删掉其中一个 |
启动报 ClassNotFoundException | <servlet-class> 没写包名,或类没编译到 classes | 检查全限定名与 WEB-INF/classes 目录 |
| 访问 404 | 路径写错 / urlPattern 不匹配 / 项目没部署 | 见下方“404 三原因” |
| 访问 405 | 只写了 doPost,却用 GET 访问 | 补 doGet,或让 doGet 调 doPost |
| 访问 500 | 代码抛了未捕获异常 | 看 logs/localhost.*.log 的异常栈 |
| 中文乱码 | 控制台编码 / 响应未设编码 | 改 conf/logging.properties,响应设 setContentType |
| 改了代码不生效 | 类没重新编译,或容器没 reload | 重新 Build,或配置 Update classes and resources |
【易错点】404 的三种常见原因,按出现频率排序:
① URL 写错。少写项目名(
/demo1而不是/web-demo/demo1)、多写斜杠(//demo1)、大小写不符(/Demo1vs/demo1)、端口写成 80 但是 Tomcat 还在 8080。先逐段核对 URL 的四段结构。② urlPattern 不匹配或注解没生效。注解值没以
/开头(这种情况在启动时就会报错)、web.xml是 2.3 旧版本导致注解被忽略、metadata-complete="true"、@WebServlet写在了抽象类或接口上(注解只对具体类生效)。检查work目录和启动日志里有没有这个 Servlet 的注册记录。③ 项目根本没部署成功。
webapps下没有这个目录、IDEA 的 Artifact 没构建(WEB-INF/classes是空的)、Application context设的不是你以为的那个值、war 包损坏没解压。去webapps看目录在不在,去WEB-INF/classes看.class在不在。
本章小结
本章的主线只有一条:Tomcat 是容器,Servlet 是被容器管理的组件,你写的类只是“图纸”。
| 主题 | 必须记住的结论 |
|---|---|
| Web 服务器 | 封装 HTTP 协议操作、简化开发;能把 web 项目部署进去对外提供服务 |
| Tomcat 定位 | Apache 开源免费轻量级 Web 服务器,支持 Servlet/JSP 少量 JavaEE 规范,又称 Web 容器、Servlet 容器 |
| 七个目录 | bin 脚本、conf 配置、lib 容器类库、logs 日志、temp 临时、webapps 部署、work JSP 编译产物 |
| 部署 | 放 webapps 即完成;war 包自动解压;另可用 server.xml 的 <Context> 或 conf/Catalina/localhost/*.xml |
| Servlet 定义 | JavaEE 规范之一,是一个接口;Java 提供的动态 Web 资源开发技术;由 web 服务器创建并调用 |
| 生命周期 | 实例化 → init() 一次 → service() N 次 → destroy() 一次;loadOnStartup 负数=首次访问建,非负=启动建 |
| 体系结构 | Servlet 接口 → GenericServlet 抽象类 → HttpServlet 抽象类 → 你的类 |
| HttpServlet 原理 | 取 req.getMethod(),按 GET/POST 分发到 doGet/doPost;默认实现返回 405 |
| urlPattern | 精确 > 目录 > 扩展名 > /* > /;/ 顶替 DefaultServlet,/* 匹配任意 |
| 两种配置 | 注解 @WebServlet(3.0+)与 web.xml;web.xml 优先,同路径重复配置启动即失败 |
| 线程安全 | 单例 + 多线程 → 成员变量危险,局部变量安全 |
这章的知识在下章立刻被用到:service(ServletRequest req, ServletResponse res) 这两个参数,第6章会拆成 Request(取参数、取请求头、转发)和 Response(写数据、设状态码、重定向)两条线分别讲透。而第7章的 Cookie 与 Session,讲的是“Servlet 如何在多次请求之间保持状态”,正好接着本章“Servlet 是无状态处理的”往下讲。
自测
一、基础题
- 说出 Tomcat 七个目录(
bin、conf、lib、logs、temp、webapps、work)各自的作用。 - 写出
Servlet接口的五个方法签名,并说明哪几个是生命周期方法。 - 简述 Servlet 生命周期的四个阶段,并指出
init、service、destroy各被调用几次。 loadOnStartup = -1和loadOnStartup = 1有什么区别?- 写出 urlPattern 四种匹配规则的名称,并按优先级从高到低排序。
二、进阶题
- 看代码写结果:下面这个 Servlet 用浏览器访问
/demo,控制台会打印什么?
@WebServlet("/demo")
public class Demo extends HttpServlet {
@Override
protected void service(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
System.out.println("service ...");
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
System.out.println("get ...");
}
}- 找错:下面的 Servlet 部署后访问报 404,指出至少两处问题。
@WebServlet("demo")
public class Demo2 implements Servlet {
public void service() {
System.out.println("service ...");
}
}- 一个项目里有四个 Servlet,分别配置
/user/add、/user/*、*.do、/*。请求/user/add.do会命中哪一个?请求/user/add呢?请求/other/x.do呢? - 简答:为什么说“Servlet 是线程不安全的”?给出至少两种解决办法。
- 简答:
/和/*的区别是什么?配了/之后为什么页面里的图片会 404?
参考答案
bin启动/关闭脚本;conf配置文件(server.xml、web.xml、logging.properties等);libTomcat 运行所需 jar;logs运行日志;temp临时文件;webapps项目部署目录;workJSP 编译后的中间文件。void init(ServletConfig config)、void service(ServletRequest req, ServletResponse res)、void destroy()、ServletConfig getServletConfig()、String getServletInfo();前三个是生命周期方法。- 加载和实例化 → 初始化(
init,1 次)→ 请求处理(service,每个请求 1 次)→ 服务终止(destroy,1 次)。- 负数是第一次被访问时才创建对象;
1是服务器启动时就创建,且数字越小优先级越高。- 精确匹配、目录匹配、扩展名匹配、任意匹配;优先级:精确 > 目录 > 扩展名 >
/*>/。- 只打印
service ...。因为重写了service却没有调用super.service(req, resp),HttpServlet的分发逻辑被切断,doGet永远不会执行。- ① 注解值
"demo"没有以/开头,启动时报Invalid <url-pattern>;②service()没有参数,没有实现接口的service(ServletRequest, ServletResponse),类无法编译(抽象方法未实现);③ 即使能跑,implements Servlet也缺init、destroy、getServletConfig、getServletInfo四个方法。/user/add.do命中/user/*(目录优先于扩展名);/user/add命中/user/add(精确最优先);/other/x.do命中*.do。- 容器为每个 Servlet 只创建一个实例,多个请求由不同线程并发调用同一实例;若持有可变实例变量就会产生竞态。解决:改用局部变量、用
AtomicInteger等原子类、对共享代码加synchronized锁。/会覆盖 Tomcat 的 DefaultServlet,只在其他规则都匹配不上时兜底;/*匹配任意访问路径。配了/后,静态资源请求(图片、CSS、JS)也会进你的 Servlet,而它没有读文件写响应的逻辑,所以全部 404。