Servlet核心原理与实战:生命周期、线程安全及性能调优
发布时间:2026/10/1 22:30:18来源:尧图网络
1. 初识Servlet从动态网页这个起点说起1.1 Servlet到底是什么为什么它活了二十多年还没退休先给一个最朴素的定义Servlet是Java Web开发中用来处理HTTP请求和响应的一组接口规范。它运行在Servlet容器里最常见的就是Tomcat负责接收浏览器发来的请求经过一系列业务处理后把结果以HTML、JSON、XML或其他格式返回给客户端。很多人第一次接触Servlet时觉得它古老、过时因为现在随便打开一个招聘JD上面写的都是Spring Boot、Spring Cloud、微服务。但你往深处看会发现Spring MVC的核心就是DispatcherServletSpring Boot内嵌的Tomcat或者说Jetty底层处理的还是Servlet。换句话说Servlet并没有退休它只是退居幕后变成了框架的底座。我当年学Servlet的困惑在于为什么要先学一个不直接用于生产的底层技术后来在实战中才慢慢体会到Servlet几乎涵盖了HTTP协议在服务端的全部核心语义——请求行、请求头、请求体、响应状态码、Cookie、Session、转发、重定向。这些概念如果不在Servlet层面弄明白后面用框架写接口时遇到跨域、会话失效、乱码、重定向死循环这类问题排查起来会非常吃力。所以这篇博文的目标很明确把Servlet讲透配一个能跑的Maven实例再延伸到调用大模型API这种贴近当前热点的应用场景。无论你是刚入门Java Web还是已经用框架写了很久接口但没系统梳理过Servlet这篇文章都值得读完。1.2 Servlet和Tomcat的分工关系很多初学者会把Servlet和Tomcat混为一谈以为它们是同一个东西。实际上两者完全是不同层面的角色。Tomcat是Servlet容器同时也兼任HTTP服务器。它的职责包括监听端口默认8080接收HTTP请求并解析成标准的请求对象。根据URL映射找到对应的Servlet类创建或复用其实例。调用Servlet的生命周期方法。把Servlet写出的响应数据封装成HTTP响应报文发送回客户端。管理Session、线程池、类加载器等底层资源。而Servlet本身只是一组接口和类定义在javax.servlet包中Servlet 3.0之前或jakarta.servlet包中Servlet 4.0之后因为Oracle把Java EE捐给了Eclipse基金会更名Jakarta EE。你自己写的Servlet类实现的是业务层面的逻辑它不关心TCP连接怎么建立、HTTP报文怎么解析这些脏活累活全由容器完成。用一个生活化的类比Tomcat是餐厅Servlet是厨师。客人浏览器来点菜服务员Tomcat的线程池记录需求、传菜厨师只负责把菜做出来。Servlet程序员关心的是这道菜怎么做而不是菜怎么端上桌。2. 一个Servlet请求的完整生命周期2.1 从浏览器地址栏到doGet方法的路上发生了什么假设你在浏览器地址栏输入http://localhost:8080/hello?namezhangsan并回车这中间发生的事远比你想象的复杂。第一步浏览器把请求拆解成HTTP报文包括请求行GET /hello?namezhangsan HTTP/1.1请求头Host、User-Agent、Accept等然后通过TCP连接发送给Tomcat。第二步Tomcat的Connector组件NIO或BIO模式接收到这个TCP数据流利用解析器把它转换成HttpServletRequest对象。注意这里的HttpServletRequest不是一次请求一个实例而是Tomcat从对象池或直接新建的一个Java对象。第三步Tomcat根据/hello这个路径去匹配部署描述符web.xml或注解WebServlet中配置的URL映射。匹配规则不是简单的字符串相等而是遵循Servlet规范中的精确匹配、路径匹配、后缀匹配、默认Servlet这一套优先级顺序。第四步找到目标Servlet后Tomcat检查这个Servlet是否已经实例化。如果还没有就通过反射创建实例调用init()方法完成初始化。然后从线程池中取出一个工作线程调用service(request, response)方法。第五步HttpServlet基类的service()方法会根据请求方法GET、POST、PUT等自动分派到对应的doGet()、doPost()等方法。你重写的doGet()方法里拿到request.getParameter(name)处理业务通过response.getWriter().write(...)写回数据。到这里一次请求的上半天结束了剩下的就是Tomcat把响应对象中的数据取出来封装成HTTP响应报文通过连接发送回浏览器。2.2 init、service、doGet/doPost、destroy谁先谁后Servlet生命周期方法共有五个按顺序排列init(ServletConfig config)初始化方法只调用一次。默认是第一次请求到达时才被调用懒加载但也可以通过load-on-startup标签配置成容器启动时立即初始化。service(ServletRequest req, ServletResponse res)核心服务方法每次请求都会调用。doGet/doPost等方法由service()内部根据HTTP方法类型分派。destroy()销毁方法在Servlet容器关闭或应用重新部署时调用只调用一次。这里有个关键点init()的调用时机。如果你在Servlet的构造方法里做了耗时操作比如建立数据库连接池那第一次请求会非常慢。一般建议把耗时资源的初始化放到init()里并在web.xml中配置load-on-startup1/load-on-startup让Tomcat启动时就完成初始化。还有一点很多人理解错init()和destroy()各自只执行一次但service()是并发执行的。也就是说多个请求可以同时进入同一个Servlet实例的service()方法这直接引出下一个坑。2.3 单实例多线程模型以及新手最容易踩的线程安全坑Servlet默认是单实例多线程模型一个Servlet类在容器中只有一个实例但这个实例的所有方法会被多个线程并发调用。如果你在Servlet类中定义了可变的成员变量比如private int count 0;并发环境下就会出现数据错乱。踩坑案例我见得多了。有人为了让计数器自增直接写了count结果压测时发现数据不对。这正是典型的线程安全问题。解决办法有三类第一尽量不定义可变的成员变量所有状态都放在方法内部或请求对象中这是最推荐的做法。第二如果必须共享状态使用同步机制比如synchronized关键字、AtomicInteger、ConcurrentHashMap等并发容器。第三用ThreadLocal把变量变成线程私有适合保存用户身份、事务上下文这类一次请求内共享但请求间隔离的数据。实际上Spring框架的很多设计就是围绕Servlet这种并发模型展开的比如Spring MVC的Controller默认也是单例所以你在Controller里写成员变量一样有线程安全问题。这个模型懂了后面看框架源码会轻松很多。3. 手把手构建基于Maven的Servlet项目3.1 Eclipse环境下Maven项目的创建与配置现在新建Servlet项目我强烈建议直接用Maven不要用Eclipse自带的Dynamic Web Project。Maven帮你管理依赖、构建产物、部署结构工程化程度高得多。而且Maven项目在IDEA和Eclipse之间迁移几乎零成本。在Eclipse中创建基于Maven的Servlet项目步骤如下选择File - New - Other - Maven Project点击Next。在Select an Archetype界面选择maven-archetype-webapp。这是Maven官方提供的Web应用骨架会自动生成src/main/webapp目录和web.xml。填写Group Id一般是公司域名倒写如com.example、Artifact Id项目名如servlet-demo、Package基础包名。点击Finish后Maven会下载依赖并生成项目结构。这时你会发现pom.xml里默认的Servlet依赖是Java EE 7时代的javax.servlet-api版本比较老。建议手动改成Jakarta版本的Servlet API也可以直接用Spring Boot的starter-web它会把Servlet容器和API一起带进来。不过为了讲清原理我用的是纯Servlet的Maven配置。下面是pom.xml的核心内容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.0/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 groupIdjakarta.servlet/groupId artifactIdjakarta.servlet-api/artifactId version5.0.0/version scopeprovided/scope /dependency /dependencies build finalNameservlet-demo/finalName /build /project注意scope是provided意思是编译和测试时需要但打包时不需要进WAR包因为Tomcat自己带有Servlet API。如果你误把scope写成默认的compile部署到Tomcat时可能会出现java.lang.LinkageError或者类冲突问题这就是scope选错的典型症状。3.2 web.xml和注解WebServlet到底该选哪个Servlet 3.0以前每个Servlet必须在web.xml中声明映射写起来非常啰嗦。比如servlet servlet-namehello/servlet-name servlet-classcom.example.HelloServlet/servlet-class load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namehello/servlet-name url-pattern/hello/url-pattern /servlet-mappingServlet 3.0之后引入了WebServlet注解直接在类上声明即可WebServlet(urlPatterns /hello)绝大多数场景下用注解更简洁但web.xml也并非一无是处。它适合集中管理公共配置比如welcome-file-list、error-page、filter和filter-mapping以及session-config超时时间。如果你所在团队有运维或配置管理人员不想让业务开发随便改Servlet路径用web.xml集中声明反而更利于管控。我的建议是小项目用注解大项目也优先用注解但保留web.xml用于全局配置。混合使用时注意同一个Servlet不能既在web.xml中声明又在注解里重复映射否则容器会报ServletRegistration冲突。3.3 第一个Servlet实例接收表单数据并响应先说这个实例要解决什么问题实现一个最简单的用户注册接口接收用户名和密码处理后返回一个JSON字符串。目的是演示Servlet如何接收参数、如何设置编码、如何输出JSON。新建一个HelloServlet类package com.example.servlet; import jakarta.servlet.ServletException; import jakarta.servlet.annotation.WebServlet; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; WebServlet(urlPatterns /register) public class RegisterServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); resp.setCharacterEncoding(UTF-8); resp.setContentType(application/json;charsetutf-8); String username req.getParameter(username); String password req.getParameter(password); PrintWriter out resp.getWriter(); if (username null || username.length() 0 || password null || password.length() 0) { out.write({\code\: 400, \message\: \用户名或密码不能为空\}); return; } out.write({\code\: 200, \message\: \注册成功\, \username\: \ username \}); } }把这个项目打包成WAR后扔进Tomcat的webapps目录启动Tomcat用Postman模拟一个POST http://localhost:8080/servlet-demo/register请求body类型选x-www-form-urlencoded填入usernamezhangsanpassword123456就能看到JSON响应。这里有几个关键点需要解释第一req.setCharacterEncoding(UTF-8)必须放在getParameter()之前调用否则POST请求体中的中文依然会乱码。第二resp.setContentType(application/json;charsetutf-8)告诉浏览器用UTF-8解码响应体同时Content-Type声明为JSON。第三代码里直接拼接JSON字符串只是演示真实项目中建议用Jackson或者Gson序列化Map或POJO。字符串拼接一旦字段变多转义字符会让你崩溃。4. 工程化演进从MVC到前后端分离Servlet的角色变化4.1 MVC、MVP、MVVM三种模式对比Servlet在其中的位置最近社区里又有人讨论MVC、MVP、MVVM结合Servlet来看这三种模式的本质差异在于谁负责通知谁更新。MVC模式里Model模型层负责数据和业务规则View视图层负责显示Controller控制器层负责接收输入并更新Model或View。在Java Web的经典实现中JSP或Freemarker是ViewJavaBean或POJO是Model而Servlet恰好承担了Controller的角色——接收请求、调用业务、转发到视图。MVP是在MVC基础上把View和Model完全解耦Presenter相当于MVC里的Controller作为中介。MVVM则更彻底View和Model通过数据绑定自动同步ViewMode层处理大部分交互逻辑。前端主流框架React、Vue采用的就是MVVM思想后端Spring MVC依然是典型的MVC。这三者没有绝对好坏关键看场景。Servlet时代的MVC模式在服务端渲染场景下非常高效JSP可以直接读取Model数据并在浏览器端渲染HTML。而现在的项目普遍前后端分离后端只返回JSON前端框架负责渲染Servlet/Framework退化为纯粹的API端点角色更接近于门卫。4.2 前后端分离后Servlet还重要吗答案是依然重要但不再是业务逻辑的主要载体。前后端分离架构下后端的Controller接收前端传来的JSON解析参数、调用Service、返回结果。如果把Spring Boot的DispatchServlet拆开看它就是基于Servlet规范的一个前端控制器模式实现。Servlet规范中的请求响应模型Filter拦截器机制Session管理方式在Spring Boot中依然全部保留。我最近用Servlet写了一个调用大模型API的接口深刻体会到即便在AI时代Servlet的同步请求-响应模型仍然是大多数业务系统的基本骨架。哪怕大模型服务商提供的SDK是异步的、流式的最终暴露给前端的HTTP接口也要通过Servlet线程把事件循环驱动起来。所以我的观点是Servlet不是过时而是被抽象了。你写Spring Boot时不直接写Servlet类但你的请求照样经过Servlet容器。理解Servlet的请求映射、生命周期、线程模型对排查任何基于Java Web框架的问题都有直接帮助。5. 实战进阶用Servlet编写一个调用大模型API的接口5.1 需求分析与接口设计现在AI应用非常火很多项目需要在后端封装一个接口让前端页面可以体验大模型的对话能力。比如做一个简单的聊天机器人页面输入一句话返回AI的回复。如果用Servlet自己实现不引入Spring Boot框架很多同学会觉得无从下手。其实思路很清晰前端发一个POST请求参数包含prompt用户输入Servlet收到后调用大模型厂商提供的HTTP API把模型返回的内容原样写回前端。接口设计如下URLPOST /api/chat请求参数prompt你好响应格式JSON包含reply字段值为模型生成的文本这里有两个关键设计点第一大模型API调用的密钥绝对不能放在前端必须由后端持有。这就是为什么需要一个Servlet或Controller来做中转。第二大模型API响应通常要好几秒甚至十几秒HTTP连接的超时时间要设得足够长。同时要考虑用户等待体验可以做成流式输出每出一个字就推送到前端。5.2 代码实现与关键点假设你用的是OpenAI兼容的大模型API下面这个Servlet实现了一个最简的对话接口package com.example.servlet; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.annotation.WebServlet; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; WebServlet(/api/chat) public class ChatServlet extends HttpServlet { private static final String API_KEY 你的API密钥; private static final String API_URL https://api.example.com/v1/chat/completions; private final ObjectMapper objectMapper new ObjectMapper(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { req.setCharacterEncoding(UTF-8); resp.setCharacterEncoding(UTF-8); resp.setContentType(application/json;charsetutf-8); JsonNode requestBody objectMapper.readTree(req.getReader()); String prompt requestBody.path(prompt).asText(); String reply callLargeModel(prompt); resp.getWriter().write(objectMapper.writeValueAsString( java.util.Map.of(reply, reply) )); } private String callLargeModel(String prompt) throws IOException { URL url new URL(API_URL); HttpURLConnection connection (HttpURLConnection) url.openConnection(); connection.setRequestMethod(POST); connection.setConnectTimeout(5000); connection.setReadTimeout(30000); connection.setRequestProperty(Content-Type, application/json; charsetUTF-8); connection.setRequestProperty(Authorization, Bearer API_KEY); connection.setDoOutput(true); String payload objectMapper.writeValueAsString( java.util.Map.of( model, gpt-3.5-turbo, messages, new Object[]{ java.util.Map.of(role, user, content, prompt) } ) ); try (OutputStream os connection.getOutputStream()) { os.write(payload.getBytes(StandardCharsets.UTF_8)); } int responseCode connection.getResponseCode(); if (responseCode ! 200) { return 模型服务异常错误码 responseCode; } BufferedReader reader new BufferedReader( new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8) ); StringBuilder sb new StringBuilder(); String line; while ((line reader.readLine()) ! null) { sb.append(line); } reader.close(); JsonNode jsonNode objectMapper.readTree(sb.toString()); return jsonNode.path(choices).get(0).path(message).path(content).asText(); } }代码里值得注意的细节很多第一HttpURLConnection是JDK自带的HTTP客户端适合简单场景。真实项目建议用Apache HttpClient或OkHttp因为要处理连接池、重试、拦截器等情况HttpURLConnection写多了你会很痛苦。第二setConnectTimeout和setReadTimeout缺一不可。连接超时设5秒读取超时设30秒避免大模型接口响应慢时Servlet线程一直挂起。第三API密钥在后端硬编码只适合本地测试生产环境必须放到环境变量或配置中心并且要有密钥轮换机制。5.3 流式输出与超时控制上面的代码是一次性返回全部内容用户体验一般。通用大模型API通常支持streamtrue参数模型会逐个输出内容片段。Servlet要想支持流式输出需要把响应头设置为Transfer-Encoding: chunked然后边读取大模型API的流边往resp.getWriter()写数据写完一个片段就flush一次。大致思路如下connection.setRequestProperty(Accept, text/event-stream); String streamPayload objectMapper.writeValueAsString( java.util.Map.of( model, gpt-3.5-turbo, stream, true, messages, new Object[]{ java.util.Map.of(role, user, content, prompt) } ) ); try (OutputStream os connection.getOutputStream()) { os.write(streamPayload.getBytes(StandardCharsets.UTF_8)); } resp.setContentType(text/event-stream;charsetutf-8); BufferedReader reader new BufferedReader( new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8) ); String line; while ((line reader.readLine()) ! null) { if (line.isEmpty()) { continue; } resp.getWriter().write(line \n); resp.getWriter().flush(); }流式输出后前端可以用EventSource或fetch的ReadableStream接口实时渲染内容。但要注意Servlet容器给每个请求分配一个线程如果流式输出要持续十几秒这个线程会一直被占用连接数一多就可能触顶。这也是为什么纯Servlet写流式接口不如Spring WebFlux或者Vert.x那样适合高并发场景。但作为教学和中小规模应用完全够用。6. 踩坑与性能调优经验总结6.1 中文乱码问题80%的初学者卡在这一关中文乱码在Servlet开发中几乎是必坑根源在于编码不一致。一次HTTP请求要经过三段编码环节第一浏览器把表单内容按页面编码或请求头Content-Type里的charset编码成字节流发送。如果你没设置req.setCharacterEncoding(UTF-8)Tomcat默认按ISO-8859-1解析POST请求体中文必然乱码。第二Tomcat对GET请求的查询字符串有自己的URIEncoding配置默认是UTF-8但老版本Tomcat可能是ISO-8859-1需要在server.xml的Connector中设置URIEncodingUTF-8。第三响应时如果没有resp.setCharacterEncoding(UTF-8)Tomcat默认按ISO-8859-1输出同样乱码。解决乱码的固定套路就三步req.setCharacterEncoding(UTF-8); resp.setCharacterEncoding(UTF-8); resp.setContentType(text/html;charsetutf-8);三行代码必须放在所有读写操作之前。如果你用的是Spring BootCharacterEncodingFilter已经帮你做了这件事所以很多用框架的人没遇到过乱码但这不代表你不该懂。6.2 并发请求导致的数据错乱以及如何复现和修复前面讲过Servlet是单实例多线程这里给一个具体的中招案例。我有一个朋友在写一个简单的在线人数统计功能在Servlet里定义了一个成员变量private int onlineCount 0;。每次请求进入时onlineCount最后把值返回给前端。结果上线后在线人数总是比实际少经常出现负数。问题就出在onlineCount不是原子操作。这个操作在JVM底层要分三步读取当前值、加1、写回。三个线程同时执行时可能同时读到一个旧值导致两次加1只生效一次。并发量越大丢的越多。修复方式很简单用AtomicIntegerprivate final AtomicInteger onlineCount new AtomicInteger(0); protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { int count onlineCount.incrementAndGet(); resp.getWriter().write(当前在线人数 count); }但更根本的解决思路是能不存成员变量就不存。如果你确实要在一次请求的多个方法间共享数据优先考虑把数据放到HttpServletRequest的attribute里或者ThreadLocal里而不是类的成员变量。6.3 压测表现与优化手段正常项目和能跑就行项目的分水岭我用JMeter对一个纯Servlet的接口做过简单的压测结论挺有意思。同样一个返回JSON的GET接口不做任何优化的情况下Tomcat默认配置的并发能力大约在200-400 QPS之间瓶颈往往不在Servlet代码本身而在Tomcat的默认线程池配置。Tomcat 9默认acceptorThreadCount为1maxThreads为200minSpareThreads为10。如果你的接口里面有数据库查询、外部API调用等IO操作单个请求耗时50ms那么200个线程最多支撑4000 QPS的IO密集型并发但实际受网络带宽和数据库连接池限制数字会低得多。实际调优手段主要有几个方向第一调大maxThreads和minSpareThreads但要注意线程数不是越大越好太多线程反而增加上下文切换开销。第二如果接口只做IO等待可以用Servlet 3.1的异步处理特性把请求交给业务线程池处理Tomcat的工作线程立即释放能显著提升吞吐量。Spring MVC的Callable和DeferredResult就是基于这个机制实现的。第三拦截器和Filter不要做耗时操作。一个常见的坑是在Filter里打印请求日志用System.out.println高并发下IO阻塞特别严重。建议用Logback异步Appender或者干脆在压测环境关掉日志。第四合理设置响应头的Cache-Control和Content-Length减少不必要的重传。我个人在实际调优中的体会是Servlet层面的性能优化80%靠的是少干不必要的事而不仅是让代码跑更快。把日志降噪、把前端静态资源交给Nginx处理、给接口加缓存效果往往比疯狂调线程池参数来得更明显。另外我强烈建议每个学Servlet的人都亲手把Tomcat的conf/server.xml文件打开看一遍把maxThreads、minSpareThreads、connectionTimeout、maxKeepAliveRequests这些参数的含义和默认值搞清楚。这些知识在你以后用任何Java Web框架时都会用到因为框架只是换了个壳底层的Servlet容器依然在默默扛着所有请求。
网站建设高端定制企业官网