资讯动态

Servlet从原理到实战:手写Java Web请求处理全流程

发布时间:2026/9/10 9:58:30 来源:尧图企业网站定制
Servlet 这个技术很多朋友一听到就觉得是老古董尤其是在 Spring Boot 大行其道的今天好像直接上手 Controller 才是正道。但我在一线踩了这么多年坑可以很负责任地告诉你Servlet 是整个 Java Web 的地基Spring MVC 里的 DispatcherServlet 本质就是一个 Servlet你用的 Tomcat 就是一个 Servlet 容器。地基如果不稳后面排查问题是会怀疑人生的。这篇文章我会带你用最原始、最接近底层的方式把 Servlet 从创建到运行完完整整跑一遍。不用 Spring Boot不用任何高级封装就用手写代码加配置文件的方式把这里的“为什么”彻底讲透。如果你是刚学完 Java SE、准备进入 Web 开发的小白这篇文章就是为你准备的如果你已经用了一段时间 Spring Boot 但对底层一知半解看完也会有很多“原来如此”的时刻。1. 内容整体设计与思路拆解1.1 为什么 Spring Boot 时代还要学习 Servlet很多初学者会问一个特别现实的问题我直接用 Spring Boot 写接口不香吗为什么还要倒退回去学 Servlet这个问题我当年也问过。后来当我第一次遇到线程安全导致的线上 Bug、第一次排查请求 404 找到凌晨、第一次被过滤器链执行顺序搞到崩溃时才明白 Servlet 这套规则恰恰是所有上层封装的基石。Spring Boot 帮你接管了自动配置和服务器嵌入但它无法替你理解“请求到底是怎么进去的”、“一个请求从到达 Tomcat 到返回给浏览器的完整路径是什么”。如果你不懂 Servlet你对这些问题的认知就会停留在“调个接口就行”的层面。用一个生活化的类比来理解你把房子交给装修公司全包拎包入住当然爽。但如果某天水管爆了你连水管阀门在哪、总闸在哪个位置都搞不清楚只能干瞪眼。Servlet 就是这栋房子的水管系统和电路布局你把它的基本结构看明白了以后无论住多豪华的装修房心里都有底。从技术演进的角度看Servlet 规范定义了 Java Web 服务器的标准行为。你写的任何一个 Servlet本质上就是一个 Java 类但它继承了HttpServlet并重写了特定方法从而具备了接客接收 HTTP 请求的能力。Tomcat、Jetty 这些容器负责监听端口、解析 HTTP 协议、把请求封装成对象再调用你写的 Servlet 方法。这就是整条链路的核心。1.2 一次 HTTP 请求的旅行地图要真正理解 Servlet脑子里必须先建立起一张请求旅行地图。我们以访问http://localhost:8080/myapp/hello为例拆解一下这个过程中到底发生了什么。首先浏览器根据域名解析出 IP 和端口向 Tomcat 发起一个 TCP 连接紧接着发送基于 HTTP 协议格式的请求数据。Tomcat 作为服务器在这里扮演了两个角色一个是网络通信层的角色负责接收原始字节流另一个是协议解析器的角色把字节流解析成结构化数据包装成HttpServletRequest对象。这个HttpServletRequest对象里封装了什么包含请求行方法、URI、协议版本、请求头Header、请求体Body。Tomcat 会根据 URI 中的/myapp匹配到当前部署的应用再根据/hello去这个应用的映射表里找到对应的 Servlet 类。找到类之后容器会实例化它并调用service()方法。这个service()会根据请求方法GET、POST分发给doGet()或doPost()方法。你的 Servlet 方法处理完毕之后会往HttpServletResponse对象里写入响应内容。Tomcat 再把这个对象转换成 HTTP 响应字节流通过 Socket 回传给浏览器。浏览器拿到之后解析这就完成了一次完整的请求往返。这个流程就类似你去餐厅吃饭门口服务员Tomcat接待你问你吃什么解析请求把你的订单传递到后厨Servlet 映射后厨做完菜业务逻辑处理再由服务员端到你面前返回响应。你对顾客屏蔽了后厨的复杂细节但作为一个经营管理者你必须清楚后厨每个工位是干嘛的。2. 核心细节解析与实操要点2.1 深入 Servlet 的生命周期从生到死的完整过程Servlet 的生命周期是整个 Servlet 机制里最核心的概念也是面试和实际排查问题的高频考点。它一共分为四个阶段加载与实例化、初始化init、服务service、销毁destroy。加载与实例化发生在容器启动或第一次请求到达时。当你启动 Tomcat 时容器会根据配置决定是否要提前加载 Servlet通过load-on-startup配置控制数值越小优先级越高。如果没有提前加载容器会在第一个请求到达时进行实例化。这里有个关键点Servlet 是单例多线程的也就是说在整个生命周期中只有一个 Servlet 实例存在多个请求会并发调用这个实例的service()方法。初始化阶段会调用init(ServletConfig config)方法。这个方法只会执行一次通常用于加载数据库连接池、读取配置文件等资源初始化工作。我在实际开发中见过不少新手把重量级对象放在构造函数里这其实是不规范的。构造方法只是简单地创建实例而init()才是容器给业务方提供的钩子这个钩子保证了 ServletConfig 已经可用并且时机在单例化之后、正式对外服务之前。服务阶段是最繁忙的阶段。每次请求到达容器都会开启一个线程由线程池管理调用 Servlet 的service()方法。service()方法是一个调度中心它根据 HTTP 请求方法类型将请求转发给对应的doXxx()方法。比如你重写了doGet()那 GET 请求就会进入这个方法如果没有重写父类HttpServlet的默认实现会返回一个 405 错误。销毁阶段发生在容器关闭或应用卸载时。destroy()方法用于释放init()中加载的资源比如关闭连接池。这里有个重要细节容器保证在调用destroy()之前不会把任何新的请求分发给这个 Servlet。但如果有正在处理中的请求容器会等这些请求处理完才会执行销毁。这就带来一个经验教训不要在destroy()里做太耗时的操作否则会拖慢整个应用的停机速度。从开发者的角度你只需要关心init()、service()相关方法、destroy()这三个阶段。生命周期是容器管理 Servlet 的底层逻辑理解它你就不会在成员变量线程安全问题上犯低级失误。2.2 URL 映射规则为什么 404 总是找上门URL 映射是 Servlet 配置中的重灾区。很多新手明明把类写好了却总是 404大部分原因就是 URL 映射没配对。Servlet 的 URL 映射有三种规则精确匹配、路径匹配、扩展名匹配。精确匹配就是最直接的写死路径比如/hello。这种匹配方式最简单直接一个 URL 对应一个 Servlet。路径匹配使用通配符以/*结尾比如/api/*会匹配所有以/api/开头的请求。扩展名匹配是以*.开头比如*.do匹配所有以.do结尾的请求。这三者混用时的优先级是精确匹配最高其次是路径匹配最后是扩展名匹配。也就是容器在收到请求时会先在精确匹配表里找找不到再去找路径匹配最后才看扩展名匹配。这个优先级关系我用一个场景来验证如果你同时配置了/hello的精确匹配和/api/*的路径匹配访问/api/hello时会走到路径匹配的 Servlet因为精确匹配只认完全相等的/hello。还有一个极容易混淆的坑/和/*的区别非常大。/是当前应用的默认 Servlet它匹配应用中的所有请求但不会匹配 JSP 页面因为 JSP 页面在容器里被映射到了*.jsp的 Servlet。而/*是拦截所有请求包括 JSP。如果你把某个 Servlet 配置成/*你就把所有 JSP 页面都拦截了页面会直接报错。2.3 注解与 web.xml两条路殊途同归Servlet 3.0 之前所有 Servlet 的映射必须在web.xml中声明。这是标准的 XML 方式流程非常固化写一个类继承HttpServlet然后在web.xml中写servlet和servlet-mapping两个标签。而在 Servlet 3.0 之后规范新增了注解支持也就是现在你大概率见到的WebServlet(urlPatterns /hello)。使用注解时容器在启动时自动扫描注解完成注册。这在日常开发中确实方便了很多不用再维护一堆 XML。但你必须明白 WEB 项目中的一种真实场景如果你在web.xml中配置了load-on-startup结合metadata-completefalse默认值容器会同时扫描注解。如果你设置了metadata-completetrue容器就不会再扫描注解了。这意味着如果项目里既有 XML 又有注解很可能出现“我明明写了注解但没生效”的诡异问题。排查思路很简单看看web.xml的根标签是不是带了metadata-completetrue。在开发中注解适合小项目、快速原型XML 适合需要集中管理映射规则、或者与运维脚本配合的大型传统项目。理解这两种方式的本质你阅读开源代码时就不会被各种配置绕晕。3. 实操过程与核心环节实现3.1 环境准备版本匹配是第一步开始写代码之前先把环境搭好。这里最重要的不是“装好”而是“版本匹配”。很多新手栽跟头就栽在版本混用上。JDK 我推荐你用 8 或 11 都行这俩是目前企业使用最广的版本。Tomcat 的选择非常讲究如果你用的是 Tomcat 9 及以下Servlet API 的包名是javax.servlet如果你用的是 Tomcat 10 及以上包名已经升级成了jakarta.servlet。这一点绝对是新手最容易踩的巨坑。你从网上找教程发现别人代码里是import javax.servlet.http.HttpServlet但自己安装的 Tomcat 10 却找不到这个类于是怀疑自己配置有问题。实际上是因为 Tomcat 从 10 开始全面拥抱 Jakarta EE 9命名空间从javax迁移到jakarta。如果你跟着老教程用 Tomcat 10请把 import 改成jakarta.servlet.http.HttpServlet。开发工具我建议直接用 IntelliJ IDEA 社区版就够了操作路径和付费版完全一致。Maven 需要安装这是 Java 项目的管理和构建工具相当于前端的 npm 或 Maven 仓库。下面我操作的版本组合是JDK 11 Tomcat 9 Maven 3.8。这套组合兼容性最好即便你之后转 Spring Boot也不会遇到版本地狱。3.2 创建 Maven 项目与 pom.xml 配置打开 IDEA新建一个 Maven 项目不使用模板直接选“Maven”分类。GroupId 填com.exampleArtifactId 填servlet-demo版本就用默认的 1.0-SNAPSHOT。创建完成后项目结构里会有一个pom.xml我们先把它改造成适用于 Web 项目的配置。完整内容如下?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion groupIdcom.example/groupId artifactIdservlet-demo/artifactId version1.0-SNAPSHOT/version packagingwar/packaging properties maven.compiler.source11/maven.compiler.source maven.compiler.target11/maven.compiler.target project.build.sourceEncodingUTF-8/project.build.sourceEncoding /properties dependencies dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency /dependencies /project注意几个关键点。packaging必须设置为war因为 Web 应用需要打成 war 包部署到 Tomcat而不是普通 jar。javax.servlet-api的scope设置为provided很关键意思是编译时需要这个 API但运行时由 Tomcat 提供不需要打入最终的包里。如果你的 pom 里没有加这个依赖代码里import javax.servlet.*就会直接报红。3.3 编写第一个 ServletHelloServlet我们写一个最简单的 Servlet。在src/main/java/com/example包下新建一个HelloServlet.javapackage com.example; 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; import java.io.PrintWriter; WebServlet(/hello) public class HelloServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType(text/html;charsetUTF-8); PrintWriter out resp.getWriter(); out.println(html); out.println(body); out.println(h1Hello Servlet, 世界!/h1); out.println(/body); out.println(/html); } }这段代码做了这么几件事用WebServlet(/hello)注解声明这个 Servlet 的访问路径是/hello继承HttpServlet并重写doGet()方法处理 GET 请求。在方法里通过resp.setContentType(text/html;charsetUTF-8)设置响应内容类型和编码然后获取PrintWriter输出流把 HTML 字符串写回浏览器。这里有个小细节可以提一嘴setContentType的 charset 到底能不能省略。如果你不写charsetUTF-8Tomcat 默认会用 ISO-8859-1 编码你写入的字符结果就是中文变成乱码。这个坑特别常见所以每次写响应内容养成习惯先设置 ContentType。3.4 配置 IDEA 本地 Tomcat 并启动运行写完代码后需要配置一个运行环境。在 IDEA 右上角点开“Add Configuration”选择“”找到“Tomcat Server”下面的“Local”。点击“Configure”按钮选择 Tomcat 的安装路径。如果你的 Tomcat 是解压版的路径就是解压后的根目录IDEA 会自动识别版本来显示让你确认。接下来是最关键的部署设置。切到“Deployment”标签点击“”选择“Artifact”。IDEA 会看到你刚刚创建的 Maven 项目弹窗里有两种选择servlet-demo:war和servlet-demo:war exploded。开发阶段选war exploded它是解压目录的形式改代码编译后可以直接热更新不用重启整个 Tomcat。之后把“Application context”设置为/这样访问的时候就不用带项目名了直接是http://localhost:8080/hello。点击运行按钮Tomcat 启动控制台刷出日志。看到类似“Server startup in [xxx] milliseconds”的输出说明容器已经起来了。打开浏览器访问http://localhost:8080/hello你会在页面上看到一个大大的“Hello Servlet, 世界!”。3.5 进阶玩法实现一个带表单处理的登录接口只输出一个静态页面显然不够“超详细”。我们再加一个LoginServlet展示如何处理 POST 请求、读取表单参数、处理中文乱码以及做重定向。首先在webapp目录下新建一个login.html!DOCTYPE html html langzh head meta charsetUTF-8 title登录/title /head body form actionlogin methodpost 用户名input typetext nameusername/br/ 密码input typepassword namepassword/br/ input typesubmit value登录/ /form /body /html然后新建LoginServlet.javapackage com.example; 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(/login) public class LoginServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String username req.getParameter(username); String password req.getParameter(password); if (admin.equals(username) 123456.equals(password)) { resp.sendRedirect(hello); } else { resp.sendRedirect(login.html); } } }在doPost()里第一行req.setCharacterEncoding(UTF-8)是处理 POST 请求中文乱码的关键。HTML 页面使用的是 UTF-8 编码提交数据如果你不告诉容器用 UTF-8 来解码请求体容器默认用 ISO-8859-1 解码中文用户名就会变成一堆乱码。业务判断部分写死了一个账号密码正式项目你肯定要去查数据库但演示交互原理已经足够了。登录成功后用sendRedirect(hello)做重定向浏览器会重新发起一个 GET 请求到/hello登录失败就跳回登录页。sendRedirect的本质就是让浏览器重新访问一个新地址所以地址栏的 URL 会发生变化。这里跟forward转发做一个对比转发是在服务器内部完成的浏览器不知道这个过程地址栏不会变。它们之间的选择在后续学习 Spring MVC 时也会遇到提前动手跑一遍体会会更深。3.6 手动配置一个完整的 web.xml虽然注解方式很方便但我还是强烈建议你至少手动配置一次web.xml这样以后看到老项目不会发怵。在src/main/webapp/WEB-INF目录下新建web.xml?xml version1.0 encodingUTF-8? web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd version4.0 display-nameservlet-demo/display-name servlet servlet-namehelloServlet/servlet-name servlet-classcom.example.HelloServlet/servlet-class load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namehelloServlet/servlet-name url-pattern/hello/url-pattern /servlet-mapping /web-app配置里的servlet-class必须写类的全限定名不能只写类名。url-pattern里的路径就是对外访问的路径。load-on-startup1/load-on-startup表示容器启动时就实例化并初始化这个 Servlet而不是等第一个请求来了才初始化。如果你配置了注解WebServlet同时又在这个 web.xml 里配置了相同的类容器的行为是XML 配置优先注解会被忽略因为容器认为你在 XML 中已经显式声明了管理规则。4. 常见问题与排查技巧实录4.1 import 出错javax.servlet 与 jakarta.servlet 的世纪难题这是 Tomcat 10 发布之后出现频率最高的编译错误。场景是这样的你从网上下载了一个老项目代码里全是import javax.servlet.http.HttpServlet但你的 Tomcat 是 10.x编译时一直提示找不到包。原因我在前面讲过Tomcat 10 从 Java EE 迁移到了 Jakarta EE 命名空间包名整体从javax.*改成了jakarta.*。解决办法有几个第一个最直接把你的 Tomcat 换成 9.0.x老项目完全不变第二个如果你必须用 Tomcat 10那就全局替换所有javax.servlet为jakarta.servlet第三个用 IDE 的全局替换功能一键替换包名改完之后重新编译通常就没问题了。还有一个细节如果你在 Spring Boot 中使用内嵌 Tomcat也要注意版本对应关系。Spring Boot 2.x 默认使用 Tomcat 9所以代码用javaxSpring Boot 3.x 默认使用 Tomcat 10代码要用jakarta。你在网上看教程时先确认一下对方用的 Spring Boot 版本再决定自己代码里该写哪个包。4.2 404 错误排查先从路径开始找起部署启动之后访问http://localhost:8080/hello结果页面显示 404这是初学者最常见的打击。排查之前先明确一件事404 说的是“找不到资源”问题是这个资源到底叫什么。先看第一个容易踩的坑应用上下文路径context path。如果你在 IDEA 部署时Application context 设置的是/servlet-demo那么访问路径应该是http://localhost:8080/servlet-demo/hello。如果你访问的是http://localhost:8080/hello就相当于在根路径下找hello这个资源找不到当然 404。再看第二个坑注解里的映射路径。如果你写的是WebServlet(/HELLO)实际访问的是/HELLO大小写敏感。Tomcat 默认不会做大小写归一化转换。第三个坑是项目没有成功部署。启动日志里如果出现了类似“Deployment of web application archive [xxx.war] has failed”的报错说明打包或部署环节出了问题这时候要往前查日志而不是盯着浏览器地址栏发呆。我给新手一个统一的排查顺序先看控制台有没有启动异常然后看 IDEA 部署面板里的 context path最后看浏览器地址栏是不是和映射一致。按这个顺序走80% 的 404 都能解决。4.3 文件上传报错feignclient failed to parse multipart servlet request这个报错是微服务场景下的高频 Bug我在工作中碰到过很多次甚至有一些已经上线许久的服务突然开始报错。完整报错信息一般是feign.FeignException: [400] during [POST] to [http://xxx-service/upload] ...[400] during [POST]... Caused by: java.io.IOException: The temporary upload location [C:\Users\xxx\AppData\Local\Temp\tomcat.xxxxx.work] is not valid这个错误表面看是 Feign 客户端调上传接口时失败但根子出在 Servlet 容器处理 multipart 请求的临时目录上。用 Servlet 处理文件上传时容器需要把客户端发来的文件先写到服务器的临时目录再转交给业务代码处理。这个临时目录默认在系统的临时文件目录下比如 Linux 的/tmpWindows 的AppData\Local\Temp。为什么之前好好的突然就报“临时上传位置无效”了呢最常见的原因是操作系统或运维的定时清理任务把/tmp下旧文件删掉了而 Tomcat 在你第一次启动时记住的那个临时目录已经不存在了。在 Linux 服务器上这个问题尤为常见因为 systemd-tmpfiles 等清理任务默认会清理规定时间之前的文件。解决这个问题的思路就两个方向一是修改临时目录指向一个稳定持久的位置在 Spring Boot 的配置文件中显式设置spring.servlet.multipart.location/data/upload_tmp同时确保这个目录存在并且应用有写权限。另一个方向是让 Tomcat 在每次启动时强制重新创建临时目录但如果你用的是内嵌式 Tomcat这个行为的可控性不如外置配置。这个 Bug 必须记录在案因为它属于“不踩不会理解不记录下次还忘”的经典故障。4.4 中文乱码请求和响应的双重处理中文乱码在 Servlet 开发中简直是必修课。先说响应乱码。在doGet()或doPost()里如果你往PrintWriter中写中文但前面没有setCharacterEncoding(UTF-8)浏览器收到后用默认编码解析十有八九是乱码。正确的做法是先设置 ContentType再获取 Writerresp.setContentType(text/html;charsetUTF-8); PrintWriter out resp.getWriter();再说请求乱码。GET 请求的中文参数走的是 URL 地址默认编码取决于 Tomcat 配置。要正确处理 GET 请求的中文参数可以在 Tomcat 的conf/server.xml里给 Connector 设置统一 URIEncodingConnector port8080 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 URIEncodingUTF-8 /而 POST 请求的参数在请求体里必须用req.setCharacterEncoding(UTF-8)在读取参数之前设置才会生效。注意顺序很重要如果你先调用getParameter()再去设置编码那是无效的因为容器在第一次读取参数时就已经用默认编码解析完了。4.5 线程安全千万别在 Servlet 里定义可修改的成员变量Servlet 是单例的也就是说只有一个实例对象在服务所有请求。如果你在这个实例里定义了一个非静态的成员变量那么所有请求线程共享这个变量这就引发了并发问题。我举个例子假设你在 Servlet 里定义了一个private int count 0然后在doGet()里执行count。两个请求同时到达A 线程读了 count 等于 1B 线程也读了 count 等于 1各自加完写回最终 count 只变成了 2而不是 3。这就是典型的并发安全漏洞。解决方案很简单尽量使用局部变量局部变量是线程私有的不存在共享问题。如果确实需要共享状态就必须加锁或使用线程安全的集合。在实际开发的 Servlet 编程中最佳实践是只保留不可变对象和无状态方法就是常说的“无状态 Servlet”。还有一处容易忽略不要在init()里启动一些后台线程来轮询共享资源除非你非常明确这个线程的生命周期和销毁机制。否则应用在热部署或重启时容易遗留僵尸线程。最后再分享一个小技巧根据个人经验学习 Servlet 时不要只看不练一定要在本地把项目跑起来然后亲自去web.xml里改写映射规则做实验。比如故意把url-pattern写成/*看看会发生什么或者把一个 Servlet 映射到两个不同的路径看看容器怎么处理。只有亲手踩过这些坑你才能真正建立起对 Servlet 机制的立体认知。如果你接下来准备深入学习 Spring MVC回来看看这一篇你会发现DispatcherServlet的入口原理和 RequestMappingHandlerMapping 的映射机制本质上都是在 Servlet 规范之上做了一层扩展。地基打牢了上层建筑再花哨你也能一眼看穿它的结构。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价