
不少刚接触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你想做一点动态内容就得用CGICommon 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?namejack StringBuffer requestURL request.getRequestURL(); // 查询字符串比如 namejackage18 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); // 一次性拿到所有参数 MapString, String[] paramMap request.getParameterMap(); // 判断参数是否存在 boolean exists request.getParameterMap().containsKey(name);注意getParameterMap()返回的是MapString, String[]键是参数名值是字符串数组。为什么是数组因为一个参数名可能对应多个值比如多选复选框提交上来的hobbyreadinghobbymusic。如果你用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 EnumerationString 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/jsongetParameter就无能为力了你必须手动从请求体里读原始字节流。// 从请求体读取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的URIEncodingUTF-8属性从根上解决。如果是Spring Boot内嵌Tomcat则设置server.tomcat.uri-encodingUTF-8。响应的乱码则要保证响应头声明了正确的字符集response.setContentType(text/html; charsetUTF-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 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.source1.8/maven.compiler.source maven.compiler.target1.8/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 build finalNameservlet-demo/finalName /build /project4.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; charsetUTF-8); PrintWriter out resp.getWriter(); out.println(!DOCTYPE html); out.println(html); out.println(headmeta charset\UTF-8\titleHello Servlet/title/head); out.println(body); out.println(h1Hello, 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; charsetUTF-8); String username req.getParameter(username); String password req.getParameter(password); PrintWriter out resp.getWriter(); out.println(!DOCTYPE html); out.println(html); out.println(headmeta 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 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 servlet-namehelloServlet/servlet-name servlet-classcom.example.servlet.HelloServlet/servlet-class !-- 可选容器启动时就实例化数字越小优先级越高 -- load-on-startup1/load-on-startup /servlet !-- 声明Servlet的URL映射 -- servlet-mapping servlet-namehelloServlet/servlet-name url-pattern/hello/url-pattern /servlet-mapping !-- 第二个Servlet -- servlet servlet-nameloginServlet/servlet-name servlet-classcom.example.servlet.LoginServlet/servlet-class /servlet servlet-mapping servlet-nameloginServlet/servlet-name url-pattern/login/url-pattern /servlet-mapping !-- 欢迎页配置 -- welcome-file-list welcome-fileindex.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?namezhangsan # 模拟登录提交 curl -X POST -d usernameadminpassword123456 \ 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之前调用setCharacterEncodingGET请求检查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中文乱码这些经历会在你以后处理各种框架问题时源源不断地给你灵感。