在 IDEA 里点一下启动按钮看到 Console 日志一行一行滚出来然后浏览器地址栏输上 localhost:8080页面就出来了。这个流程做 Java 后端的同学闭着眼都能操作。可一旦有人追问一句Tomcat 到底是怎么做到的很多人的回答就卡在“它就是个 Web 服务器、Servlet 容器”这个层面。卡壳不是因为不会用而是从来没有把它当作一个可以拆解的工程系统去研究过。这次我要干的事就是从零手写一个简易版的 Tomcat 核心把端口监听、HTTP 协议解析、路由映射、Servlet 生命周期、线程池并发这些环节全部自己捋一遍。目标不是复刻一个能扛住千万并发的成熟服务器而是把那个启动按钮按下之后发生的事情变成你能亲手控制、逐行调试的代码。如果本身就会写 Servlet 接口、会部署 war 包但没看过 Tomcat 源码这个项目特别适合。手写一遍之后再回头看你用的 Spring Boot 内嵌 Tomcat会发现它不再是一个谜而是一套你心里有数的设计。下面我会按照从一个空项目到能跑通请求的顺序把整个实现过程、踩过的坑和关键决策全部写出来。1. 为什么要手写一个简易 Tomcat1.1 你天天在用却未必真正理解它Tomcat 扮演的角色说白了是这样浏览器发一个 HTTP 请求过来Tomcat 负责把这段符合协议的文本解析成结构化的请求对象然后根据 URL 找到对应的业务处理逻辑拿到结果后再按照协议拼成响应文本回给浏览器。这里面没有太多的玄学就是一套约定好的文本格式的解析与生成过程。但真正让你“每天在用却不懂”的是几个藏在深处的细节协议解析的边界在哪里、Servlet 的 init 方法什么时候被调用、多个请求同时过来时 Servlet 实例是怎么被共享的、响应头里的 Content-Length 如果不设置会出什么问题。这些东西在平时的业务开发里几乎碰不到因为 Tomcat 全帮你做完了。可一旦你遇到诡异问题比如请求偶尔卡死、中文参数严重乱码、并发场景下 Servlet 状态被互相污染如果不懂底层机制排查起来就像在黑暗里摸开关。手写这个简易版最大的价值就是把“网上那些说法”变成“自己验证过的结论”。比如我们常听人说“Servlet 是单例多线程的”这句口诀背后意味着什么只有当你自己设计实例化逻辑、亲自应对多线程并发时才会真正理解它到底有多重要。1.2 手写会带来哪些实质收获第一你会精通 HTTP/1.1 请求这一套文本格式。请求行、请求头、空行、请求体每一段的边界怎么判断GET 查询参数与 POST 表单参数的读取方式有什么不同RFC 规定的换行符如何从 socket 流中安全读取。这些知识写代码的时候认得读别人的框架时也用得上。第二你会彻底搞明白 Servlet 容器到底做什么。对初学者而言Servlet 只是一个接口真正让它“活”起来的是容器容器负责创建实例、调用 lifecycle 方法、在合适的时间把它收集起来。手写一遍之后你会对 WebServlet 注解或 web.xml 中的每一个配置项多一层直感。第三你会建立并发模型的基本判断力。简版 Tomcat 从单线程开始很快就会遇到“一个请求没处理完第二个请求就得排队等待”的尴尬局面。你会亲手引入线程池也会亲手处理线程池满、任务拒收、共享状态并发修改这些问题。这些经验能直接迁移到后面的中间件学习和生产环境问题排查里。2. 动手前的架构拆解与模块划分2.1 连接器与容器分离的设计思想在正式写代码之前我建议先做一件事把 Tomcat 的架构抽象成两部分一部分叫连接器另一部分叫容器。连接器的职责是跟网络打交道——监听端口、接受 Socket、读取字节流、解析 HTTP 请求、把响应写回 Socket容器的职责是处理业务——根据 URL 找到对应的 Servlet、调用 Servlet 的 service、管理 Servlet 的整个生命周期。为什么要做这个拆分因为这条界限决定了服务器能否横向扩展。连接器只关心协议今天用 HTTP/1.1 还是 HTTP/2影响到的只是连接器的实现容器只关心处理逻辑不管请求是从网络来还是从本地测试来都按照同一个接口处理。真实 Tomcat 里Connector 支持 BIO、NIO、NIO2、APR 多种实现就是靠这条抽象边界解耦的。我们自己写的简易版虽然不搞这么多扩展点但保留这个分层后续想加新协议或者替换 IO 模型就不至于动到业务逻辑的代码。从整体请求流程来看一次请求的生命周期可以被切分成六步。第一步ServerSocket 拿到新的 TCP 连接第二步连接器从 Socket 输入流逐行读取请求文本第三步解析出方法、URI、协议版本、请求头等关键信息第四步容器根据 URI 匹配路由表找到对应的 Servlet第五步Servlet 执行业务逻辑把结果写入 Response 对象第六步连接器把响应文本和响应体写回 Socket。这个流程想清楚之后代码结构自然而然就出来了。2.2 这个简易项目由哪些类组成为了让项目保持零依赖可运行我连 Servlet API 都不引入而是自己定义一个极简的接口只保留最核心的语义。整个项目分为以下这些类每个类的职责都非常单一类名职责HttpServer启动监听接受连接将连接交给线程池处理Request封装输入流解析请求行、请求头、查询参数和请求体Response封装输出流负责写状态行、响应头和响应体ServletMapping保存 URL 与 Servlet 类的映射关系提供注册与查询能力Servlet自定义接口定义 init、service、destroy 三个生命周期方法HttpServlet抽象类默认实现 service 方法的分发逻辑区分 doGet/doPostStaticResourceProcessor处理静态资源的读取与返回ServletProcessor加载并实例化目标 Servlet调用其 service 方法这种“一个类只干一件事”的拆分方式最大的好处是排查问题容易。请求解析出错了去 Request 类里找路由匹配不对去 ServletMapping 里找Servlet 没有被调用去 ServletProcessor 里找。早年我写这种练习项目时总喜欢把什么都塞进一个类里结果就是越往后越乱改一个地方崩一串功能。后面我会详细讲这些类的具体实现。3. 实现网络层与 HTTP 请求解析3.1 用 ServerSocket 搭一个可运行的服务端最基础的网络层我们先用 ServerSocket 监听一个固定端口。真实 Tomcat 可以自己配置端口我们这个版本就先写死在 8080。主循环的思路是不断调用 accept 阻塞等待新连接拿到 Socket 之后不要在当前线程里处理业务而是提交给线程池执行。我先给出这个最初的骨架代码public class HttpServer { public static final int PORT 8080; public void start() throws IOException { ServerSocket serverSocket new ServerSocket(PORT); System.out.println(Server started at http://localhost: PORT); ExecutorService executor new ThreadPoolExecutor( 5, 20, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(100), r - { Thread t new Thread(r, simple-server-worker); t.setDaemon(true); return t; }, new ThreadPoolExecutor.CallerRunsPolicy() ); while (true) { Socket socket serverSocket.accept(); executor.submit(() - handleSocket(socket)); } } private void handleSocket(Socket socket) { try (socket) { Request request new Request(socket.getInputStream()); request.parse(); Response response new Response(socket.getOutputStream()); if (request.getUri() null) { response.sendError(400, Bad Request); return; } String uri request.getUri(); if (uri.startsWith(/servlet/)) { new ServletProcessor().process(request, response); } else { new StaticResourceProcessor().process(request, response); } } catch (Exception e) { e.printStackTrace(); } } public static void main(String[] args) throws IOException { new HttpServer().start(); } }这段代码里线程池的设置我会在后面的并发模型章节细讲这里先关注 accept 循环本身。注意我用的是 try-with-resources 来关闭 Socket这意味着每个请求处理完之后连接都会关闭。这确实不算高效但它换来了一个很大的优点不会出现 HTTP keep-alive 连接里前后请求粘包、读取流位置错乱的问题非常适合我们早期调试。3.2 解析请求行、请求头与查询参数拿到 Socket 的输入流之后最关键的就是解析 HTTP 文本。一个最简单的 GET 请求长这样GET /hello?nameworld HTTP/1.1 Host: localhost:8080 User-Agent: curl/7.68.0 Accept: */*注意这里的换行符是 CRLF即\r\n。如果用 Java 的 BufferedReader 的 readLine 方法它会自动把 CR 和 LF 都处理掉最后返回的字符串里不带换行符省了不少事。我的 Request 类的 parse 方法实现如下public void parse() throws IOException { BufferedReader reader new BufferedReader(new InputStreamReader(input, StandardCharsets.ISO_8859_1)); String requestLine reader.readLine(); if (requestLine null || requestLine.isEmpty()) { return; } String[] parts requestLine.split( ); if (parts.length 3) { return; } this.method parts[0].toUpperCase(); this.uri parts[1]; this.protocol parts[2]; int queryIndex uri.indexOf(?); if (queryIndex 0) { this.queryString uri.substring(queryIndex 1); this.uri uri.substring(0, queryIndex); parseQueryString(queryString); } String line; while ((line reader.readLine()) ! null !line.isEmpty()) { int colonIndex line.indexOf(:); if (colonIndex 0) { String name line.substring(0, colonIndex).trim().toLowerCase(); String value line.substring(colonIndex 1).trim(); headers.put(name, value); } } if (POST.equals(method) || PUT.equals(method)) { parseBody(reader); } } private void parseQueryString(String queryString) { String[] pairs queryString.split(); for (String pair : pairs) { int eq pair.indexOf(); if (eq 0) { String key pair.substring(0, eq); String value pair.substring(eq 1); params.put(decode(key), decode(value)); } else if (pair.length() 0) { params.put(decode(pair), ); } } } private void parseBody(BufferedReader reader) throws IOException { String contentLength headers.get(content-length); if (contentLength null) return; int length Integer.parseInt(contentLength.trim()); char[] buffer new char[length]; int read reader.read(buffer, 0, length); if (read 0) { String body new String(buffer, 0, read); if (body.contains()) { parseQueryString(body); } } }这里有几个细节值得单独拿出来说。第一请求头名称在解析时统一转成小写这样后面读取headers.get(content-length)就不会因为大小写问题踩坑。第二请求行通过空格分割正常情况下是三段但如果请求行不完整必须做防御处理不然数组越界异常会让整个服务器进程崩掉。第三POST 请求体的读取依赖 Content-Length 头没有这个头就无法确定要读多少字节这也是 HTTP 协议的一个关键约定。3.3 URL 解码与中文处理URL 里的内容默认是经过 percent-encoding 编码的尤其是中文。比如“你好”会被编码成%E4%BD%A0%E5%A5%BD空格会被编码成或%20。如果你不做解码后面做业务匹配时永远拿不到正确的中文。这里我提供一个封装的 decode 方法private String decode(String value) { if (value null) return null; try { return URLDecoder.decode(value, StandardCharsets.UTF_8.name()); } catch (UnsupportedEncodingException e) { return value; } }值得提醒的是URLDecoder 在把解码成空格的同时也会按你指定的字符集去解码百分号转义序列。因此只要你统一用 UTF-8 发送请求这里就能正确还原出原始字符串。如果发现中文还是乱码大概率是发送端用的编码和这里不一致比如本来用 GBK 发的请求你却用 UTF-8 解码。这个乱码问题很多人在真实 Tomcat 里也遇到过排查思路其实是一样的先确定请求里携带的字节再确定两侧的字符集最后确认有没有统一。另外一个容易被忽略的点解析查询参数时如果 URL 本身带有分号、特殊字符提前做一次判断或者捕获异常更稳妥。我在初版代码里没做异常保护结果遇到一个不规范的请求直接抛出 IllegalArgumentException 导致响应永远发不出去。后来我给 decode 加了兜底才保证了解析异常不会拖垮整个处理流程。4. 实现路由映射与 Servlet 生命周期4.1 路由注册机制现在的服务器还是死板的收到/servlet/hello就去找 class 名为 hello 的类这显然不够用。我们需要给 URL 和 Servlet 类之间建立显式的映射关系。最简单的方式是提供一个注册表也就是一个线程安全的 Mappublic class ServletMapping { private static final MapString, Class? extends Servlet mappings new ConcurrentHashMap(); public static void addServlet(String urlPattern, Class? extends Servlet clazz) { mappings.put(urlPattern, clazz); } public static Class? extends Servlet getServletClass(String urlPattern) { return mappings.get(urlPattern); } public static boolean contains(String urlPattern) { return mappings.containsKey(urlPattern); } }为了让这个表有意义我通常在启动的时候先做一些静态注册。比如ServletMapping.addServlet(/hello, HelloServlet.class); ServletMapping.addServlet(/login, LoginServlet.class);真实 Tomcat 里的映射规则远比这复杂需要支持/user/*、*.do这种通配符匹配还要考虑默认 Servlet 兜底。我们手写版本先支持精确匹配就够了。但有一个点不能省ServletProcessor 在查询路由时如果发现没有命中的映射不能直接 404 了事。一个严谨的做法是先尝试精确匹配再尝试最长前缀匹配最后返回 404。这样后面扩展通配符时只需要改动匹配逻辑不影响调用方。4.2 Servlet 的初始化与销毁Servlet 生命周期是容器管理的重头戏。尤其要理解的是Servlet 实例在第一次被请求时创建随后被所有线程共享。这意味着我们需要做一个懒加载的单例容器用双重检查锁来保证并发安全public class ServletProcessor { private static final MapString, Servlet instanceCache new ConcurrentHashMap(); public void process(Request request, Response response) throws Exception { String uri request.getUri(); Class? extends Servlet clazz ServletMapping.getServletClass(uri); if (clazz null) { response.sendError(404, Not Found); return; } Servlet servlet getServlet(clazz, uri); servlet.service(request, response); } private Servlet getServlet(Class? extends Servlet clazz, String key) throws Exception { Servlet servlet instanceCache.get(key); if (servlet null) { synchronized (ServletProcessor.class) { servlet instanceCache.get(key); if (servlet null) { servlet clazz.getDeclaredConstructor().newInstance(); servlet.init(); instanceCache.put(key, servlet); } } } return servlet; } public void destroyAll() { instanceCache.values().forEach(Servlet::destroy); instanceCache.clear(); } }这套模式对很多人来说并不陌生日常写单例也常见。但放在这里它的意义远不止是“只创建一个实例”init 方法在整个生命周期里只执行一次所以耗时的初始化工作比如读取配置、建立数据库连接池都适合放在 init 里而 service 方法会被并发调用所以 Servlet 实现类里绝对不要用实例字段保存某个请求的临时数据否则下一个请求会覆盖上一个请求的状态。这一点我会在后面的并发问题里继续展开。4.3 用抽象类区分 doGet 和 doPost早期的 Servlet API 直接定义了一个 Servlet 接口里面只有一个 service 方法。真实开发里我们写的是继承 HttpServlet 的类重写 doGet、doPost。为了让我们手写的版本看起来接近真实结构我也定义了一个简单的 HttpServlet 抽象类public abstract class HttpServlet implements Servlet { Override public void service(Request request, Response response) throws Exception { if (GET.equalsIgnoreCase(request.getMethod()) || HEAD.equalsIgnoreCase(request.getMethod())) { doGet(request, response); } else if (POST.equalsIgnoreCase(request.getMethod())) { doPost(request, response); } else { response.sendError(405, Method Not Allowed); } } protected void doGet(Request request, Response response) throws Exception { response.sendError(405, Method Not Allowed); } protected void doPost(Request request, Response response) throws Exception { response.sendError(405, Method Not Allowed); } }这个设计的核心价值在于具体 Servlet 只需要关心自己的业务方法不需要关心请求分发逻辑。你写一个 HelloServlet只需继承 HttpServlet 并重写 doGet 即可。它让 dispatch 逻辑集中在基类也使整个项目更接近真实 Tomcat 的使用方式。也就是说你在这个简易服务器上写的业务类将来迁移到真实 Tomcat 时除了接口包名不同代码结构几乎可以无缝平移。5. 手写静态资源处理与 Session5.1 静态资源返回与 Content-Type浏览器请求一个 HTML 页面或者一张图片时服务器需要把磁盘上的文件字节流原样返回。静态资源处理的第一个决定是资源根目录。我在项目里固定使用classes目录下的webroot作为根目录也就是文件放在编译输出目录里的 webroot 文件夹下即可。处理流程很简单根据 URI 拼接出文件路径判断文件是否存在存在就读取字节并写出不存在就返回 404。代码如下public class StaticResourceProcessor { private static final String WEB_ROOT StaticResourceProcessor.class.getResource(/webroot).getPath(); public void process(Request request, Response response) throws IOException { String uri request.getUri(); if (/.equals(uri)) { uri /index.html; } Path filePath Paths.get(WEB_ROOT, uri); if (!Files.exists(filePath)) { response.sendError(404, File Not Found); return; } byte[] content Files.readAllBytes(filePath); String fileName filePath.getFileName().toString(); String contentType getContentType(fileName); response.setStatus(200, OK); response.setHeader(Content-Type, contentType); response.setHeader(Content-Length, String.valueOf(content.length)); response.writeBody(content); } private String getContentType(String fileName) { if (fileName.endsWith(.html)) return text/html; charsetUTF-8; if (fileName.endsWith(.css)) return text/css; if (fileName.endsWith(.js)) return application/javascript; if (fileName.endsWith(.png)) return image/png; if (fileName.endsWith(.jpg) || fileName.endsWith(.jpeg)) return image/jpeg; return application/octet-stream; } }这个类看着简单但 Content-Length 的设置在真实场景中极其关键。HTTP/1.1 默认是多路复用空闲连接如果响应没有正确的 Content-Length 或者没有使用 chunked 编码客户端不知道该认为消息在哪儿结束。你可以想象一下浏览器收到了一个不完整的响应却以为还有后续内容页面就一直转圈等。我早年手写服务器时就栽过这个坑当时以为写完了输出流再关闭连接就行结果在 keep-alive 场景下怎么刷新都出不来内容最后才发现是漏了 Content-Length。5.2 简易 Session 会话机制Session 存在的意义是解决 HTTP 无状态的问题。浏览器每次请求都是独立的服务器怎么知道“你是谁”答案是通过一个随机的会话 ID。真实 Tomcat 会把这个 ID 通过 Cookie 种到浏览器后续请求带上这个 Cookie服务器就能从内存中找到对应的 Session 数据。我们的实现思路是第一从请求头里解析 Cookie第二如果请求里没有 JSESSIONID就为用户创建一个新 Session ID 并生成对应的对象同时让 Response 通过 Set-Cookie 头把这个 ID 发回去第三把 Session 对象存放在一个 ConcurrentHashMap 中支持按 ID 查询和更新。下面是我当时写的一个 MiniSession 工具类的核心逻辑public class SessionManager { private static final long EXPIRE_INTERVAL 30 * 60 * 1000L; private static final MapString, Session sessions new ConcurrentHashMap(); private static final SecureRandom random new SecureRandom(); public static Session getOrCreateSession(Request request, Response response) { String sessionId request.getCookie(JSESSIONID); if (sessionId null || !sessions.containsKey(sessionId)) { sessionId generateSessionId(); Session session new Session(sessionId); sessions.put(sessionId, session); response.setHeader(Set-Cookie, JSESSIONID sessionId ; Path/; HttpOnly); return session; } return sessions.get(sessionId); } private static String generateSessionId() { byte[] bytes new byte[16]; random.nextBytes(bytes); StringBuilder sb new StringBuilder(); for (byte b : bytes) { sb.append(String.format(%02x, b)); } return sb.toString(); } public static void cleanExpiredSessions() { long now System.currentTimeMillis(); sessions.values().removeIf(session - now - session.getLastAccessTime() EXPIRE_INTERVAL); } }这种简单实现有一个明显的弱点所有 Session 都放在一个内存 Map 里应用重启就全部丢失。真实 Tomcat 支持把 Session 持久化到文件或数据库也会做 Session 迁移和集群同步。但对于我们理解原理的练习来说掌握这个从“无状态到有状态”的转折点就够了。另一个心得是生成 Session ID 时一定要用足够随机的算法不然容易被伪造会话 ID 从而劫持他人会话。SecureRandom 每次生成 16 字节随机数碰撞概率已经很低这算是安全底线。6. 用线程池改造并发模型6.1 默认单线程为什么不行如果服务器只在 accept 循环里直接调用处理逻辑会出现一个非常直观的故障浏览器开两个标签页同时访问同一个服务器时第二个请求必须等第一个请求处理完才能开始。假设某个 Servlet 里做了一个耗时 5 秒的操作整个服务器就“卡”了 5 秒第二个请求哪怕只是访问一个静态文件也要跟着排队。这在真实项目里是不能接受的。Tomcat 之所以能同时服务几十上百个请求是因为每个连接都由独立的线程去处理而线程的管理交给了线程池。不过线程池也不是越大越好线程太多CPU 上下文切换成本飙升线程太少长耗时任务会拖慢所有短任务。这里需要做的是在响应能力和资源开销之间取一个平衡。6.2 线程池接入与参数选择我在最开始的代码里已经展示了线程池的配置这里把参数拆开讲清楚。生产环境里Tomcat 的maxThreads默认值通常是 200minSpareThreads是 25。手写项目规模小我用的是核心线程数 5、最大线程数 20、阻塞队列容量 100 的配置。ExecutorService executor new ThreadPoolExecutor( 5, 20, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(100), r - { Thread t new Thread(r, simple-server-worker); t.setDaemon(true); return t; }, new ThreadPoolExecutor.CallerRunsPolicy() );核心线程数是存活线程数的下限当并发请求数超过 20 时新任务会进入队列队列也满了才会触发拒绝策略。我用的是CallerRunsPolicy意思是任务提交者线程自己执行这个任务这比直接抛出RejectedExecutionException要温柔得多在高峰期至少保证了任务不会丢失只是处理速度会下降。有一点必须提醒线程工厂里的线程要设置为守护线程。这样一来当主线程退出 JVM 时这些工作线程不会阻止进程结束。很多人在手写服务器时遇到过“main 方法跑完了 JVM 却不退出”的诡异现象十有八九就是线程池里的普通线程还活着。另外不要在handleSocket里直接创建裸线程那样并发一上来线程数量会不可控系统资源很快被耗尽。6.3 并发环境下的 Servlet 共享问题线程池改造完成之后并发问题接踵而来。最典型的是 Servlet 实例字段被多个线程同时读写。假设你写了这样一个 Servletpublic class BadServlet extends HttpServlet { private String userName; Override protected void doGet(Request request, Response response) { userName request.getParameter(name); // 模拟耗时操作 String result hello userName; response.write(result); } }两个请求同时进来请求 A 把 userName 写成 Alice请求 B 把 userName 写成 Bob然后 A 在读取 userName 时可能拿到的是 Bob。这就是共享可变状态引发的竞态条件。解决办法很简单不要在 Servlet 里用实例字段保存单次请求的数据所有临时数据都应该放在方法的局部变量里或者放进 Request 对象的 attribute Map 中。这个教训在我刚开始做并发实验时踩得很痛打印日志时看到的用户名字总是对不上号排查了好久才发现是共享变量在捣鬼。7. 常见故障与调试速查7.1 端口被占用导致启动失败启动时报java.net.BindException: Address already in use是新手最容易遇到的。原因几乎都是端口被别的进程占了。解决方案是先找到占用进程再决定处理方式Linux 上用netstat -anp | grep 8080Windows 用netstat -ano | findstr 8080拿到 PID 后可以去任务管理器结束进程也可以换个端口启动。这个问题的本质不是代码逻辑问题而是操作系统资源冲突所以排查思路要跳出代码本身。7.2 中文乱码问题速查有一类问题特别消耗耐心请求参数里的中文、响应页面的中文总有一个地方是乱码。这个问题的背后是字符集不一致核心是搞清楚三个环节的编码接收数据时用什么解析、中间处理时用什么编码、输出时用什么编码。我自己的排查顺序是先看请求行是不是被 ISO-8859-1 读进来的再看 URLDecoder 用的字符集最后看响应的 Content-Type 里是否声明了charsetUTF-8。每一个环节只要有一个不一致显示端就会乱。真实 Tomcat 里头也有同样的三处字符集设置这个排查思路完全通用。7.3 读取请求流时线程阻塞线程池化之后还有一个隐蔽问题如果客户端发来一个没有请求体的 POST 请求但请求头里漏掉了 Content-Length 或者使用了Expect: 100-continueread 操作会一直阻塞在哪里线程就被白白占用住了。我在手写版本里处理得比较简单判断不到 Content-Length 就直接跳过请求体读取但在严格要求下应该设置 Socket 的读取超时时间避免恶意连接长期占据线程资源。这部分虽然只是一个socket.setSoTimeout()的问题但实际线上问题往往就是从这种小细节引爆线程池资源耗尽开始的。7.4 路径匹配与上下文根问题进入服务的 URI 带不带上下文根是很多人混淆的地方。/servlet/hello在你的服务器里到底匹配的是路径还是 Servlet 名取决于路由表的设计。我的简易版本直接拿 URI 作为 key 查询如果后端是前后端分离部署还需要考虑应用名前缀带来的路径差异。调试这种问题最好的方式就是打印请求的原始 URI不要凭直觉判断。我自己调试时会把 method、uri、headers 都输出到控制台看到实际到的数据很多疑惑马上就解开了。下面整理一个速查表方便以后遇到问题快速定位现象可能原因排查方式启动时报端口占用端口被其他进程占用netstat 查看占用并释放端口中文参数乱码解析、解码或输出字符集不一致检查 Request 解码与响应 Content-Type 的 charset页面一直转圈加载响应缺少 Content-Length 或 chunked确认响应头设置了正确的 Content-Length并发访问数据错乱Servlet 实例状态被共享检查是否有可变的实例字段请求偶尔卡死Socket 读取超时未设置设置 soTimeout处理异常并关闭连接线程池满后主线程卡住拒绝策略不当或队列过小调大队列或使用 CallerRunsPolicy8. 扩展方向与我的心得体会8.1 后续可以升级的方向这个项目跑通之后往上走的方向其实很多。你可以给路由表加上通配符支持让映射规则真正贴近 Tomcat 的/user/*和*.do可以加一个简单的过滤器链在请求进入 Servlet 之前做权限校验和日志记录可以把读取 HTTP 正文改成支持分块传输编码还可以把 Session 存储迁移到本地文件或 Redis让它具备跨实例共享的能力。如果想要更接近真实 Tomcat甚至可以实现一个极简的 web.xml 解析器通过 XML 配置来注册 Servlet取代代码里死板的静态注册。每做一步你对 Tomcat 的认知就会深入一层。尤其过滤器链和上下文路径映射这两块做完之后再看 Spring MVC 的请求流程你会发现自己能看懂更多底层逻辑了。8.2 做这个项目后的真实体会写这个项目的过程比我预想中更能暴露自己的知识盲区。最初我以为难点在 Servlet 生命周期真正动手才发现最棘手的是那些闻所未闻的边界问题空请求、超长请求行、流关闭顺序、头部大小写、URL 编码里的特殊符号。每一个问题在真实 Tomcat 里都有专人处理轮到自己写的时候才知道这里的工作量有多大。我个人最受用的一个习惯是把调试工具用起来。手写服务器不像部署好的 Tomcat 有完善的管理界面所以我基本靠curl -v来观察每一次请求的完整往来报文看看请求头发送的原始内容、响应头是否完整。遇到刁钻问题再用抓包工具看字节级的数据流动。这套组合拳下来大部分 Web 协议问题都能在几分钟内缩小范围。最后分享一个很实用的小技巧每次改动代码后不要急着启动服务器先编译一遍再检查一下端口有没有被上次运行的进程占着最后用一个最简单的curl http://localhost:8080/hello来验证基本功能。等这个命令通了再逐步测试带参、带 Cookie、并发访问这些复杂场景。按这个节奏推进即使中途出了奇怪问题也能快速定位到底改错了哪里。其实无论你是刚开始学 Java Web还是已经写了多年业务代码手写一次简易 Tomcat 都非常值得。它不会让你的简历多一行“精通 Tomcat 源码”但会实实在在改变你对 Web 服务器的理解方式。以后别人问你 Tomcat 是怎么工作的你能从 socket 讲起讲到线程池讲到生命周期讲到会话维持——这份脚踏实地的底气才是敲完这几百行代码之后真正留下来的东西。