JavaWeb 复习讲义
原始讲义
第二部分 协议与容器:请求是怎么到达 Java 代码的

第5章 Tomcat 与 Servlet

难度:★★★☆☆ | 前置知识:第4章 HTTP 协议与响应状态码 | 本章解决:把“浏览器发来的一段 HTTP 报文”变成一个能运行的 Java 对象,并且搞清 Tomcat 是什么、Servlet 怎么写、为什么这么写。

本章导读

5.1 Web 服务器与 Tomcat

5.1.1 什么是 Web 服务器

Web 服务器的定义:一个能听懂 HTTP 协议的应用程序,它替程序员把协议细节全部挡在门外。

Web服务器是一个应用程序(软件),对 HTTP协议的操作进行封装,使得程序员不必直接对协议进行操作,让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 服务器的作用可以压缩成两句话,直接背下来即可:

  1. 封装 HTTP 协议操作,简化开发。
  2. 可以将 web 项目部署到服务器中,对外提供网上浏览服务。

5.1.2 Tomcat 简介

Tomcat 是 Apache 软件基金会一个核心项目,是一个开源免费的轻量级 Web 服务器,支持 Servlet/JSP 少量 JavaEE 规范。

三个定语都要会解释:

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 负责“按这个样子把它运行起来”。所以:

“容器”这个词很关键。容器 = 能装东西、能管理里面东西生老病死的运行环境。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.xServlet 3.0JSP 2.2JDK 6javax.servlet
Tomcat 8.0.xServlet 3.1JSP 2.3JDK 7javax.servlet
Tomcat 8.5.xServlet 3.1JSP 2.3JDK 7javax.servlet
Tomcat 9.0.xServlet 4.0JSP 2.3JDK 8javax.servlet
Tomcat 10.0.xServlet 5.0JSP 3.0JDK 8jakarta.servlet
Tomcat 10.1.xServlet 6.0JSP 3.1JDK 11jakarta.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 的启动脚本、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.md

bin:存放启动和关闭脚本。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)即可。

启动时可能出现的问题:

  1. 端口号冲突:找到对应程序,将其关闭掉。
  2. 启动窗口一闪而过:检查 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 到部署之间“多出来的那一步”:

src/main/java 编译出的 .class 和 src/main/resources 里的配置文件,最终都进 WEB-INF/classes;Maven 依赖的 jar 最终都进 WEB-INF/lib。这个搬运工作是 IDEA 构建 Artifact 时自动完成的,你只需要理解结果。

WEB-INF 这个名字必须一字不差,而且它有一个非常重要的安全特性下面会讲。

在 IDEA 里创建 Maven Web 项目有两条路。

路线一:使用项目模板。 archetype 就是 Maven 的项目模板。

  1. 选择 web 项目模板,创建项目。在 New Project → Maven 里勾选 Create from archetype,选 org.apache.maven.archetypes:maven-archetype-webapp。
  2. 删除 pom.xml 中多余的坐标。模板会写入一些示例依赖和过时的插件配置,留着只会拖慢构建。
  3. 补齐缺失的目录结构。模板默认没有 src/main/java 和 src/main/resources,要手动建,否则你连 Java 类都放不进去。

路线二:不使用项目模板。

  1. 创建一个普通 Maven 项目(不勾选 archetype)。
  2. pom.xml 中添加打包方式为 war:
<packaging>war</packaging>
  1. 补齐缺失的目录结构:手动创建 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 目录的两条铁律,考试和实战都爱考:

  1. 浏览器不能直接访问 WEB-INF 下的任何文件。访问 http://localhost:8080/web-demo/WEB-INF/web.xml 一定 404,这是 Servlet 规范强制的安全限制,所以 web.xml、classes、lib 天然受保护,配置和字节码不会被直接下载。
  2. 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 个阶段:

  1. 加载和实例化:默认情况下,当 Servlet 第一次被访问时,由容器创建 Servlet 对象。
  2. 初始化:在 Servlet 实例化之后,容器将调用 Servlet 的 init() 方法初始化这个对象,完成一些如加载配置文件、创建连接等初始化的工作。该方法只调用一次。
  3. 请求处理:每次请求 Servlet 时,Servlet 容器都会调用 Servlet 的 service() 方法对请求进行处理。
  4. 服务终止:当需要释放内存或者容器关闭时,容器就会调用 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 接口就这五个方法,全部要能说出作用:

void init(ServletConfig config)
void service(ServletRequest req, ServletResponse res)
void destroy()
ServletConfig getServletConfig()
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 {
    // ...
}

取值规则如下:

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 的体系结构是一条直线,三句话就能概括三个层次:

我们将来开发 B/S 架构的 web 项目,都是针对 HTTP 协议,所以我们自定义 Servlet,会继承 HttpServlet。

所以三者关系是:

        Servlet(接口)
            ▲
            │ implements
        GenericServlet(抽象类)
            ▲
            │ extends
        HttpServlet(抽象类)
            ▲
            │ extends
        你自己写的 ServletDemo

GenericServlet 帮你省了什么?它把 getServletConfig()、getServletInfo()、init()、log() 这些“和协议无关”的方法都实现好了,只留下 service(ServletRequest, ServletResponse) 一个抽象方法给你。所以继承 GenericServlet 时,你只需要写一个方法。

HttpServlet 又帮你省了什么?它把 service(ServletRequest, ServletResponse) 实现好了:先判断请求方式是 GET 还是 POST,再分别调用 doGet 或 doPost。所以继承 HttpServlet 时,你连 service 都不用写,只写 doGet/doPost 就行。

【对比】

三种实现方式的“要写几个方法”,直接决定了它们的实用价值:

实现方式需要重写的方法数能否直接拿 HttpServletRequest实际使用
implements Servlet5 个全写不能,只有 ServletRequest教学演示、理解接口
extends GenericServlet1 个(service)不能,参数仍是 ServletRequest极少用,非 HTTP 场景才考虑
extends HttpServlet1~2 个(doGet/doPost)能,参数是 HttpServletRequest99% 的实际项目

关键差异在最后一行: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 使用步骤:

  1. 继承 HttpServlet。
  2. 重写 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 的区别

这是本章最容易被忽略、又最能拉开分差的一个点。

【对比】

重写 service vs 重写 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 的配置有两条总纲和四条规则:

  1. 一个 Servlet,可以配置多个 urlPattern。
  2. urlPattern 配置规则:① 精确匹配 ② 目录匹配 ③ 扩展名匹配 ④ 任意匹配。

四条规则的“配置路径”和“实际能匹配的访问路径”最容易混,下面这张表可以直接照着抄(假设项目访问路径是 /web-demo):

匹配方式配置路径能匹配的访问路径说明
① 精确匹配/demo1http://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 / 与 /* 的区别

这两个长得像、作用完全不同,是必考对比题,结论先摆出来:

要理解第一句,得先知道 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 配置文件的配置方式。

步骤:

  1. 编写 Servlet 类。
  2. 在 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 的单例是“每个 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 看报错
启动报 BindException8080 被占用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)、大小写不符(/Demo1 vs /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 是无状态处理的”往下讲。

自测

一、基础题

  1. 说出 Tomcat 七个目录(bin、conf、lib、logs、temp、webapps、work)各自的作用。
  2. 写出 Servlet 接口的五个方法签名,并说明哪几个是生命周期方法。
  3. 简述 Servlet 生命周期的四个阶段,并指出 init、service、destroy 各被调用几次。
  4. loadOnStartup = -1 和 loadOnStartup = 1 有什么区别?
  5. 写出 urlPattern 四种匹配规则的名称,并按优先级从高到低排序。

二、进阶题

  1. 看代码写结果:下面这个 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 ...");
    }
}
  1. 找错:下面的 Servlet 部署后访问报 404,指出至少两处问题。
@WebServlet("demo")
public class Demo2 implements Servlet {
    public void service() {
        System.out.println("service ...");
    }
}
  1. 一个项目里有四个 Servlet,分别配置 /user/add、/user/*、*.do、/*。请求 /user/add.do 会命中哪一个?请求 /user/add 呢?请求 /other/x.do 呢?
  2. 简答:为什么说“Servlet 是线程不安全的”?给出至少两种解决办法。
  3. 简答:/ 和 /* 的区别是什么?配了 / 之后为什么页面里的图片会 404?

参考答案

  1. bin 启动/关闭脚本;conf 配置文件(server.xml、web.xml、logging.properties 等);lib Tomcat 运行所需 jar;logs 运行日志;temp 临时文件;webapps 项目部署目录;work JSP 编译后的中间文件。
  2. void init(ServletConfig config)、void service(ServletRequest req, ServletResponse res)、void destroy()、ServletConfig getServletConfig()、String getServletInfo();前三个是生命周期方法。
  3. 加载和实例化 → 初始化(init,1 次)→ 请求处理(service,每个请求 1 次)→ 服务终止(destroy,1 次)。
  4. 负数是第一次被访问时才创建对象;1 是服务器启动时就创建,且数字越小优先级越高。
  5. 精确匹配、目录匹配、扩展名匹配、任意匹配;优先级:精确 > 目录 > 扩展名 > /* > /。
  6. 只打印 service ...。因为重写了 service 却没有调用 super.service(req, resp),HttpServlet 的分发逻辑被切断,doGet 永远不会执行。
  7. ① 注解值 "demo" 没有以 / 开头,启动时报 Invalid <url-pattern>;② service() 没有参数,没有实现接口的 service(ServletRequest, ServletResponse),类无法编译(抽象方法未实现);③ 即使能跑,implements Servlet 也缺 init、destroy、getServletConfig、getServletInfo 四个方法。
  8. /user/add.do 命中 /user/*(目录优先于扩展名);/user/add 命中 /user/add(精确最优先);/other/x.do 命中 *.do。
  9. 容器为每个 Servlet 只创建一个实例,多个请求由不同线程并发调用同一实例;若持有可变实例变量就会产生竞态。解决:改用局部变量、用 AtomicInteger 等原子类、对共享代码加 synchronized 锁。
  10. / 会覆盖 Tomcat 的 DefaultServlet,只在其他规则都匹配不上时兜底;/* 匹配任意访问路径。配了 / 后,静态资源请求(图片、CSS、JS)也会进你的 Servlet,而它没有读文件写响应的逻辑,所以全部 404。