ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

手写Tomcat核心原理:从HTTP解析到Servlet生命周期全流程拆解

2026/10/5 8:03:04 拓冰建站 浏览量
手写Tomcat核心原理:从HTTP解析到Servlet生命周期全流程拆解 把Tomcat源码翻来覆去读了几遍之后我还是决定把手写一遍。手写Tomcat这个项目最值钱的地方在于它能把Web容器从“熟视无睹的黑盒”变成一条你能亲手摸到的流程线从浏览器发出HTTP请求到ServerSocket收到字节流到解析出请求方法和路径再到反射调用某个Servlet的service方法每一步都看得见、改得动、测得了。如果你也想搞清楚那些面试题里翻来覆去的“Tomcat是什么、Servlet生命周期到底怎么回事”或者单纯想把网络编程、反射、线程池这些东西真正串起来照着我这条路子走一遍比看十遍八遍源码都管用。我下面写的这些相当于我的项目笔记外加踩坑记录不是那种把代码一贴就完事的“教程”重点讲流程里每一步怎么拆、为什么这么拆、哪些地方实测会翻车。1. 动手前先划好边界手写版到底要写什么1.1 先分清楚Tomcat既是服务器也是容器手写Tomcat之前如果不去拆解它的职责最容易犯的错就是眉毛胡子一把抓什么都想实现结果一个都做不透。真实的Tomcat里面有两套相对独立的东西一个是通信相关的连接器一个是管理Servlet的容器。连接器干的事是监听端口、接收Socket连接、把HTTP报文按协议解析出来、再把后端写好的响应按报文格式发回去。容器干的事是保存Servlet实例、做URL到Servlet的映射、管理Servlet的初始化、调用和销毁。用生活里的话说连接器是饭店门口的服务员负责把客人迎进来、记下客人点单容器是后厨负责真正把菜做出来。客人就是HTTP请求菜就是Servlet处理完返回的响应。手写的时候这两块必须分开设计否则后面想改成支持别的协议你会发现整个项目都得推翻。我第一次写的时候没想清楚这层把Socket解析和Servlet调用全塞在一个类里代码写到两三百行就乱成一团之后重构花的时间比写的时间还长。后来才老实按连接器、容器两条线去组织。1.2 我划出的功能边界做哪些砍哪些手写版的定位是“能跑通完整流程的最小闭环”不是再造一个生产级Tomcat。越早把边界划清楚越不会被各种琐碎功能拖死。我当时列了一张表做和砍分得清清楚楚。要做的事包括HTTP/1.1的基础报文解析、静态资源返回、Servlet类的加载与URL映射、Servlet生命周期管理、多请求并发处理、基本的404/500错误响应。明确砍掉的事包括JSP解析、HTTPS支持、异步Servlet、NIO模式、Session集群、热部署、管理后台、多虚拟主机。尤其是Session和热部署看起来简单真做起来会牵扯出大量边界情况对理解主流程没有任何帮助。你可能觉得砍了Session有点可惜毕竟面试常问。我的做法是先写好了一个HttpSession接口请求和响应里都留了位置但容器不维护会话状态。这样既不影响主流程以后想扩展也有明确的挂载点。提示手写项目的成功标准不是“功能多”而是“你能否用一句话讲清一个请求从进来到回去经过了哪几个自己写的类”。如果讲不清说明类划分还不对。2. 从main方法到accept启动链路拆开看2.1 入口类越薄越好只负责组装几乎所有入门项目都会写一个BootStrap或者Launcher作为入口手写Tomcat也一样。但这个入口类我建议只放三件事读取端口配置、创建服务器对象、调用start方法。别把业务逻辑堆在main里。public class BootStrap { public static void main(String[] args) { int port 8080; if (args ! null args.length 0) { port Integer.parseInt(args[0]); } HttpServer server new HttpServer(port); server.start(); } }这个类越薄你的注意力就越能集中在HttpServer内部。HttpServer内部我再拆了两个部分一是ServerSocket的监听循环二是连接建立后的处理任务。监听和业务处理必须分开否则只要某个请求处理得慢后面所有请求都会排队。2.2 端口绑定与连接接收最容易忽略的细节创建ServerSocket这一步我踩过一个特别基础的坑。端口被占用时抛出的Address already in use异常很好认但真正难受的是Windows上的端口被TIME_WAIT状态占住。后面我处理的方式是在创建ServerSocket之前先把端口打印出来方便排错并给用到的Socket设置了setReuseAddress(true)能明显减少调试期“刚才还能起来现在起不来”的尴尬。主循环的正确长法是下面这样public void start() { try (ServerSocket serverSocket new ServerSocket(port)) { this.serverSocket serverSocket; running true; while (running) { Socket socket serverSocket.accept(); executor.execute(new SocketProcessTask(socket)); } } catch (IOException e) { e.printStackTrace(); } finally { destroyServlets(); executor.shutdown(); } }accept()是一个阻塞调用没有连接到达时线程会挂在这里。这是正常的不是死锁。真正需要注意的是accept循环里别做任何耗时操作尤其别在这里解析请求否则一个慢请求会拖住整个服务器。2.3 请求来了先交给线程池而不是无限创建线程很多人一开始会用new Thread(new Task(socket)).start()手写版跑起来也能用但恶果在并发测试时会爆发。几十个请求同时进来系统直接创建几十个线程线程上下文切换的开销比业务处理还大响应时间反而变长。我是这样分的监听线程只有一条只管accept真正干活的处理器扔给固定大小的线程池。private ExecutorService executor Executors.newFixedThreadPool(20);为什么定20因为手写版的Servlet处理基本是CPU密集加少量文件IO又不是微服务网关没必要几百个线程。后面我用压测数据验证过20个线程跑我这个手写版已经能满足本机几百QPS的小目标了。线程数再往上加QPS提升有限内存倒先上去了。注意线程池一定要在start方法里初始化在finally里shutdown。我没写shutdown之前每次CtrlC停掉进程都感觉有点粗鲁加了之后才算有了干净退出的能力。3. 协议层不能偷懒手动解析HTTP报文的完整过程3.1 请求行method、uri、protocol的拆分用Socket接到连接后第一步永远是读请求行。HTTP请求长这样GET /login?namezhangsan HTTP/1.1这一行按空格拆开是固定的三段请求方法、原始URI、协议版本。拆法很直接但有个细节特别容易被忽略URI里面带着query string也就是问号后面的参数必须把它们拆开。String line reader.readLine(); String[] parts line.split( ); if (parts.length 3) { sendError(400, Bad Request); return; } String method parts[0]; String rawUri parts[1]; String protocol parts[2]; int qIdx rawUri.indexOf(?); if (qIdx 0) { uri rawUri.substring(0, qIdx); queryString rawUri.substring(qIdx 1); } else { uri rawUri; }很多教程在这一步会使用String.split( )看起来没问题但你要是遇到多个连续空格的数据拆出来的数组就不够三位了。我处理的时候加了长度判断不够三位直接回400。这类防御性写法不是多余因为真有人会用爬虫或者畸形包来打你的服务器。3.2 请求头需要循环读到空行body长度按字节算读完请求行之后后面是一堆请求头格式固定是“键: 值”结束标志是一个空行。我的经验是循环读读到空行就停MapString, String headers new HashMap(); String headerLine; while ((headerLine reader.readLine()) ! null headerLine.length() 0) { int colon headerLine.indexOf(:); if (colon 0) { String key headerLine.substring(0, colon).trim().toLowerCase(); String value headerLine.substring(colon 1).trim(); headers.put(key, value); } }Key统一转小写是为了后面取content-length、content-type的时候不用纠结大小写。真实Tomcat内部也是这么规范化的。Header读完后重点来了。如果请求方法是POST而且header里带了Content-Length就说明还有请求体。请求体不能按行读必须按声明的字节长度读。为什么因为请求体里可能包含任意二进制内容按行读会把换行符歧义化。int contentLength Integer.parseInt(headers.getOrDefault(content-length, 0)); if (contentLength 0) { char[] bodyChars new char[contentLength]; int readCount reader.read(bodyChars); body new String(bodyChars, 0, readCount); }这里有个隐蔽的坑如果请求里带了Transfer-Encoding: chunkedContent-Length头就不会出现。真实Tomcat支持chunked也就是流式分块传输。手写版我明确不支持它遇到这种情况就按没有body处理或者直接回501。你能跑通所有常规场景就够了没必要为分块传输额外写解码器。3.3 解析异常输入空连接、畸形报文和超长URI解析环节有一类问题特别容易造成服务器卡死就是客户端连接建立后迟迟不发数据。readLine()会一直阻塞把线程池里的干活线程全部占满。最典型的就是浏览器开了连接又取消请求如果连接没有超时控制线程池很快会没线程可用。我的处理分两层一是给Socket设置读超时socket.setSoTimeout(5000)超过5秒读不到数据直接抛异常扔了这个连接二是给读取的字节总量设上限URI超过2048个字符就判定为非法请求。畸形报文也绝不能让其一路传下去。比如解析请求行时数组长度不够或者Header格式里连冒号都没有这时候要回一个明确的4xx状态码同时把连接关闭。别想着能“修复”客户端的坏请求直接丢掉最干净。实操心得解析HTTP报文的类是这套代码里最值得反复测试的。我写完后特意抓了几个真实浏览器的请求报文来对发现很多问题比如请求头key大小写、空格的位数、空行的位置。用真实流量测一次比你读十篇规范文章都有用。4. 让URL和Servlet对上号映射加载与反射调用4.1 我选的映射方案Properties加一个轻量注解手写版要做URL到Servlet的映射方法有很多种。最接近真实Tomcat的是解析web.xml但手写版去实现一个XML解析器太蠢了除非你想顺便练DOM。我选了两层方案基础映射用Properties文件自定义Servlet用注解。Properties文件长这样/hellocom.example.HelloServlet /logincom.example.LoginServlet启动时一次性加载放进一个ServletMapping类里。类内部维护一个MapString, Stringkey是URL路径value是Servlet的类名。注解映射更贴近现在的主流写法。我自己定义了一个WebServlet(路径)注解在启动扫描指定包下的类遇到就登记映射。扫描包的时候注意别用Class.forName去加载每个类因为你只是想看看类上有没有注解用字节扫描工具或者反射的轻量检查都可以。我这版简单处理成只扫描用户配置的一个或几个包。4.2 反射加载Servlet时绕开构造函数这个暗坑拿到类名之后剩下的事情就是反射实例化Class? clazz Class.forName(className); Object servlet clazz.getDeclaredConstructor().newInstance();这一步看着简单里面藏着一个非常经典的坑Java的Class.newInstance()方法在JDK9以后被标记为废弃原因是它无法处理构造器抛出的异常类型而且要求类必须有公开的无参构造方法。我一开始走了老路后来换成了getDeclaredConstructor().newInstance()这个才是官方推荐写法。还有一个更隐蔽的问题如果Servlet类没写无参构造器或者构造器是私有的反射调用直接抛异常。所以我会在实例化前用try-catch捕获InstantiationException并打印明确的错误信息告诉使用者“你的Servlet需要无参构造器”。别图省事把异常吞掉吞掉之后排查问题会痛苦得多。4.3 service方法到底怎么调到接口设计比反射本身更重要有了实例还得调到它的service方法。如果每次都用getMethod(service, Request.class, Response.class)去反射调用那么所有用户Servlet都必须暴露一个叫service、参数类型一致的公开方法耦合很死。更合理的做法是仿照真实Servlet规范定义一个HttpServlet抽象类把service留给用户覆盖内部提供doGet和doPost的默认模板。这样映射层只需要统一调用servlet.service(request, response)。public abstract class HttpServlet { public void service(HttpRequest request, HttpResponse response) throws Exception { String method request.getMethod(); if (GET.equalsIgnoreCase(method)) { doGet(request, response); } else if (POST.equalsIgnoreCase(method)) { doPost(request, response); } } protected void doGet(HttpRequest request, HttpResponse response) throws Exception { response.sendError(405, Method Not Allowed); } protected void doPost(HttpRequest request, HttpResponse response) throws Exception { response.sendError(405, Method Not Allowed); } }反射调用就稳定成一行了Method serviceMethod servlet.getClass().getMethod(service, HttpRequest.class, HttpResponse.class); serviceMethod.invoke(servlet, request, response);或者干脆把HttpServlet定义为接口然后在容器里直接调用。我推荐用抽象类方案因为它给doGet、doPost留了模板位置跟真实用法完全一致以后手写Filter、Listener的时候也好挂。5. 生命周期真没你想的那么简单init到destroy的容器视角5.1 一个Servlet在被你写好后经历了什么生命周期是Servlet容器最核心的功能也是手写版与普通HTTP服务器最大的区别。一个Servlet实例从生到死要经历三个步骤创建实例并调用init()、每次请求调用service()、容器关闭前调用destroy()。创建时机有两种策略。一种是懒加载第一次请求对应URL时才去实例化这是很多容器的默认行为另一种是启动预加载配置里声明了loadOnStartup的Servlet在容器启动时就实例化。我在手写版里默认懒加载但是允许用户通过Properties配置参数控制预加载。public ServletWrapper load(String url) throws Exception { String className mapping.get(url); if (className null) { return null; } // 检查是否已经实例化 ServletWrapper wrapper wrappers.get(className); if (wrapper null) { Object servletInstance Class.forName(className).getDeclaredConstructor().newInstance(); // 容器自身需要维护实例并提供init调用时机 Method initMethod servletInstance.getClass().getMethod(init); initMethod.invoke(servletInstance); wrapper new ServletWrapper(url, servletInstance); wrappers.put(className, wrapper); } return wrapper; }init()只执行一次。这一点必须用代码保证如果多个请求同时打到同一个URL两个线程同时创建实例就可能会执行两边init。我解决的方式是在加载方法上加synchronized或者用ConcurrentHashMap.computeIfAbsent。别小看这个细节并发下实例不唯一会让你的“生命周期”变成笑话。5.2 单实例多线程模型的线程安全风险手写版走的是和标准Servlet一样的单实例多线程模型一个Servlet类只创建一个实例所有请求共用它。优点是省内存代价是Servlet类里的成员变量天然是共享的如果不做同步并发请求就会互相踩。举个例子我第一个测试Servlet里加了一个count成员变量每个请求进来count再写回响应。压测开50个并发结果五花八门有的请求看到45有的看到47反正对不上。这就是典型的竞态条件。解决的方式分三层第一层不要在Servlet里放可变的成员变量第二层实在要放对写操作加锁或者用AtomicInteger第三层整个Servlet设计成无状态的所有状态都从请求参数里取。写真实业务Servlet也是这个思路所以手写的时候就要养成习惯。5.3 关停容器时的清理顺序容器不能一停了之。我有一次CtrlC退出后发现有些测试资源没有被释放第二次启动时端口被占用排查半天才发现是前一个进程根本没干净退出。正确的清理顺序是先停止接收新连接再关闭线程池等待已提交任务结束最后逐个Servlet调用destroy()。destroy里可以做资源释放、日志关闭这类收尾。我用JVM的ShutdownHook挂了一个清理操作确保CtrlC时也能走一遍Runtime.getRuntime().addShutdownHook(new Thread(() - { running false; if (serverSocket ! null) { try { serverSocket.close(); } catch (IOException ignored) {} } executor.shutdown(); destroyAllServlets(); }));提示不要先关Servlet再关线程池。因为线程池里可能还有正在执行的Servlet方法如果destroy先执行了正在跑的请求会突然操作一个已经销毁的对象报错很难看。6. 补齐响应链路状态行、报文头和正文一次说清6.1 手动拼HTTP响应Content-Length必须和实际字节数一致解析完请求业务Servlet会生成内容接着就是把HTTP响应写回客户端。响应的结构跟请求对称状态行、响应头、空行、正文。public void write(String content) throws IOException { byte[] bodyBytes content.getBytes(StandardCharsets.UTF_8); StringBuilder response new StringBuilder(); response.append(HTTP/1.1 200 OK\r\n); response.append(Content-Type: text/html; charsetUTF-8\r\n); response.append(Content-Length: ).append(bodyBytes.length).append(\r\n); response.append(\r\n); OutputStream out socket.getOutputStream(); out.write(response.toString().getBytes(StandardCharsets.UTF_8)); out.write(bodyBytes); out.flush(); }这里最致命的坑就是Content-Length。数值大于实际字节数浏览器会一直转圈等剩下的数据数值小了正文会被截断。而且这个长度是字节数不是字符数。中文内容用content.getBytes().length和用content.length()就差很多我一开始就是被这个坑绊倒的。状态码的选择也要明确请求成功返回200资源找不到返回404Servlet内异常捕获后返回500请求方法不支持则返回405。每种情况下都要保证响应格式完整哪怕Body是一行字也要有头、有状态行、有长度。6.2 静态资源路径解析要防穿越读取要考虑大文件纯Servlet太重型很多时候浏览器只是想拿个JS或CSS文件。手写版里要支持静态资源其实很简单把URI解析成磁盘路径读文件写回去即可。但这个简单逻辑里隐藏着一个严重安全问题路径穿越。String root new File(webapp).getCanonicalPath(); String path new File(root, uri).getCanonicalPath(); if (!path.startsWith(root)) { response.sendError(403, Forbidden); return; }用getCanonicalPath()的理由是它会解析掉../这类相对路径。如果你直接用原始字符串拼接来访者构造一个/../../etc/passwd的URI就能读服务器上任意文件这是漏洞级别的问题。我写完这个功能后用curl测了一堆穿越路径确认返回403才放心。静态资源文件如果比较大比如几MB的图片一次性Files.readAllBytes()读进内存再写出去内存会被直接打穿。正确做法是用流拷贝缓冲区给8KB左右边读边写try (InputStream in new FileInputStream(file); OutputStream out socket.getOutputStream()) { in.transferTo(out); }transferTo在Java 9里才提供底层就是把输入流导到输出流。用它既简洁又高效手写版的实现粒度足够。6.3 中文乱码的本质编码是否写进了响应头乱码问题几乎所有手写HTTP服务器都要遇到。我之前在响应头里只写了Content-Type: text/html没有指定charsetUTF-8浏览器就按本地默认编码去解码中文就成了乱码。这里有两个维度必须统一第一生成正文时用什么编码就用什么编码写进响应头的charset第二如果Servlet里自己对字符串调用getBytes(GBK)而响应头写UTF-8照样乱。因此我约定所有内容统一UTF-8响应头与转换方式保持一致。另外PrintWriter和OutputStream不要混用。PrintWriter有内部缓冲区讲究一个flush时机写一半还没flush就被OutputStream紧接着写头部会造成响应头跑到了正文后面。我最后的结构是先用单调的拼接把所有响应头拼成一个字节数组再拼正文字节数组最后一次性write出去顺序永远不会乱。7. 对照真实Tomcat源码手写版照出了哪些设计必然性7.1 手写版与Tomcat组件的一一对应写完之后回过来看真实Tomcat会发现手写版虽然简单但五脏俱全每个类几乎都能在Tomcat源码里找到影子。手写版类真实Tomcat组件职责BootStrapCatalina入口与组装读取配置启动整个服务器HttpServerConnector监听端口、接收Socket、分发请求HttpRequest / HttpResponseRequest / Response封装协议解析后的数据与输出流ServletMappingMapper根据URL匹配对应的Wrapper/Context等组件HttpServletServlet接口业务处理方法定义ExecutorService线程池ThreadPoolExecutor并发请求处理这张表说明我手写时“拍脑袋”设计的模块边界和Tomcat多年迭代沉淀下来的分层不谋而合。不是因为Tomcat抄谁而是网络服务器的天然结构就是这样。写完后你回头看源码会发现查找一个类省力得多。7.2 为什么Tomcat要把容器拆成Engine、Host、Context、Wrapper真实Tomcat容器部分拆了好几层Engine、Host、Context、Wrapper一层套一层。刚接触的时候很容易觉得这是在堆复杂度。手写版跑起来之后我才意识到这些分层各有实际用途。Host对应的是虚拟主机也就是IP或域名Context对应的是一个Web应用也就是一个WAR包。如果你一台服务器上要运行多个域名每个域名下的应用互相隔离没有Host和Context这两层逻辑上根本表达不了。手写版里我直接用URL路径映射到Servlet相当于把Host和Context全部退化成了一层。Wrapper是Servlet的包装它负责管理单个Servlet实例并记录这个Servlet支持哪些URL映射。我的ServletWrapper就是它的极简版本。Engine是顶层阀门入口负责把请求往下传。所以手写版没必要硬模仿四层结构但要知道四层结构的动机多域名、多应用、多实例管理。面试时能讲清楚这个“为什么会分层”比背一百个类名都有用。7.3 Pipeline和Valve到底解决了什么问题Tomcat里的请求处理并不像手写版那样直接掉到Servlet中间套着一条管道管道上有若干阀门。Valve依次执行每个Valve处理完可以选择继续或者中断。这种设计本质上就是责任链模式。好处是你可以不修改核心代码就往请求处理流程中插入前后处理逻辑比如访问日志、权限校验、请求编码转换。我在手写版里给请求处理加了一个极简的FilterChain雏形就是在反射调用service之前遍历一组过滤器public class SimpleFilterChain { private ListFilter filters; private int index 0; private HttpServlet servlet; public void doFilter(HttpRequest request, HttpResponse response) { if (index filters.size()) { filters.get(index).doFilter(request, response, this); } else { servlet.service(request, response); } } }这段代码只有几十行但把Tomcat的Pipeline思想落到了实处。以后你去看Tomcat源码里的ApplicationFilterChain会发现核心逻辑几乎一模一样。8. 并发与稳定性从单线程憋屈到线程池的实测记录8.1 第一版单线程效果浏览器都等哭了我第一个版本根本没考虑并发直接在主循环里处理请求。结果打开首页时还没什么一个ajax请求加上页面里的CSS、JS三个资源同时发出浏览器连接一个接一个排队。因为主循环每次只处理一个连接处理完一个再accept下一个第一个请求响应完之前后面两个压根没被接进去。更惨的是如果某个Servlet里执行了一个耗时的模拟查询浏览器直接转圈几分钟。那一刻我才真切理解为什么Tomcat的Connector要支持多线程为什么处理线程要尽可能独立于监听线程。用一段真实压测来说单线程版对/hello发起50个并发请求有几条连接直接超时能成功的请求平均耗时超过1.2秒。8.2 线程池参数怎么定按任务类型而不是拍脑袋改成线程池之后我一上来就犯了一个典型错误看了网上说线程池大小等于CPU核数加1就设成了Runtime.getRuntime().availableProcessors() 1。结果并发一高响应还是慢。原因是我这个任务里有IO操作主要是读文件、写Socket线程阻塞等待IO时完全可以再调度别的任务上CPU。手写版的任务更接近IO密集型我给的建议是核心线程数设为CPU核数的两倍比如本机8核就设16到24同时用有界队列兜底。当然这个值不是死的最好在你的机器上跑一次ab压测看线程数从多少开始QPS不再明显上升那个点就是合适值。线程池的拒绝策略也很重要。如果队列满了默认AbortPolicy会直接抛异常用户看到的是连接被重置。我用的是CallerRunsPolicy让提交任务的线程自己跑这个任务至少不会直接把请求丢掉。真实Tomcat面对过载时也有类似思路就是尽量优雅地分流。8.3 用ab做了组对比数据同时注意keep-alive这个隐藏变量我用本机的Apache Bench做了三组测试目标都是/hello这个简单Servlet参数-n 1000 -c 50也就是一共1000次请求每次50个并发。单线程版的表现最差平均响应时间大约在800毫秒以上而且还有不少超时请求。线程池版改完之后平均响应时间降到几十毫秒QPS大概在500到600之间。如果我再开一个while循环在同一个Socket上连续处理请求也就是实现了HTTP keep-alive吞吐量还能再往上走一点。不过keep-alive有个代价如果客户端不发请求也不断开连接线程池里就有一条线程被它占着。所以我对空闲连接加了超时时间比如5秒内没有新请求就主动关闭。这个数字可以被调优真实Tomcat也有类似的keepAliveTimeout配置参数。压测的时候要注意ab -c 50也不是越大越好并发太高时本机端口和线程数都可能成为瓶颈。我自己的经验是从10、20、50、100逐级往上测记录每一档的数据比一上来直接跑1000并发有意义得多。因为你能清楚看到从哪一档开始性能拐头朝下。最后说一句我把手写Tomcat的整个过程整理成这份笔记并不指望它能跑多高的QPS。它的作用就是让我把之前零碎的网络编程、反射、线程池、协议知识串成一条线。如果照着这条路走下来的你跑到某一步跟我遇到同样的现象可以优先看我标了“注意”“提示”的地方基本都是实打实的坑。