Servlet这东西,说起来有点“老”,但如果你想搞明白Java Web到底是怎么跑起来的,它还真躲不开。我带团队和面试新人的时候发现一个现象:不少人上来就学Spring Boot,写接口已经非常熟练,但你把问题往回问一层——浏览器发出一个HTTP请求之后,数据是怎么一步步走到你的Java方法里的?很多人就答不上来了。Servlet恰恰就是那个被跳过的“底层”。
这篇内容不打算做教科书式的功能罗列。我会从一个实际写过大量Servlet代码的人的角度,帮你把Servlet的核心体系、生命周期、请求处理链路、从零搭建一个可运行的项目、以及那些在真实环境里一定会踩的坑,一次性讲透。无论你是刚学Java Web的初学者,还是用框架两年但没看过底层的开发者,这篇文章都值得花二十分钟看完。
1. Servlet的核心体系与设计思路
1.1 Servlet到底解决了什么问题
要理解Servlet的价值,得先看它出现之前的Web开发是什么样子。早期的CGI(公共网关接口)时代,服务器每收到一个HTTP请求,就要为这个请求创建一个独立的操作系统进程。进程的创建、销毁、上下文切换都是重量级操作,并发一高,服务器的资源就迅速耗尽。
Servlet的出现改变了这个局面。它跑在JVM进程内部,容器(比如Tomcat)启动后,Servlet类被加载并实例化一次,之后所有请求都复用这同一个实例,由容器从线程池中分配工作线程来处理每个请求。换句话说,从“一个请求一个进程”变成了“一个请求一个线程”,资源开销降低了一个数量级,这也是Servlet能成为Java Web基石的最根本原因。
另一个关键价值是它提供了统一的请求-响应抽象。HTTP报文是纯文本协议,如果让你自己从Socket读取数据再解析请求行、请求头、请求体,那写起来又繁琐又容易出错。Servlet规范里定义了HttpServletRequest和HttpServletResponse,把报文内容封装成Java对象,你只需要调用方法就能拿到参数、读取流、写出响应。
开发者的关注点因此被大大简化:不用关心Socket,不用关心TCP,不用手工拼HTTP响应报文,只需要在Servlet里写业务逻辑。打个比方,Servlet是处理具体业务的“员工”,Tomcat这类容器是“前台+行政+后勤”,它负责接电话(网络监听)、帮你把电话内容整理成工单(Request对象)、再把你的回复转达给对方(Response对象)。
1.2 容器与Servlet的协作关系
很多人把Tomcat和Servlet混为一谈,其实它们的分工完全不同。Tomcat是Servlet容器,它实现了Servlet规范,负责管理Servlet的整个生命周期,同时承担HTTP服务器的职责——监听端口、接受连接、解析协议、管理线程池。
Servlet本身只是一个遵循规范编写的Java类,它不知道网络连接的存在。两者通过一套标准接口通信,这套接口就是Servlet API。容器从端口收到HTTP请求后,解析请求数据,创建一个Request对象和一个Response对象,然后按照映射规则找到处理这个URL的Servlet,调用它的service()方法,把两个对象传进去。Servlet处理完业务逻辑后,把结果写入Response对象,容器再把Response对象中的内容包装成HTTP响应报文,通过网络返回给客户端。
这种分级设计带来的好处是可以遵循“约定优于配置”的思路——你不必关心容器是怎么实现的,只要Servlet类写得符合规范,换一个容器(Tomcat、Jetty、Undertow)照样能跑。这也是为什么Servlet规范存在了二十多年,依然是Java Web世界的基石。
2. Servlet的核心运行机制与生命周期
2.1 生命周期那五个阶段
Servlet的一生可以用五个节点概括:加载、实例化、初始化、服务、销毁。看起来简单,但每个节点的时机和执行次数都暗藏细节。
加载和实例化一般发生在两个时间点:容器启动时,或者第一次请求到达时。 具体取决于配置的load-on-startup属性。在web.xml里给Servlet配一个正整数,比如1,容器启动时就会提前实例化它;值越小,启动优先级越高。如果不配置,默认是首次请求时才创建实例。
实例化完成后,容器调用init(ServletConfig)做初始化。这个方法在整个生命周期中只执行一次。 init方法里适合做什么?读取初始化参数、初始化数据库连接池、启动后台线程、加载配置文件等一次性工作。如果这里抛出ServletException,说明初始化失败,这个Servlet会被标记为不可用,容器会销毁它。很多新手不知道这个特性,把初始化逻辑写在构造方法里,导致拿不到ServletConfig配置。规范要求你必须在init方法里调用super.init(config),否则后续拿不到配置对象。
服务阶段是请求进来时执行,对应service()方法。这里有一个必须刻进脑子的事实:Servlet是单实例多线程的。容器会从线程池抽调线程,多个线程同时执行同一个Servlet实例的service()方法(通过它分发到doGet/doPost方法里)。实例变量是所有线程共享的,如果写代码不设防,高并发下就会出现数据错乱。
销毁阶段由容器调用,发生在应用卸载或容器关闭时。通常只有一次。 你可以在destroy()里释放连接池、停止后台线程。但要注意,容器不保证destroy一定在JVM退出前被调用,比如进程被强杀的情况。所以重要的持久化动作不要写在这里,数据落库这种操作应该在业务代码里同步完成。
2.2 请求处理核心链路
一次请求从浏览器发出到Servlet处理完,中间经过的环节比大多数人想象的多。拆开看是这样的:
浏览器输入URL并访问,DNS解析域名得到IP,建立TCP连接,发出HTTP请求报文。报文到达Tomcat时,由连接器(Coyote)组件负责监听和读取字节流。连接器解析完请求行、Header、Body之后,根据配置把数据封装成org.apache.catalina.connector.Request(一个实现了HttpServletRequest接口的对象)。接着请求进入容器内部管道:Engine -> Host -> Context,逐级匹配虚拟主机和Web应用,最终定位到目标Servlet以及它所属的Filter链。
这里有一点值得展开:请求在到达Servlet之前,会先经过当前应用的所有匹配的过滤器。Filter是Servlet规范里一个非常重要的扩展点。一个常见的用途是统一编码设置——在过滤器里对每个请求调用request.setCharacterEncoding("UTF-8"),这样所有Servlet都不用再重复设置请求编码。另一个典型用途是登录校验:判断Session是否存在,如果未登录直接重定向到登录页,不放行到Servlet。
请求真正进入Servlet后,容器调用Servlet的service(ServletRequest, ServletResponse)方法。这个方法是从GenericServlet继承下来的,HttpServlet在实现时会对请求对象做向下转型,然后根据HTTP方法类型,自动分发到doGet、doPost、doPut、doDelete等方法。如果你在Servlet里重写了doGet但没有重写doPost,而客户端用POST提交,默认行为是返回405错误提示Method Not Allowed。
2.3 关键API与高频方法速查
HttpServletRequest和HttpServletResponse这两个对象是开发者每天打交道的核心API,熟练者闭着眼也能写出来,但新手经常混淆一些方法的使用场景。
从请求对象拿数据,最常见的是下面这些:
- getParameter(name):取请求参数的聚合方法,同时支持URL上的查询参数和POST表单中的body参数。注意它只适合读取普通文本,不适合读取JSON请求体。
- getHeader(name):读取请求头。做鉴权时经常用来取Authorization头。
- getSession() / getSession(true):获取或创建当前会话。
- getRequestDispatcher(path):获取转发器。配合forward()可以把请求转发给另一个Servlet或JSP,客户端看到的URL不会改变,这是转发和重定向的一个核心区别。
- setAttribute / getAttribute:在请求范围内传递数据。通常在Servlet里把数据处理结果放入request,再转发给JSP渲染。
从响应对象写数据,核心是下面这些:
- setContentType("text/html;charset=UTF-8"):先设置Content-Type,其中charset同时规定了响应体的编码方式,直接影响浏览器如何解码。
- setCharacterEncoding("UTF-8"):单独设置响应字符编码,注意必须在getWriter()被调用之前执行。
- getWriter():返回PrintWriter,适合输出文本内容。
- getOutputStream():返回ServletOutputStream,适合输出二进制内容,比如图片、文件下载。两者不能同时使用。
- sendRedirect(url):重定向,浏览器会重新发起一个新请求,URL会改变。
ServletConfig和ServletContext也经常用到。ServletConfig是单个Servlet的配置对象,对应web.xml中该Servlet下的 ;ServletContext是全局应用对象,整个Web应用只有一份,适合存放全局配置和应用级别的共享数据。比如一个数据源连接,可以在ServletContextListener中初始化后放进ServletContext,所有Servlet都能取到。
3. 从零搭建一个可运行的Servlet项目
3.1 环境准备与工程结构
动手写代码之前,先列一下需要的东西:JDK 8或更高版本、IDEA或你顺手IDE、Maven、一个本地的Tomcat。我用的是Tomcat 9来配合Servlet 4.0规范,你选Tomcat 10之前要注意,它的Servlet API包名已经从javax.servlet迁移到了jakarta.servlet,很多老教程的代码在不改导入包的情况下直接跑不起来,这一点必须提前提醒。
新建一个Maven项目,group/artifact随意。在pom.xml里加Servlet API依赖,但一定记得把scope设置为provided:
<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency>provided的意思是编译时需要,但打包部署时不包含。因为Tomcat自己带了Servlet API的实现,你要是把Servlet依赖打成war包带进去,运行时会和容器自带的类冲突,典型情况是出现NoSuchMethodError或ClassCastException,非常难排查。
标准的Web应用目录结构长这样:
src/main/java # Java源代码 src/main/webapp/ # Web根目录 src/main/webapp/WEB-INF/ src/main/webapp/WEB-INF/web.xmlWEB-INF目录是Web应用的私有区域,外部浏览器无法直接访问它下面的任何资源。web.xml是部署描述符,Servlet 3.0之后它不是必须的了,但加上它有些场景会很方便,后面详细说。 还应该建立一个index页面,如果要是部署后访问根路径需要一个默认欢迎页面,通常在这里配。
index.html可以写一个最简单的页面,把地址指向后面的Servlet。
3.2 写第一个能处理的Servlet
写一个Servlet其实非常直接:继承HttpServlet,重写doGet或doPost。下面这个例子是一个返回JSON数据的接口:
package com.example.demo; 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("/hello") public class HelloServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType("application/json;charset=UTF-8"); String json = "{\"code\":0,\"message\":\"success\",\"data\":\"Hello Servlet\"}"; resp.getWriter().write(json); } }为什么要继承HttpServlet而不是直接实现Servlet接口?因为Servlet接口里定义了一堆生命周期方法,而这些方法在GenericServlet里大部分都有了默认空实现;HttpServlet进一步帮你在service()中做好了doGet/doPost的分发。你如果直接实现Servlet接口,就得自己从ServletRequest里判断请求方法,然后手动分流,纯属给自己找麻烦。
注意一个细节:doGet方法的签名上没有throws ServletException,而doPost在HttpServlet里是这个异常。如果你的业务代码需要抛出ServletException,可以自行在方法上声明,不会影响容器调用。重写与否的关键是方法签名要与父类一致,参数类型不能变。
3.3 配置映射的两种方式
写好了Servlet类,接下来要告诉容器访问路径。上面代码里用了@WebServlet("/hello")这行注解,这是Servlet 3.0开始支持的写法,容器启动时会自动扫描注解并注册Servlet。省事是省事,但对路径的管理分散在各个类里。
另一种方式是使用web.xml配置。假设我们要把HelloServlet映射到/cli路径,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_4_0.xsd" version="4.0"> <servlet> <servlet-name>hello</servlet-name> <servlet-class>com.example.demo.HelloServlet</servlet-class> <load-on-startup>1</load-on-startup> <init-param> <param-name>adminPhone</param-name> <param-value>13800000000</param-value> </init-param> </servlet> <servlet-mapping> <servlet-name>hello</servlet-name> <url-pattern>/hello</url-pattern> </servlet-mapping> </web-app>用xml的好处是,init-param、load-on-startup这些额外配置在注解方式下配置起来很别扭。注解虽然提供了initParams属性可以配置初始化参数,但代码可读性明显不如xml直观。 你在真实项目里两者都会见到,所以要都会看。
url-pattern的匹配规则也要弄清楚。它有三种主流写法:精确匹配,比如/hello;路径匹配,比如/api/,此时访问/api/anything都能命中;扩展名匹配,比如.do,所有以.do结尾的URL都能命中。 有个容易坑人的点是“/”这个写法,它匹配所有URL,如果你是用来做过滤器,一般没问题,但如果你把Servlet映射到/,它会拦截包括静态资源在内的所有请求,导致很多行为变得奇怪。
3.4 部署与验证
在IDEA里跑Servlet项目最直接的方式是配置Tomcat运行环境。进入Run菜单,选择Edit Configurations,点加号找到Tomcat Server,选Local。在Deployment标签页里点加号,选择Artifact,把项目的war包或war exploded加进去。两者区别是:war包是完整打包的压缩包,需要容器解压;war exploded是展开目录,启动更快,开发调试时能实现热更新。
配置好之后,注意Application context这个值。如果你设置为/,访问路径就是http://localhost:8080/hello;如果你设置为/servlet-demo,那路径就变成http://localhost:8080/servlet-demo/hello。很多新手第一次部署后访问404,多半就是这里少加了context-path。
启动后控制台看到“Server startup in xxx ms”说明容器起来了。浏览器访问对应地址,页面返回JSON字符串,第一个Servlet就算跑通了。这个完整流程虽然简单,但它是理解后面所有Java Web框架的起点——Spring MVC的核心入口,本质上就是一个大Servlet(DispatcherServlet),只是它在内部做了更强大的路由分发和依赖注入。
4. 常见问题与实战排查
4.1 中文乱码问题详解
乱码是Servlet开发中遇到最多的坑,没有之一。它不是一个问题,是三个不同环节的问题。
请求编码。 如果客户端以POST方式提交表单,请求体里的中文参数要能被正确读取,必须在你读取第一个参数之前调用request.setCharacterEncoding("UTF-8"),优先级最高的位置是Servlet代码的第一行,或者在Filter里统一设置,这点前面提过。如果你已经调用过getParameter再设置编码,是无效的,因为参数已经按旧编码解析过了。
GET请求的编码又不一样。Tomcat 8开始默认对URI使用UTF-8解码,所以一般问题不大,但如果你用老版本的容器,URL里的中文参数会变成乱码。老版本上你可以修改server.xml中Connector节点的URIEncoding属性,改成UTF-8。不过现在的Tomcat基本不用动这个配置,除非谁还在维护老古董版本。
响应编码。 输出中文乱码,通常是因为响应没用UTF-8。正确做法是在拿Writer之前同时设置:
resp.setContentType("text/html;charset=UTF-8"); resp.setCharacterEncoding("UTF-8");setContentType中的charset会影响Content-Type响应头,setCharacterEncoding负责实际编码。其实只要setContentType里包含了charset,容器就会用它来编码,但为了保险起见,显式双写也不多余。 还有一个更隐蔽的坑:你的页面声明了UTF-8,但服务端输出时用了ISO-8859-1,浏览器按页面声明的UTF-8解码,结果就是“锟斤拷”那种乱码。排查思路是从浏览器Network面板看响应头里的charset,再对照实际页面输出验收。
4.2 HTTP状态码错误排查
你部署Servlet时最常遇到的几个状态码,基本都能对应到特定原因。
404 Not Found意味着请求的URL在服务器上找不到匹配项。排查顺序是:先看地址栏的context-path对不对,再看servlet的url-pattern映射是否匹配,最后看应用是否成功部署。IDEA里最常见的迷之404是修改了url-pattern后没有重新部署war包,Tomcat还在跑旧代码。
405 Method Not Allowed说明URL找到了Servlet,但该Servlet没有处理当前请求类型的方法。比如URL映射到了Servlet,但客户端发的是POST,而Servlet只重写了doGet。记住HttpServlet的默认行为就是这样,对不支持的请求方法返回405。
500 Internal Server Error是最通用的服务器内部错误。打开IDEA控制台或Tomcat的logs目录,找到最新日志文件,重点看Caused by后面的堆栈。大多数情况下是空指针、类型转换、或者初始化异常。要注意的是,如果你在web.xml里没配error-page,容器会显示一个默认的500错误页,真正的异常信息在服务器日志里,不在浏览器页面上,所以不要只盯着页面看。
4.3 Servlet线程安全问题深度解析
线程安全是Servlet进阶路上绕不过去的坎。前面说了Servlet是单实例多线程的,这意味着所有实例变量的值都会在多个线程之间共享。看下面这个例子:
@WebServlet("/counter") public class CounterServlet extends HttpServlet { private int count = 0; @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { count++; resp.getWriter().write("count=" + count); } }这代码单线程跑没问题,但并发下count的累加会丢失。两个线程同时读到count=10,各自加1后写回11,结果应该是12却变成了11。这里可以用AtomicInteger解决,但更重要的是理解问题的本质:类字段是共享的,方法内局部变量是每个线程各有一份的。
再举一个开发中几乎必踩的坑:在Servlet里使用SimpleDateFormat。这东西不是线程安全的,多个线程同时格式化同一实例,会得到错误数据。解决办法是用ThreadLocal保存SimpleDateFormat实例,或者在Java 8之后直接用新的时间API(LocalDate/LocalDateTime配合DateTimeFormatter),它就是线程安全的。
还有一个经验法则:Servlet代码尽量写成“无状态”的。能用局部变量解决的问题不要用字段,必须共享的字段要么是只读的配置数据,要么使用线程安全集合或原子类。锁定一段业务代码也行,但不要锁太久,否则高并发下性能直接崩掉。
4.4 Session机制与状态保持实战
HTTP协议本身是无状态的,每个请求都可以看成是独立的。Session机制就是靠Cookie来让服务端能认出“这是同一个用户”。第一次请求时,容器调用req.getSession()会创建一个会话,同时通过响应头Set-Cookie设置一个名为JSESSIONID的Cookie给浏览器。后续每个请求,浏览器会自动带上这个Cookie,容器据此找到之前的Session对象。
实战中最常见的Session相关问题是“会话莫名其妙丢失”。排查要点有这几个:一是检查Cookie是否正常生成以及路径是否正确。默认情况下JSESSIONID的Cookie路径是当前Web应用的context-path,也就是说一个应用设置的Session Cookie不会被另一个应用读取,这是符合预期的。二是部分部署环境使用域名不加端口方式访问,JSESSIONID的Cookie如果设置了Secure或SameSite属性,某些情况下可能导致跨域携带失败。三是浏览器禁用了Cookie,那Session基本是不可用的,这种情况下很多框架会退化成URL重写来传递Session ID。
Session超时时间默认一般是30分钟,可以在web.xml里调整:
<session-config> <session-timeout>60</session-timeout> </session-config>单位为分钟。这个配置是分钟还是秒曾经有人问过我,规范写得很清楚是分钟。
还有一个在集群部署时躲不开的问题:Session共享。默认每个Tomcat节点的Session各自为政,用户在节点A登录了,下一次请求被负载均衡转发到节点B,节点B不认识这个Session,用户就被判定为未登录。常见的解决方案是Spring Session外加Redis存储,把Session数据集中到一个Redis集群里,所有节点都从Redis读写Session。理解了这个背景,再看Spring Session的使用就会通透很多。
4.5 一些容易忽略的实战细节
上面这些大的问题上手之后,还有些零碎的小细节值得知道。比如重定向和转发的区别。转发是服务器内部的跳转,整个过程只有一次请求,浏览器地址栏URL不变。重定向是服务器给浏览器返回一个302状态码和新的Location地址,浏览器再发起第二次请求,地址栏会变化。服务端代码里转发能用request.getRequestDispatcher(target).forward(),重定向用response.sendRedirect(target)。原则是:数据要从Servlet带过去给下一个页面展示用转发,支付跳转这类需要用户看到地址变化的用重定向。
再比如请求体比较大的场景。表单编码为multipart/form-data时,Servlet 3.0之后可以直接用request.getPart()获取上传的文件部分,而不需要引入第三方组件。相关配置是把@MultipartConfig放到Servlet上。不过真实项目里处理上传一般还是用框架提供的功能,或者直接走对象存储的上传URL,因为大文件直接经过应用服务器效率不高,而且会占用连接和线程资源。
4.6 如何快速定位线上问题
最后分享几个定位线上Servlet问题的经验。很多时候,报错发生在生产环境而不是本地,排查步骤可以先沿着日志走。Tomcat的logs目录下面会有几个文件:localhost.log记录应用启动和报错信息,catalina.out记录容器本身的运行日志,access log记录每一次请求的访问情况。如果你配置了access log并开了请求耗时记录,你可以精确到每一次请求的处理时长,判断是某个接口变慢了还是整体都慢了。
对接口返回的内容不对,但本地又复现不出来,可以优先考虑是不是环境差异导致的。比如本地JDK版本和生产不一致,比如本地容器是Tomcat 9而线上是Jetty,这些差异虽然小但偶尔会在某些边界条件下爆发。还有一个排查方向是请求头的变化,比如上线了新的反代设备,它有没有改写Host头、有没有在转发时去掉某些请求头字段,这些都会影响Servlet侧读取到的数据。
我发现一个非常实际的做法:在Servlet入口处加一行日志,打印请求URL、参数、客户端IP、耗时,看到整个请求生命周期。踩过几次坑之后你就发现,很多问题的答案其实就藏在日志里,关键是你要知道去哪看、看什么。
写在最后的体会
如果你认真从头看到了这里,你实际上已经具备了自己手写Servlet应用的能力。坦率讲,Servlet这个技术确实不够时髦,很多新人一上来就学Spring Boot,完全没有碰过Servlet。但我个人体会是:Servlet问题搞明白了,Spring MVC里的DispatcherServlet、HandlerMapping、拦截器,理解起来都会顺利得多,因为框架本质上就是Servlet外面的华丽包装。
最后再分享一个小建议:找时间自己从零建一个Servlet项目,不要用任何框架,手动写一个带登录、Session判断、几个前端页面的小Demo。你亲手处理过这些底层细节之后,再看任何Java Web框架的文档,很多都会觉得原来如此。这份动手经验带来的收益,远比多看几篇框架教程要大。