1. 为什么值得手写一个 Tomcat最近抽时间把 Tomcat 的核心流程手写了一遍。没有用 Spring Boot也没引任何现成的 Web 框架就从ServerSocket开始把一个 HTTP 请求接进来、解析出路径、找到对应的 Servlet、再把响应写回去。做完之后再看平时那些 Tomcat 启动闪退、404、乱码、端口占用问题感觉完全不一样了。这篇文章就是把整个思路、代码过程和踩过的坑都整理出来给同样想拆黑盒的你做个参考。很多人用了很多年 Tomcat知道它是 Servlet 容器知道把 war 包丢进webapps就能跑但未必清楚一个请求从浏览器发出来到 Servlet 执行完中间到底发生了什么。手写一个迷你版不是为了替代 Tomcat而是为了把这条链路彻底拆开让你理解 Tomcat 最核心的几个职责监听端口、解析 HTTP 协议、维护 Servlet 生命周期、处理多线程。这些理解到位之后线上排障会顺手很多至少不会再对着日志一头雾水。1.1 拆掉黑盒Tomcat 到底在干什么Tomcat 本质上是两部分功能的叠加第一部分是 HTTP 服务器负责跟浏览器通过 TCP 协议通信第二部分是 Servlet 容器负责加载和管理你写的 Servlet 类。浏览器发送的请求是一段纯文本符合 HTTP 协议格式Tomcat 的任务就是把这段文本读出来、按协议拆解然后转换成 Java 对象最终调用你写的业务代码。这里有个特别容易忽略的点Servlet 类不是你直接new出来的而是容器在适当的时机创建并调用的。所以手写版里必须有一个注册表记录哪个 URL 路径对应哪个 Servlet 类。浏览器请求/hello容器就去注册表里查找到HelloServlet实例化调用它的service方法。找不到就返回 404。这个查找和调度的过程其实就是路由的核心。很多刚接触的人会把 Tomcat 当成简单的服务器来用忽略了它作为容器的生命周期管理。实际上一个 Servlet 从类加载、实例化、初始化、提供服务到销毁每一步都由容器触发。手写版哪怕不完整实现全部生命周期也要把初始化一次、多线程调用、最后销毁这个思路做出来否则后面做高并发和资源清理时容易出问题。1.2 手写版本要做到什么程度才够用完整实现 Tomcat 的 HTTP 2.0、NIO 多路复用、JNDI、JMX 这些特性显然不是一篇文章能覆盖的事。手写版的目标应该是够用能接收 HTTP 请求能解析出方法和路径能根据路径调用对应的 Servlet能把静态资源返回给浏览器。做到这一步核心链路就已经通了剩下的功能都是在这个骨架上做加法。我给自己定的范围是只处理GET和POST两种方法支持查询参数解析、支持简单表单body解析、支持WebServlet注解式映射、支持静态资源返回。Session、Filter、管道这些先不做等核心跑通之后再往后加。这样的好处是思路清晰不会一开始就被复杂的规范绕晕。把这个边界想清楚很重要否则很容易陷入无限接近 Tomcat的泥潭。我在动手之前列了一张清单把必须项和加分项分开了。必须项里包括端口监听、HTTP 报文解析、Servlet 映射、响应输出加分项里包括 Session、Cookie、过滤器和优雅关闭。先完成必须项再根据兴趣扩展。实际做下来光是必须项就能学到很多东西尤其是 HTTP 协议细节。2. 动手前的设计先想清楚再写代码我见过不少人手写 Web 服务器上来就写ServerSocket然后陷入怎么解析请求的泥潭。原因就是没想清楚整个调用链。其实这个项目的逻辑不复杂复杂度都在边边角角比如编码、线程安全、资源释放。所以设计阶段要把几个关键决策定下来后面写代码会顺很多。2.1 整体架构从 ServerSocket 到 Servlet 的调用链整体可以分成四层第一层是MiniTomcat启动类负责创建ServerSocket并进入 accept 循环第二层是线程池负责接收每个进来的 socket 并分发给工作线程第三层是请求处理和响应封装把 socket 输入流解析成Request对象把Response对象写回输出流第四层是 Servlet 容器维护 URL 和 Servlet 实例的映射。请求的处理顺序是这样的浏览器发出请求TCP 连接到达监听端口accept()返回一个Socket线程池里的工作线程开始处理。工作线程先从输入流里读请求行和请求头再根据请求方法解析参数接着查映射表找到对应的 Servlet 并调用。如果没找到就看请求路径是否指向静态文件是就读取文件返回不是就返回 404。所有响应都通过统一的Response类输出保证格式一致。这个链条看起来简单但每一步都有坑。比如 HTTP 请求头可能跨多个 TCP 包到达直接readLine()可能读到半截再比如InputStreamReader的默认字符集依赖操作系统不显式指定就会在 Windows 上出乱码。设计阶段提前意识到这些问题写代码的时候就会主动处理而不是等问题出现了才补。2.2 核心对象有哪些Request、Response、Mapping 和线程池手写版需要四个核心对象优先级从高到低排列Request负责解析和承载请求信息Response负责构建和输出 HTTP 响应ServletMapping负责路径与 Servlet 的对应关系线程池负责并发处理。Request对象里至少要保存方法、URI、协议版本、请求头、查询参数和 post body。把 URI 和 query string 分开存储因为 Servlet 的映射匹配只关心路径部分参数要单独解析成 Map。Response对象里要持有输出流提供write(String content, String contentType)这样的方法所有输出都走同一个出口这样能保证响应头格式统一。ServletMapping可以是简单的MapString, Servletkey 是路径value 是 Servlet 实例。这里要特别注意同一个 Servlet 实例会被多个线程并发调用所以 Servlet 实现里不能有共享的可变状态否则线程安全问题很隐蔽。线程池直接用ThreadPoolExecutor不要自己手动new Thread因为无限制创建线程会拖垮系统。2.3 线程模型BIO 线程池为什么是最佳起点Tomcat 发展到现在支持 NIO 和 APR但在手写版里我用的是传统 BIO 加线程池。原因很简单BIO 的编程模型跟 HTTP 请求处理天然对应一个连接一个任务逻辑清晰方便初学者理解。NIO 的 Selector 模型虽然性能高但事件驱动和回调会显著增加理解成本不适合作为第一版。线程池的核心参数要提前定好。我设的是核心线程数 8最大线程数 20队列容量 100拒绝策略用CallerRunsPolicy。注意拒绝策略很关键如果默认抛异常高并发下服务会直接挂掉用CallerRunsPolicy虽然会让 accept 线程去执行任务但至少不会丢请求。BIO 的代价是每个连接占用一个线程长连接场景下浪费资源。可手写版的目标不是支撑百万并发而是把请求链路跑通。真到了性能优化那一步再换成SocketChannel也来得及。我自己的体会是先用 BIO 把业务逻辑写对再谈 NIO 优化这个顺序更适合个人项目。3. 核心代码实现从请求解析到 Servlet 调用这个部分是我认为整个项目里最有价值的一段因为它直接对应你平时配置 Tomcat 时那些看不见的步骤。每写一个类都对应一个实际要解决的问题。这里我按照实际开发顺序来写你可以跟着直接敲。3.1 请求解析把 HTTP 报文转成 Java 对象HTTP 请求报文的首行格式是固定的方法 路径 HTTP/版本。例如GET /hello?nametomcat HTTP/1.1。解析的第一步就是读这一行然后按空格切分。这里有个坑查询参数是 URI 的一部分所以/hello?nametomcat中路径和 query 要拆开否则后面做映射匹配会失败。public class Request { private String method; private String path; private String protocol; private MapString, String params new HashMap(); public static Request parse(InputStream in) throws IOException { BufferedReader reader new BufferedReader(new InputStreamReader(in, UTF-8)); String requestLine reader.readLine(); if (requestLine null || requestLine.isEmpty()) { return null; } String[] parts requestLine.split( ); Request request new Request(); request.method parts[0]; request.protocol parts[2]; String fullPath parts[1]; int idx fullPath.indexOf(?); if (idx 0) { request.path fullPath.substring(0, idx); parseParams(fullPath.substring(idx 1), request.params); } else { request.path fullPath; } return request; } }这里需要注意编码问题。URI 中的非 ASCII 字符浏览器会按照 UTF-8 做百分号编码所以解析 query 时要先用 ISO-8859-1 读取原始字节再按 UTF-8 解码。直接用URLDecoder.decode(value, UTF-8)是标准做法不要偷懒不指定字符集。请求头是从第二行开始的格式是键: 值直到空行为止。读取的时候要循环readLine()遇到空字符串就停止。请求体只对 POST 有意义而且长度由Content-Length头决定所以不要盲目读否则会阻塞。GET 一般不需要读 bodyPOST 才需要读Content-Length指定的字节数。3.2 响应封装让浏览器能正确渲染页面响应对象的核心职责是拼接 HTTP 响应头然后把 body 写出去。很多新手直接往输出流写html标签浏览器也能解析但因为没有Content-Type中文大概率乱码。所以响应类一定要提供一个统一出口把字符集固定成 UTF-8。public class Response { private OutputStream out; public Response(OutputStream out) { this.out out; } public void write(String content, String contentType) throws IOException { byte[] body content.getBytes(UTF-8); StringBuilder head new StringBuilder(); head.append(HTTP/1.1 200 OK\r\n); head.append(Content-Type: ).append(contentType) .append(;charsetutf-8\r\n); head.append(Content-Length: ).append(body.length).append(\r\n); head.append(Connection: close\r\n\r\n); out.write(head.toString().getBytes(ISO-8859-1)); out.write(body); out.flush(); } }响应头里的Content-Length很重要浏览器靠它判断 body 是否接收完毕。如果省略这个头HTTP/1.1 默认是 keep-alive浏览器会不知道响应何时结束页面就可能一直转圈。实践中最稳的做法是Connection: close让服务端写完 body 后关闭连接省去处理持久连接的复杂度。静态资源响应也走同一个方法区别只在于 body 是文件内容而不是动态拼的字符串。Content-Type 要根据文件后缀判断.html用text/html.css用text/css.js用application/javascript.png用image/png。我写了一个简单映射表虽然不完整但日常够用。3.3 Servlet 注册与映射找到该干活的那个类Servlet 映射是手写版最有容器感的部分。真实 Tomcat 会解析web.xml或扫描注解我这里为了简单采用的是扫描WebServlet注解。启动时遍历指定包下的所有类找到带注解的类把注解里的 URL 路径当作 key实例化的 Servlet 当作 value存入ConcurrentHashMap。WebServlet(/hello) public class HelloServletServlet implements MiniServlet { Override public void service(Request request, Response response) throws IOException { String name request.getParam(name); if (name null) { name world; } response.write(h1Hello, name !/h1, text/html); } }这里要定义一个自己的MiniServlet接口里面只有一个service方法参数是前面写好的Request和Response。为什么不用官方javax.servlet.Servlet因为手写版目标不是做完整规范实现而是理解链路自己定接口更灵活也不会被一大堆必须实现的方法牵扯。实例化时机要注意Servlet 应该是首次请求时创建并缓存还是启动时提前创建Tomcat 默认支持load-on-startup但手写版启动时创建所有实例更省事也更容易排查问题。代价是启动慢一点对于学习项目无伤大雅。如果后续要加懒加载可以用Class.forName反射构造但要做好线程安全。3.4 启动入口accept 循环、线程池和关闭逻辑启动类的骨架是固定的创建ServerSocket绑定端口循环accept()把拿到的Socket提交给线程池处理。真正要费心的是初始化和关闭两个阶段。初始化要做三件事扫描并注册 Servlet、加载静态资源配置、启动线程池。任何一步失败都应该直接抛异常退出不要静默吞掉。public class MiniTomcat { private final int port; private ServerSocket serverSocket; private ExecutorService executor; public void start() throws IOException { scanServlets(); serverSocket new ServerSocket(port); executor new ThreadPoolExecutor( 8, 20, 60, TimeUnit.SECONDS, new ArrayBlockingQueue(100), new ThreadPoolExecutor.CallerRunsPolicy() ); while (!Thread.currentThread().isInterrupted()) { Socket socket serverSocket.accept(); socket.setSoTimeout(3000); executor.submit(new RequestTask(socket)); } } }启动后可以在main方法里打印一行日志告诉用户访问哪个端口。这一点很关键不然启动成功了你都不知道去访问哪里还以为是假死。我一般会打印MiniTomcat started on port 8080, URL: http://localhost:8080/然后看一眼控制台确认端口没被占用。关闭逻辑要处理优雅退出停止接受新连接、关闭线程池、等待已有任务执行完。Java 里先调用executor.shutdown()再调用awaitTermination给一个合理的超时时间。如果直接System.exit(0)那些还在处理请求的线程会被强行终止极端情况下会导致响应写到一半客户端收到截断数据。4. 启动调试与常见问题排查实录手写版跑起来之后你会碰到一堆平时用 Tomcat 也会遇到的问题。这些问题实际上和真实 Tomcat 的排障逻辑一模一样只是因为你写的代码自己能看所以排查起来更有底。我把几个典型问题整理成一个速查表都是我实际踩过的坑。现象根因排查和解决启动就闪退端口被占用或初始化异常被吞先看控制台第一行日志用netstat -ano或lsof -i:8080查端口占用请求中文乱码请求或响应字符集没有统一指定查询参数按 UTF-8 解码响应头加charsetutf-8访问一直 404映射路径和请求路径不一致或 context path 没处理打印解析出的 path和注册表里的 key 逐一对比页面显示但不带样式静态资源 Content-Type 不对.css返回text/css.js返回application/javascript高并发下请求丢失线程池拒绝策略不恰当用CallerRunsPolicy或增大队列长度关闭后端口很久才释放线程池没有 shutdownsocket 没 close确认关闭逻辑里调用了executor.shutdown()和socket.close()4.1 启动闪退端口占用和初始化异常手写版启动闪退最常见的原因就是端口被占用。很多人习惯继续用 8080如果本机已经跑了一个 Tomcat 或别的服务new ServerSocket(8080)就会抛java.net.BindException。第一次遇到时我以为是代码写错了反复检查了很久最后才发现是自己之前跑的服务没关干净。排查方法很简单lsof -i:8080或者 Windows 上的netstat -ano | findstr 8080找到 PID 再决定是关掉进程还是换端口。另一类闪退原因是初始化异常被吞。比如扫描 Servlet 包时类名写错ClassNotFoundException抛出来但如果你在catch里只打印e.printStackTrace()后继续启动最后啥也跑不起来。我的做法是初始化阶段用Objects.requireNonNull和主动抛出IllegalStateException让错误在启动时就暴露而不是等请求过来了再挂。建议把端口号抽成启动参数用-Dmini.port9090传入这样不用改代码就能切换端口。平时调试时我常用一个随机端口比如0让操作系统自动分配避免和其他服务冲突。扫描端口链表用netstat很容易但也要养成习惯写代码时不要硬编码端口。4.2 乱码问题请求、响应的编码源头在哪里乱码几乎是新手必踩的坑而且分很多种。第一种是 GET 请求参数乱码根因是 URI 里的百分号编码没有按 UTF-8 解码。比如你传?name张三线上网络传输的是%E5%BC%A0%E4%B8%89如果你用平台默认编码Windows 上常见 GBK去解码出来的就是乱码。解决办法就是明确URLDecoder.decode(value, UTF-8)不要依赖默认值。第二种是 POST body 乱码。表单提交时浏览器会按页面编码对 body 编码通常也是 UTF-8但服务端读取时要按Content-Type里的charset来读。手写版为了方便可以约定只支持 UTF-8在读取 body 时就指定 UTF-8不用回退。第三种是响应乱码。响应头里必须带上Content-Type: text/html;charsetutf-8否则浏览器可能会按照 ISO-8859-1 去解码中文全变问号。我在写Response类时统一在write方法里拼接charsetutf-8所有业务代码走这个出口就不会遗漏。如果你自己手写响应头一定要记得这条。4.3 404 的真相路径匹配的隐藏规则404 有两种情况服务器没收到请求或者收到请求但没找到对应资源。手写版最常见的坑是映射路径带了前缀比如给WebServlet(/app/hello)但浏览器请求的是/hello。真实 Tomcat 里这叫 context path 问题你的应用部署在webapps/myapp下访问路径就得带/myapp。手写版没有 context path 概念但你在设计映射表时仍然要想清楚注册表里的 key 到底要和 Request.path 完全相等还是支持前缀匹配我实际测试下来最简单可靠的做法是完全相等匹配。如果请求路径是/hello映射表里查到就执行查不到再尝试静态文件。有人说 Restful 风格路径带参数怎么办那就需要更复杂的前缀匹配和正则匹配不属于第一版范围。可以先打印出解析后的 path然后拿着这个 path 跟注册表里所有 key 比较很快就能看出问题。另外要注意 URL 末尾的斜杠。/hello和/hello/是两个不同的字符串如果映射表里只有/hello访问/hello/就会 404。真实 Tomcat 会对这种路径做重定向但手写版完全可以先不管记录成已知限制。我自己踩过这个坑后在注册映射时做了个简单处理把 key 末尾的斜杠去掉再存入。4.4 用日志和抓包定位请求处理的每一环手写版没有 Spring Boot 那种丰富的日志体系所以排查问题更依赖原始手段。我的做法是在几个关键节点打日志accept 到连接后打一行解析完请求后打印 method、path、params调用 Servlet 前再打一行。这样请求走到哪一步断了一眼就能看到。JDK 自带的java.util.logging就够用不用额外引框架。抓包手段也很有用。浏览器 F12 的 Network 面板能看到请求响应头curl -v能看到完整报文。如果响应头里的Content-Length和实际 body 长度不一致浏览器会报错这时候用curl能直接看到二进制层面的问题。还有一个经验如果觉得服务响应慢要注意是不是无意中调用了socket.getInetAddress().getHostName()这个方法会尝试反向域名解析在网络环境里可能卡几秒。手写版里直接用getRemoteAddr()拿 IP 就够了这个坑我在真实 Tomcat 访问日志里也见过多次。5. 再往前一步给手写版加 Session、Filter 和自启动核心链路跑通之后你会觉得不过瘾想往里加东西。这个阶段是收获最大的因为每加一个功能都是对 Tomcat 设计哲学的一次复现。这里我分享一下我加的三个功能以及它们各自踩到的坑。5.1 让 Session 生效Cookie 与 JSESSIONIDSession 的本质是在服务端保存用户数据用 Cookie 里的一个 ID 来关联。手写版实现 Session最简单的方式是维护一个ConcurrentHashMapString, Sessionkey 是随机生成的 Session ID。第一次请求时服务端生成 ID放进 Cookie 返回给浏览器后续请求带这个 Cookie服务端就能从 Map 里找到对应的 Session。这里有个关键点Cookie 头的解析格式是Cookie: JSESSIONID...。请求解析阶段要把 Cookie 头单独存起来或者在 Header 里直接取。响应阶段需要额外输出一行Set-Cookie: JSESSIONIDxxx; Path/; HttpOnly这样浏览器才会存住。要注意HttpOnly属性它禁止 JavaScript 读取 Cookie对安全很有意义手写版也应该带上。Session 的过期时间也要处理。真实 Tomcat 默认 30 分钟手写版可以在 Session 对象里存lastAccessedTime每次请求更新一次再用一个后台线程定期清理过期 session。虽然麻烦但做完之后你就明白为什么生产环境要调 session timeout也明白集群环境下为什么 session 复制这么复杂。5.2 加一层过滤器管道Filter 可以通过过滤器管道看到这个设计。最直观的方式是定义一个MiniFilter接口也只有一个方法doFilter(Request, Response, FilterChain)。然后把所有 Filter 按注册顺序存成列表FilterChain里维护一个计数器每执行一个 Filter 就调用下一个直到最后一个才真正调用 Servlet。public class FilterChain { private final ListMiniFilter filters; private final MiniServlet servlet; private int index 0; public void doFilter(Request request, Response response) throws IOException { if (index filters.size()) { filters.get(index).doFilter(request, response, this); } else { servlet.service(request, response); } } }这段代码看起来简单但它已经体现了责任链模式的思想。真实 Tomcat 里除了 Filter还有 Valve、Pipeline 这些东西本质上都是一层层往下传递。我加 Filter 的时候发现请求在进入 Servlet 前的所有处理都可以通过管道组合起来比如编码统一设置、登录状态校验、性能计时都能优雅地插进去。这个抽象能力比 Filter 本身更重要。5.3 Linux 下做自启动脚本真实 Tomcat 在 Linux 部署时经常要做自启动热词里也有一堆相关搜索。手写版其实同样可以配启动脚本。我在项目根目录写了一个start.sh内容很简单先检查JAVA_HOME是否存在再设置 JVM 参数最后用nohup后台启动并把日志写到独立文件。#!/bin/bash export JAVA_HOME/usr/lib/jvm/java-1.8.0 APP_HOME$(cd $(dirname $0)/.. pwd) CLASSPATH$APP_HOME:$(find $APP_HOME/lib -name *.jar | tr \n :) nohup $JAVA_HOME/bin/java -Xms128m -Xmx512m \ -Dmini.port8080 \ -cp $CLASSPATH com.example.MiniTomcatMain \ $APP_HOME/logs/run.log 21 echo $! $APP_HOME/run.pidLinux 下真正要用的是 systemd 服务但手写版用简单脚本反而更能看清进程管理的关键PID 文件保存进程号日志文件保存标准输出和错误输出JVM 参数通过-D传给 Java 系统属性。这样设置之后启动、停止、排查崩溃都清晰可控。做完这个部分我还顺手把 JVM 参数调优试了一遍。堆内存设太小并发一上来就OutOfMemoryError设太大又可能在容器环境被整体杀掉。手写版虽然没有线上流量但通过调整-Xms和-Xmx观察 GC 日志的变化能帮你积累不少容器环境下调参的经验。你之后再去配真实 Tomcat 的catalina.sh对这些参数就不会陌生了。如果你也想动手写一个我建议别一开始就追求完整先从解析请求开始把第一个 Servlet 跑通然后慢慢加 Session、Filter、自启动脚本。等看到浏览器里弹出自己服务器返回的页面时那种从底层把东西做出来的感觉会比你单纯配置一个 Tomcat 要有意思得多。踩几个坑不怕手写版最大的价值就是让你敢去踩坑也让你踩完坑之后真正明白为什么会这样。