☰
手写Tomcat核心原理:从HTTP解析到Servlet生命周期全流程拆解
2026/10/5 8:02:50 网站建设 项目流程

把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之前,每次Ctrl+C停掉进程都感觉有点粗鲁,加了之后才算有了干净退出的能力。

3. 协议层不能偷懒:手动解析HTTP报文的完整过程

3.1 请求行:method、uri、protocol的拆分

用Socket接到连接后,第一步永远是读请求行。HTTP请求长这样:

GET /login?name=zhangsan 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长度按字节算

读完请求行之后,后面是一堆请求头,格式固定是“键: 值”,结束标志是一个空行。我的经验是循环读,读到空行就停:

Map<String, 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: chunked,Content-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文件长这样:

/hello=com.example.HelloServlet /login=com.example.LoginServlet

启动时一次性加载,放进一个ServletMapping类里。类内部维护一个Map<String, String>,key是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 关停容器时的清理顺序

容器不能一停了之。我有一次Ctrl+C退出后,发现有些测试资源没有被释放,第二次启动时端口被占用,排查半天才发现是前一个进程根本没干净退出。

正确的清理顺序是:先停止接收新连接,再关闭线程池等待已提交任务结束,最后逐个Servlet调用destroy()。destroy里可以做资源释放、日志关闭这类收尾。我用JVM的ShutdownHook挂了一个清理操作,确保Ctrl+C时也能走一遍:

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; charset=UTF-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,资源找不到返回404,Servlet内异常捕获后返回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,没有指定charset=UTF-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 List<Filter> 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。它的作用就是让我把之前零碎的网络编程、反射、线程池、协议知识串成一条线。如果照着这条路走下来的你,跑到某一步跟我遇到同样的现象,可以优先看我标了“注意”“提示”的地方,基本都是实打实的坑。

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

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

立即咨询