☰
手写简易Tomcat:从零实现HTTP服务器与Servlet容器
2026/9/28 5:16:36 网站建设 项目流程

在 IDEA 里点一下启动按钮,看到 Console 日志一行一行滚出来,然后浏览器地址栏输上 localhost:8080,页面就出来了。这个流程,做 Java 后端的同学闭着眼都能操作。可一旦有人追问一句:Tomcat 到底是怎么做到的?很多人的回答就卡在“它就是个 Web 服务器、Servlet 容器”这个层面。卡壳不是因为不会用,而是从来没有把它当作一个可以拆解的工程系统去研究过。这次我要干的事,就是从零手写一个简易版的 Tomcat 核心,把端口监听、HTTP 协议解析、路由映射、Servlet 生命周期、线程池并发这些环节全部自己捋一遍。目标不是复刻一个能扛住千万并发的成熟服务器,而是把那个启动按钮按下之后发生的事情,变成你能亲手控制、逐行调试的代码。

如果本身就会写 Servlet 接口、会部署 war 包,但没看过 Tomcat 源码,这个项目特别适合。手写一遍之后,再回头看你用的 Spring Boot 内嵌 Tomcat,会发现它不再是一个谜,而是一套你心里有数的设计。下面,我会按照从一个空项目到能跑通请求的顺序,把整个实现过程、踩过的坑和关键决策全部写出来。

1. 为什么要手写一个简易 Tomcat

1.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/doPost
StaticResourceProcessor处理静态资源的读取与返回
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?name=world 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 类之间建立显式的映射关系。最简单的方式是提供一个注册表,也就是一个线程安全的 Map:

public class ServletMapping { private static final Map<String, 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 Map<String, 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. 手写静态资源处理与 Session

5.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; charset=UTF-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 Map<String, 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默认值通常是 200,minSpareThreads是 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 实例字段被多个线程同时读写。假设你写了这样一个 Servlet:

public 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 8080,Windows 用netstat -ano | findstr 8080,拿到 PID 后可以去任务管理器结束进程,也可以换个端口启动。这个问题的本质不是代码逻辑问题,而是操作系统资源冲突,所以排查思路要跳出代码本身。

7.2 中文乱码问题速查

有一类问题特别消耗耐心:请求参数里的中文、响应页面的中文,总有一个地方是乱码。这个问题的背后是字符集不一致,核心是搞清楚三个环节的编码:接收数据时用什么解析、中间处理时用什么编码、输出时用什么编码。我自己的排查顺序是:先看请求行是不是被 ISO-8859-1 读进来的,再看 URLDecoder 用的字符集,最后看响应的 Content-Type 里是否声明了charset=UTF-8。每一个环节只要有一个不一致,显示端就会乱。真实 Tomcat 里头也有同样的三处字符集设置,这个排查思路完全通用。

7.3 读取请求流时线程阻塞

线程池化之后还有一个隐蔽问题:如果客户端发来一个没有请求体的 POST 请求,但请求头里漏掉了 Content-Length 或者使用了Expect: 100-continue,read 操作会一直阻塞在哪里,线程就被白白占用住了。我在手写版本里处理得比较简单,判断不到 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,处理异常并关闭连接
线程池满后主线程卡住拒绝策略不当或队列过小调大队列或使用 CallerRunsPolicy

8. 扩展方向与我的心得体会

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 讲起,讲到线程池,讲到生命周期,讲到会话维持——这份脚踏实地的底气,才是敲完这几百行代码之后,真正留下来的东西。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询