不少刚接触Java Web的同学,第一次听说Servlet的时候都是一脸懵:这东西到底是个啥?为什么别人写接口直接一个注解就完事,我却要整一堆配置?更常见的问题是——“Servlet有没有main方法?它到底是怎么跑起来的?”
这篇文章我会把Servlet的核心功能从头到尾拆一遍,重点讲三件事:javax.servlet.http.HttpServletRequest这个请求对象到底能干什么;Servlet没有main方法却能被执行的完整逻辑链路;以及用最传统的web.xml方式手写一个能跑的Servlet完整示例。搞清楚这三件事,你后面看Spring MVC、过滤器、拦截器这些东西,基本就是降维打击。
1. Servlet在Java Web体系里的真实位置
1.1 没有Servlet之前的世界
先聊个背景。1990年代中期的Web服务器只能返回静态HTML,你想做一点动态内容,就得用CGI(Common Gateway Interface)——每次请求来了,服务器就fork一个进程去跑一段脚本,请求结束进程销毁。这种方式的毛病很明显:进程启动开销大、并发一高服务器就扛不住、开发效率也低。
Java的解决方案是把动态处理能力放进一个常驻的Java进程里,这个进程不退出,每个请求来了分配一个线程去处理。而“处理请求”这段代码的规范,就是Servlet。说直白点,Servlet就是Java世界里处理HTTP请求的基本单元,是运行在Servlet容器(比如Tomcat、Jetty)里的一段Java类。
这个定位到今天依然成立。你用Spring Boot写接口,底层干的还是Servlet的事——DispatcherServlet就是一个Servlet,你的Controller最终也是被Servlet的调用链拉起来的。所以别觉得Servlet是老古董,它是整个Java Web的地基。
1.2 Servlet的核心约定:请求-响应模型
Servlet的核心模型极其简单:容器接收HTTP请求,封装成请求对象和响应对象,交给你的Servlet处理,Servlet往响应对象里写内容,容器把内容送回客户端。
这里有两个关键对象:
ServletRequest/HttpServletRequest:封装了HTTP请求的所有信息,请求行、请求头、请求体、参数、Cookie、Session全在这里面。ServletResponse/HttpServletResponse:封装了HTTP响应的所有信息,你往里面写状态码、写Header、写响应体。
你写的Servlet类只需要继承javax.servlet.http.HttpServlet,重写doGet、doPost等方法,剩下的“请求怎么进来、响应怎么出去”的脏活累活,容器全包了。这也是Servlet思想的核心:开发者只关心业务逻辑,不关心网络通信细节。
2. HttpServletRequest:请求数据到底怎么读
2.1 最常用的API:从请求行到参数
HttpServletRequest是ServletRequest的子接口,额外增加了HTTP相关的操作方法。平时开发中接触最多的就是下面这几个。
读取请求方式、URI、协议版本:
// 请求方式:GET、POST、PUT、DELETE... String method = request.getMethod(); // 请求URI:比如 /hello String requestURI = request.getRequestURI(); // 完整URL:比如 http://localhost:8080/demo/hello?name=jack StringBuffer requestURL = request.getRequestURL(); // 查询字符串:比如 name=jack&age=18 String queryString = request.getQueryString(); // 协议版本:HTTP/1.1 String protocol = request.getProtocol();读取请求参数:
客户端传参通常有两种方式:GET请求把参数拼在URL查询字符串里,POST请求把参数放在请求体里(表单格式)。但对Servlet来说,不管哪种方式,你都可以用统一的方法拿:
// 单个参数,取第一个值 String name = request.getParameter("name"); // 同名多值参数(比如复选框) String[] hobbies = request.getParameterValues("hobby"); // 一次性拿到所有参数 Map<String, String[]> paramMap = request.getParameterMap(); // 判断参数是否存在 boolean exists = request.getParameterMap().containsKey("name");注意getParameterMap()返回的是Map<String, String[]>,键是参数名,值是字符串数组。为什么是数组?因为一个参数名可能对应多个值,比如多选复选框提交上来的hobby=reading&hobby=music。如果你用getParameter("hobby"),只会拿到第一个值reading。
2.2 Header、Cookie与Session:状态从哪来
HTTP本身是无状态的,但实际业务离不开状态。Servlet里状态通常靠三样东西:请求头、Cookie、Session。
读取请求头:
// 读取单个Header String userAgent = request.getHeader("User-Agent"); String referer = request.getHeader("Referer"); // 遍历所有Header Enumeration<String> headerNames = request.getHeaderNames(); while (headerNames.hasMoreElements()) { String headerName = headerNames.nextElement(); String headerValue = request.getHeader(headerName); System.out.println(headerName + ": " + headerValue); } // 读取整型Header,读不到或格式不对会抛异常 int contentLength = request.getIntHeader("Content-Length"); // 读取日期型Header long dateHeader = request.getDateHeader("If-Modified-Since");Cookie与Session:
// 读取请求携带的所有Cookie Cookie[] cookies = request.getCookies(); if (cookies != null) { for (Cookie cookie : cookies) { if ("JSESSIONID".equals(cookie.getName())) { String sessionId = cookie.getValue(); } } } // 获取Session,参数为true时,如果没有Session则自动创建 HttpSession session = request.getSession(); // 参数为false时,如果没有Session则返回null,避免无谓创建 HttpSession existingSession = request.getSession(false); // Session中存取值 session.setAttribute("userId", 1001); Object userId = session.getAttribute("userId");这里强调一下:request.getSession()和request.getSession(true)效果一样,没有Session就新建一个;但如果你只是想查一下当前请求有没有携带合法Session,用getSession(false)更合适,不会莫名其妙创建一个新Session,这在做登录校验拦截时特别有用。
2.3 请求体的读取:表单与JSON的差异
当客户端用POST提交表单时,请求体是application/x-www-form-urlencoded格式,getParameter能直接读到。但如果客户端提交的是JSON,比如现在前后端分离项目里最常见的application/json,getParameter就无能为力了,你必须手动从请求体里读原始字节流。
// 从请求体读取JSON字符串 BufferedReader reader = request.getReader(); StringBuilder sb = new StringBuilder(); String line; while ((line = reader.readLine()) != null) { sb.append(line); } String jsonBody = sb.toString(); // 或者用getInputStream(),适合同时需要字节流的场景 ServletInputStream inputStream = request.getInputStream();读完之后你会发现,getInputStream()和getReader()只能调用一次,因为请求体是一个流,读完就没了。这也是为什么在Spring MVC里,如果不小心在过滤器里读了请求体,Controller里就再也拿不到参数了——本质就是这个原因。后面遇到这种诡异问题时,你心里就有数了。
2.4 中文乱码问题的根源与处理
中文乱码是Servlet新人最容易踩的坑,而且它有两个不同场景,处理方式完全不同。
POST请求的乱码,根源在于请求体字节流解码用的字符集不对。解决办法是在读取任何参数之前设置请求编码:
request.setCharacterEncoding("UTF-8");注意,这段代码必须在调用getParameter()之前执行,否则请求体已经按默认编码(ISO-8859-1)解码完了,你再设置也没用。
GET请求的乱码,跟请求体无关,问题出在Tomcat默认对URL中的参数解码用的是ISO-8859-1。在Servlet 4.0之前,最直接的解决办法是手动转码:
String name = new String(request.getParameter("name").getBytes("ISO-8859-1"), "UTF-8");从Tomcat 8.0开始,可以修改server.xml里Connector的URIEncoding="UTF-8"属性,从根上解决。如果是Spring Boot内嵌Tomcat,则设置server.tomcat.uri-encoding=UTF-8。
响应的乱码,则要保证响应头声明了正确的字符集:
response.setContentType("text/html; charset=UTF-8");一句话总结:请求的乱码管request,响应的乱码管response,别搞混了。
3. main执行与Servlet生命周期:到底谁在调用你的代码
3.1 Servlet没有main方法,但容器有
很多新手会问:Servlet类里没有main方法,那它是怎么被执行的?
答案是:Servlet容器有自己的main方法。以Tomcat为例,它的入口是org.apache.catalina.startup.Bootstrap类的main方法。你启动Tomcat时,执行的是Tomcat的main方法,它会创建容器、加载web应用、扫描web.xml或注解,然后在合适的时机实例化你的Servlet类并调用它的方法。
所以准确地说,Servlet不是“自己跑起来的”,而是“被容器调起来的”。整个调用链大致是这样的:
- Tomcat的
Bootstrap.main()启动JVM进程。 - 容器解析
web.xml,根据<servlet>和<servlet-mapping>配置,注册Servlet的信息。 - 客户端请求到达,容器根据请求URL匹配Servlet映射。
- 容器为Servlet创建实例(如果没有的话),调用
init()初始化。 - 容器创建一个线程,调用Servlet的
service()方法。 service()根据HTTP方法分发给doGet或doPost。- 响应写完后,容器把结果返回客户端,线程回到线程池。
这个链条里的核心逻辑,就是Servlet的生命周期管理。
3.2 生命周期五阶段详解
Servlet的生命周期由容器管理,一共五个关键字:加载、实例化、初始化、服务、销毁。
加载与实例化:容器根据web.xml配置,通过Class.forName()加载Servlet类,然后用newInstance()创建实例。默认情况下,第一次请求到达时才实例化;但如果配了<load-on-startup>,容器启动时就会提前实例化。
初始化:实例创建后,容器调用init()方法。init()在整个生命周期里只被调用一次,适合做数据库连接、读取配置文件等一次性准备工作。
@Override public void init() throws ServletException { // 只执行一次 System.out.println("Servlet 初始化开始"); this.config = getServletConfig(); }服务:每次请求到达时,容器调用service()方法。HttpServlet的service()方法已经帮我们实现了基于HTTP方法的自动分发,所以我们通常只重写doGet、doPost这些方法。
// HttpServlet中的service()大致逻辑 protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String method = req.getMethod(); if ("GET".equals(method)) { doGet(req, resp); } else if ("POST".equals(method)) { doPost(req, resp); } else if ("PUT".equals(method)) { doPut(req, resp); } // ... 其他方法类似 }销毁:容器关闭或应用重新部署时,调用destroy()方法,释放资源。同样只调用一次。
@Override public void destroy() { // 释放连接池、关闭文件流等 System.out.println("Servlet 销毁"); }生命周期里最容易被忽略的一点是:init()和destroy()每个Servlet实例只执行一次,而service()每来一个请求执行一次。如果你的代码里有需要全局只用一次的初始化逻辑,别放在doGet里,否则每次请求都执行一遍,浪费资源不说,还可能造成数据错乱。
3.3 线程模型:单实例多线程的并发真相
Servlet的线程模型是理解其性能特征的关键,也是最容易踩坑的地方。
容器对每个Servlet只创建一个实例,但每个请求由一个独立的线程处理。这意味着,所有请求共享同一个Servlet对象,实例变量是共享的,而局部变量是每个线程独有的。
来看一个典型的错误写法:
public class UnsafeServlet extends HttpServlet { // 实例变量,多线程共享,存在并发问题 private int count = 0; @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { count++; // 如果两个线程同时执行到这里,count的值可能不一致 resp.getWriter().write("count=" + count); } }在并发场景下,count++不是原子操作,两个线程同时读到同一个值,再各自加1写回去,最终结果就丢了更新。解决办法是把共享变量改成局部变量,或者加锁,或者尽量不要用实例变量存业务状态。
@Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 局部变量,每个线程独有一份,没有并发问题 int count = 0; count++; resp.getWriter().write("count=" + count); }Servlet的service()方法本身也没有加锁,所以doGet、doPost里的代码必须自己保证线程安全。这也是后来Spring MVC的Controller默认是单例却要求无状态的原因——本质上还是继承自Servlet这个线程模型。
4. 使用web.xml方式编写Servlet的完整示例
4.1 项目结构准备
既然热词里反复出现“使用web.xml方式编写Servlet的完整示例”,那这部分我就给你一个能直接跑起来的完整工程。我用最传统的Maven + War包方式,不用任何Spring,让你看清Servlet最原始的运作方式。
先看整体项目结构:
servlet-demo ├── pom.xml └── src └── main ├── java │ └── com │ └── example │ └── servlet │ ├── HelloServlet.java │ └── LoginServlet.java └── webapp ├── WEB-INF │ └── web.xml └── index.htmlpom.xml里只需要引入Servlet API,并且把依赖范围设为provided,因为Tomcat自身已经带了Servlet API,打包时不需要打进去。
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>servlet-demo</artifactId> <version>1.0-SNAPSHOT</version> <packaging>war</packaging> <properties> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> </dependencies> <build> <finalName>servlet-demo</finalName> </build> </project>4.2 编写Servlet类:第一个能跑的Servlet
我们先写一个最基础的HelloServlet,用来演示请求的接收和响应的输出。
package com.example.servlet; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; public class HelloServlet extends HttpServlet { @Override public void init() throws ServletException { System.out.println("[HelloServlet] init() 执行,Servlet 初始化"); } @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 设置响应内容类型,必须放在获取Writer之前 resp.setContentType("text/html; charset=UTF-8"); PrintWriter out = resp.getWriter(); out.println("<!DOCTYPE html>"); out.println("<html>"); out.println("<head><meta charset=\"UTF-8\"><title>Hello Servlet</title></head>"); out.println("<body>"); out.println("<h1>Hello, Servlet!</h1>"); out.println("<p>请求方式:" + req.getMethod() + "</p>"); out.println("<p>请求URI:" + req.getRequestURI() + "</p>"); out.println("<p>查询字符串:" + req.getQueryString() + "</p>"); out.println("<p>客户端IP:" + req.getRemoteAddr() + "</p>"); out.println("</body>"); out.println("</html>"); } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // POST请求也交给doGet处理,方便测试 doGet(req, resp); } @Override public void destroy() { System.out.println("[HelloServlet] destroy() 执行,Servlet 销毁"); } }再写一个LoginServlet,演示POST请求的参数接收、Servlet之间转发、以及Session的简单使用。这个例子结合了表单提交的实际场景。
package com.example.servlet; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; import java.io.PrintWriter; public class LoginServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 必须在读取参数之前设置编码 req.setCharacterEncoding("UTF-8"); resp.setContentType("text/html; charset=UTF-8"); String username = req.getParameter("username"); String password = req.getParameter("password"); PrintWriter out = resp.getWriter(); out.println("<!DOCTYPE html>"); out.println("<html>"); out.println("<head><meta charset=\"UTF-8\"><title>登录结果</title></head>"); out.println("<body>"); // 模拟校验:admin / 123456 if ("admin".equals(username) && "123456".equals(password)) { HttpSession session = req.getSession(); session.setAttribute("loginUser", username); out.println("<h2>登录成功</h2>"); out.println("<p>欢迎," + username + "!您的Session ID是:" + session.getId() + "</p>"); out.println("<a href=\"/servlet-demo/welcome\">进入欢迎页</a>"); } else { out.println("<h2>登录失败</h2>"); out.println("<p>用户名或密码错误,请<a href=\"/servlet-demo/index.html\">重试</a></p>"); } out.println("</body>"); out.println("</html>"); } }注意上面用了/servlet-demo/welcome这样的绝对路径,servlet-demo是项目的上下文路径(Context Path)。不同容器的上下文路径可能不一样,更稳妥的做法是用req.getContextPath()动态拼:
String ctxPath = req.getContextPath(); out.println("<a href=\"" + ctxPath + "/welcome\">进入欢迎页</a>");4.3 web.xml中的Servlet映射配置
接下来是关键部分:web.xml。在Servlet 3.0之前,web.xml是配置Servlet的唯一方式;Servlet 3.0之后虽然可以用@WebServlet注解,但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"> <display-name>Servlet Demo</display-name> <!-- 声明Servlet --> <servlet> <servlet-name>helloServlet</servlet-name> <servlet-class>com.example.servlet.HelloServlet</servlet-class> <!-- 可选:容器启动时就实例化,数字越小优先级越高 --> <load-on-startup>1</load-on-startup> </servlet> <!-- 声明Servlet的URL映射 --> <servlet-mapping> <servlet-name>helloServlet</servlet-name> <url-pattern>/hello</url-pattern> </servlet-mapping> <!-- 第二个Servlet --> <servlet> <servlet-name>loginServlet</servlet-name> <servlet-class>com.example.servlet.LoginServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>loginServlet</servlet-name> <url-pattern>/login</url-pattern> </servlet-mapping> <!-- 欢迎页配置 --> <welcome-file-list> <welcome-file>index.html</welcome-file> </welcome-file-list> </web-app>每一组配置分两部分:<servlet>声明“这个Servlet类叫什么名字”,<servlet-mapping>声明“哪个URL路径交给哪个Servlet处理”。名字是逻辑上的引用,类名是全限定类名,两者通过<servlet-name>关联起来。
<url-pattern>的写法很关键,常见的规则有:
| 写法 | 匹配规则 | 示例 |
|---|---|---|
/hello | 精确匹配 | 只有/hello能命中 |
/user/* | 路径匹配 | /user/list、/user/1都能命中 |
*.do | 扩展名匹配 | 任何以.do结尾的路径都能命中 |
/ | 默认Servlet | 未匹配到其他Servlet的所有请求 |
/* | 拦截所有请求 | 慎用,会覆盖所有静态资源 |
很多人分不清/和/*,这里强调一下:/是默认Servlet的映射,负责处理没有被其他映射匹配到的请求;/*是真正意义上的“所有请求都归我管”,一旦配置了/*,连JSP、静态资源、欢迎页都会被它截走,很容易出问题。新手别轻易用/*。
4.4 部署与验证
项目写好后,用Maven打成war包:
mvn clean package打包完成后,在target/目录下会生成servlet-demo.war。把这个war包丢到Tomcat的webapps/目录下,启动Tomcat(即执行Tomcat的main方法),它就会自动解压部署。
如果没有额外配置Context Path,默认访问路径就是http://localhost:8080/servlet-demo/。
启动之后,依次验证几个请求:
# 访问欢迎页 curl http://localhost:8080/servlet-demo/ # 访问HelloServlet curl "http://localhost:8080/servlet-demo/hello?name=zhangsan" # 模拟登录提交 curl -X POST -d "username=admin&password=123456" \ http://localhost:8080/servlet-demo/login如果一切正常,curl会分别返回HTML页面。这时候再翻Tomcat控制台日志,你会看到init()的输出已经出现了,这验证了Servlet生命周期的执行顺序。
在实际开发中,curl是验证Servlet接口最快的工具。但注意GET请求带中文参数时,curl默认不会做URL编码,建议用--data-urlencode显式编码:
curl -G --data-urlencode "name=张三" http://localhost:8080/servlet-demo/hello5. 实战中绕不开的坑与排查经验
5.1 load-on-startup到底该不该配
<load-on-startup>的作用是告诉容器:启动时就创建Servlet实例并调用init(),而不是等第一个请求来了才初始化。配置的值是整数,数字越小,优先级越高。
什么场景需要配置?比如你的Servlet在init()里加载了一个大配置、建立了一个重量级连接池,你希望这些准备工作在容器启动时完成,而不是让第一个用户白白等几秒钟。如果init()里只是简单赋值,就没必要配,懒加载反而是更好的选择,能加快应用启动速度。
还有一个容易被忽略的副作用:如果配置了load-on-startup且init()抛出了异常,应用会启动失败。这在某些场景下反而是好事——启动时发现配置错误,总比线上运行半天后才暴露强。
5.2 常见错误与排查思路
404但代码没报错:先确认<url-pattern>是否正确,再看war包是否真的部署成功,访问路径的Context Path是否写对了。常见坑是把http://localhost:8080/servlet-demo/hello写成了http://localhost:8080/hello。
405 Method Not Allowed:说明Servlet匹配到了,但你只重写了doGet,客户端发的是POST请求。HttpServlet的默认doPost实现是返回405,这是规范设计,提醒你没有处理这个HTTP方法。
ClassNotFoundException:检查<servlet-class>里的全限定类名是否写错,检查编译后的class文件是否在WEB-INF/classes对应目录下,或者依赖jar是否在WEB-INF/lib里。
getParameter返回null:确认参数名拼写,确认请求方式和提交方式是否匹配。特别注意:如果请求体是JSON格式,getParameter拿不到,得用getReader()读原始流。
中文乱码:按前面说的,分别排查请求侧和响应侧的字符集设置。POST请求在getParameter之前调用setCharacterEncoding,GET请求检查Tomcat的URIEncoding,响应检查setContentType。
上一次能访问,改动后一直报错:先停Tomcat,删掉work/Catalina下的缓存目录,再重新部署。Tomcat的JSP编译缓存和类加载缓存偶尔会出幺蛾子,这个操作能解决很多莫名其妙的旧代码残留问题。
5.3 从Servlet到现代框架:这条链路依然重要
最后说点我的真实体会。很多人觉得现在都Spring Boot了,哪还有人直接写Servlet?但恰恰因为Spring Boot把Servlet封装得太好,很多人反而失去了对请求处理底层逻辑的感知。
当你遇到过滤器不生效、请求体读不到、拦截器和过滤器的执行顺序混乱这些问题时,如果没有Servlet的基础,排查起来会非常吃力。Spring Boot里的DispatcherServlet本质就是个Servlet,它被映射到/,你的所有接口都是通过它分发的。你配的Filter实现javax.servlet.Filter,走的还是Servlet规范定义的过滤器链。理解了Servlet的映射规则、生命周期、线程模型,就等于拿到了排查Java Web问题的一把万能钥匙。
以我的经验,花一个下午手写一个web.xml配置的Servlet示例,比看十篇教程都管用。你亲眼看一次init()只执行一次、亲眼看一次线程池复用线程、亲眼调一次POST中文乱码,这些经历会在你以后处理各种框架问题时源源不断地给你灵感。