手写Tomcat绕不开的坎,就是线程池的引入。我自己写简易Servlet容器的时候,最开始根本没想这么深:一个ServerSocket循环,accept()之后直接new Thread处理,逻辑上特别通顺,压测到二三十并发也感觉还不错。直到有一次联调环境里并发冲到几百,线程数不像话地往上涨,CPU跟着报警,我才认真去研究线程池到底该怎么引入。这篇文章不聊花哨的架构,只讲两件事:一,手写Tomcat为什么要引入线程池,不引入会怎样;二,线程池真正需要理解的核心知识点,尤其是一些官方文档里容易模糊的细节。适合正在撸手写Web服务器、或者对Tomcat线程池原理好奇的同学。
1. 从BIO到线程池:手写Tomcat最初是怎么被压垮的
1.1 最初一版:一句话的"每请求一线程"
几乎所有手写Tomcat的起步版本都长一个样:main方法里起一个ServerSocket,循环等待连接,拿到Socket之后直接开个新线程去处理。代码大概是这样:
public class SimpleTomcat { private ServerSocket serverSocket; private volatile boolean running = true; public void start(int port) throws IOException { serverSocket = new ServerSocket(port); while (running) { Socket socket = serverSocket.accept(); new Thread(() -> handleRequest(socket)).start(); } } }这个版本的可读性很好,符合直觉:每个来访的连接都有一个专属线程,互相不干扰。问题在于"每个连接一个线程"这句话,在很多场景下会变成"每个连接一个重量级对象"。
线程本身的开销不是零。默认栈大小通常1MB左右,就算现代操作系统的虚拟内存不值钱,创建和销毁线程时涉及的系统调用、线程上下文切换、缓存失效,都会实实在在地吃掉CPU。更麻烦的是,一旦并发连接数变多,线程数就会线性增长,然后整个进程的资源都被线程管理本身占据,真正做业务的CPU时间反而变少了。
这个阶段你会观察到的典型现象是:用JMeter或wrk压到三四百并发时,响应时间开始剧烈抖动,FGC频次增加,甚至出现java.lang.OutOfMemoryError: unable to create new native thread。线程数失控是手写容器最常见的死法。
1.2 并发一上来,问题就藏不住了
为什么线程池能解决"每请求一线程"的问题?表面上看是资源复用,深一点看是三个能力:
- 限制并发执行的任务数:不是所有请求来了就立刻跑,而是由线程池决定当前最大同时处理多少请求,把多余请求放到队列里排队。
- 复用线程对象:线程执行完一个任务后不销毁,继续执行队列里下一个任务,省掉反复创建/销毁的开销。
- 提供可观测的管理手段:线程池可以查询当前线程数、活动线程数、队列积压量、已完成任务数。这一条在手写Tomcat里特别重要,因为一旦容器出了问题,至少能知道是线程不够还是队列堵死。
这里要澄清一个容易误解的点:线程池并不是让单个请求处理得更快。恰恰相反,如果在池线程之外还有富余的CPU,每请求一线程的极限吞吐可能还更高。线程池真正的价值是让系统在大量并发下保持稳定可控,避免线程数量膨胀拖垮整个进程。对于手写Tomcat这种需要长期运行的服务器程序,稳定性优先级远高于极限吞吐。
2. 引入线程池之前,先把Java线程池的工作机制想明白
2.1 从Executors速成到ThreadPoolExecutor真面目
很多初学者第一次接触线程池都用Executors工具类:
ExecutorService executor = Executors.newFixedThreadPool(200);这里有两个隐患,在手写Tomcat里都不合适。
newFixedThreadPool底层用的是LinkedBlockingQueue,队列容量是Integer.MAX_VALUE,默认无界。一旦业务处理跟不上,所有请求都会排队,队列里的Runnable积压起来,最终候内存溢出。对于一个Web容器来说,这意味着拒绝服务,而不是优雅限流。
newCachedThreadPool更极端:核心线程数为0,最大线程数为Integer.MAX_VALUE,任务一进来就尝试创建新线程,空闲线程60秒回收。这相当于把"每请求一线程"重新请回来了,只是加了回收机制,并发爆发时依然可能创建几十万个线程。
手写Tomcat里有真实业务压力,必须自己面对ThreadPoolExecutor的完整参数,这也是理解线程池的必经之路。
2.2 线程池的那四个关键阶段:核心线程、队列、扩容、拒绝
ThreadPoolExecutor.execute()的流程是标准而且固定的,我建议直接把它背下来:
- 如果当前工作线程数小于
corePoolSize,创建新核心线程执行任务。 - 如果当前线程数已经大于等于
corePoolSize,尝试把任务放入workQueue队列。 - 如果队列已经满了,再尝试创建新线程,直到线程数达到
maximumPoolSize。 - 如果线程数已经等于
maximumPoolSize且队列也满了,执行拒绝策略。
这个默认顺序对大部分业务系统是合理的:先用空闲的核心线程,忙不过来就排队,排队太多再扩线程。但放到Tomcat的场景里就会有点别扭,后面我会专门讲Tomcat是怎么改的。
参数之间的关系可以整理成一张表,方便对照:
| 参数 | 含义 | 手写Tomcat里的角色 |
|---|---|---|
| corePoolSize | 长期保留的工作线程数量 | 相当于最小空闲线程数 |
| maximumPoolSize | 允许的最大工作线程数 | 相当于最大线程数 |
| workQueue | 放等待任务的队列 | 请求排队缓冲区 |
| keepAliveTime | 非核心线程空闲存活时间 | 空闲回收策略 |
| threadFactory | 创建线程的工厂 | 设置线程名、Daemon标记 |
| handler | 队列满、线程满时的处理方式 | 拒绝策略 |
不要小看这四个阶段的顺序。同样是"请求来了没线程处理",是选择排队、创建新线程还是直接拒绝,直接影响Web服务器的行为特征。后面手写Tomcat改进时,核心就是调整这个顺序。
3. 手写Tomcat改进第一步:把请求交给线程池
3.1 一个能直接跑的最小改造
引入线程池的第一步不是把代码改成花哨的架构,而是用一个Acceptor线程只负责接收连接,处理逻辑全部交给线程池。这个结构是Tomcat连接器的雏形。
public class ThreadPoolTomcat { private final ServerSocket serverSocket; private final ThreadPoolExecutor executor; private final AtomicBoolean running = new AtomicBoolean(true); public ThreadPoolTomcat(int port, int core, int max, int queueSize) throws IOException { this.serverSocket = new ServerSocket(port); ThreadFactory threadFactory = new ThreadFactory() { private final AtomicInteger seq = new AtomicInteger(); @Override public Thread newThread(Runnable r) { Thread t = new Thread(r, "server-thread-" + seq.getAndIncrement()); t.setDaemon(false); return t; } }; this.executor = new ThreadPoolExecutor( core, max, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(queueSize), threadFactory, new ThreadPoolExecutor.CallerRunsPolicy() ); } public void start() { Thread acceptor = new Thread(this::acceptLoop, "acceptor-thread"); acceptor.start(); } private void acceptLoop() { while (running.get()) { try { Socket socket = serverSocket.accept(); executor.execute(() -> processRequest(socket)); } catch (IOException e) { if (running.get()) { e.printStackTrace(); } } } } private void processRequest(Socket socket) { try (Socket s = socket) { // 读取请求行、解析头、调用Servlet、写响应 } catch (Exception e) { e.printStackTrace(); } finally { // 确保连接关闭 } } public void shutdown() throws IOException { running.set(false); serverSocket.close(); executor.shutdown(); } }这个版本已经有三个关键点:
- Acceptor线程数固定为1。
accept()是阻塞的,一个线程足够接收连接,真正的业务由线程池并发处理。 - 线程池使用有界队列。
ArrayBlockingQueue可以让并发请求在队列满之后被限流,而不是无限堆积。 - 线程工厂命名。每个线程都有可读的名字,出问题时用
jstack一眼就能看出哪些线程是容器自己的。
3.2 改造中的几个坑:线程池怎么初始化、怎么传Socket
第一个坑:线程池必须是容器级别的单例。如果processRequest里每次请求new ThreadPoolExecutor,那等于另类的每请求一线程,线程池的意义完全丢失。线程池应该在start()之前创建好,并伴随容器整个生命周期。
第二个坑:Socket不能直接交给业务线程后就没人管了。processRequest里必须用try-with-resources或finally关闭连接,否则时间一长,Socket句柄会泄漏,表现就是Too many open files。
第三个坑:线程池里的任务可能会抛异常。如果使用executor.execute(),异常会被线程池吞到任务执行的框架内部,不处理的话日志里什么都没有。建议在Runnable.run()里整体包一层try/catch,至少打出一条错误日志。这一点比线程池参数本身更容易被忽略,但生产环境里往往先暴露的正是这样的问题。
第四个坑:拒绝策略的选择有讲究。AbortPolicy默认抛出异常,Acceptor线程收到异常后如果没处理,请求就直接丢失;DiscardPolicy和DiscardOldestPolicy更隐蔽,会静默丢弃。CallerRunsPolicy会让提交任务的线程——也就是Acceptor——自己去执行任务,好处是自然限流,坏处是Acceptor被占用后,新的连接无法及时accept。手写Tomcat的初期我更推荐CallerRunsPolicy,它至少不会丢请求,代价只是接收连接的速度变慢。按需取舍。
4. Tomcat线程池和Java内置线程池的"不一样"
4.1 Tomcat为什么不用Executors
如果你翻过Tomcat源码,会发现它没有直接用Executors,而是自己在org.apache.tomcat.util.threads包下实现了一个ThreadPoolExecutor,配套还有一个TaskQueue。
这背后的核心区别在于:Java内置线程池的默认行为是"优先排队,队列满了才扩线程",而Tomcat希望的是"优先扩线程,达到上限之后才排队"。
为什么会有这个差异?因为Web请求通常都是短任务,每个请求都需要尽快被处理,如果任务进到队列里等待,请求的响应时间就会多出一段不可控的排队延迟。Tomcat的做法是:只要还没达到maximumPoolSize,来了新请求就尽量创建新线程去处理;等到线程数顶到上限,才让请求进入队列排队。这样并发较低的时候不会发生排队,并发较高的时候用队列做缓冲。
这个思路和JVM默认策略看起来只是顺序反了,实际影响非常明显。手写Tomcat时如果直接使用默认ThreadPoolExecutor,你会观察到在并发爬升阶段,大量请求先堵塞在队列里,白白浪费时间。
4.2 TaskQueue重写offer的秘密:线程优先,排队兜底
Tomcat的TaskQueue继承自LinkedBlockingQueue,它重写了offer()方法,简化后的逻辑类似这样:
public class TaskQueue extends LinkedBlockingQueue<Runnable> { private ThreadPoolExecutor parent = null; public void setParent(ThreadPoolExecutor executor) { this.parent = executor; } @Override public boolean offer(Runnable runnable) { if (parent == null) { return super.offer(runnable); } // 当前线程池还没有到最大值时,不入队,而是返回false,让线程池创建新线程 if (parent.getPoolSize() < parent.getMaximumPoolSize()) { return false; } // 已经达到最大线程数,才真正把任务放进队列 return super.offer(runnable); } }理解这个方法的重点在ThreadPoolExecutor的execute逻辑:当核心线程满了之后,它会尝试workQueue.offer(),如果offer返回false,就继续尝试创建非核心线程,直到线程数达到maximumPoolSize。所以TaskQueue.offer(false)是在主动告诉线程池:"我不排队,你先给我开新线程。"
在我们的手写Tomcat里完全可以复刻这个思路:自己继承LinkedBlockingQueue,在offer里判断当前线程池大小,模仿Tomcat的线程优先策略。这样既不需要引入Tomcat依赖,又能获得同样的行为。
需要提醒的是,这个"无界队列配合线程优先"的方案,并不意味着系统可以接收无限任务。Tomcat在连接器层面还有acceptCount、maxConnections等参数来限制TCP层面的连接队列,防止无界堆积。手写版本如果把队列设成无界,就必须自己承担积压风险。我的建议是:模仿Tomcat线程优先的思路可以,但队列最好还是设一个明确的容量上限。
4.3 Tomcat配置参数与线程池概念的对应关系
Tomcat的conf/server.xml里,Connector标签上有一堆线程池相关的配置项。很多人只认识maxThreads,不知道其他参数到底映射到线程池的哪个概念。我整理了一个对照表:
| Tomcat配置 | 对应线程池概念 | 作用 |
|---|---|---|
| minSpareThreads | corePoolSize | 保证至少有多少个空闲线程等待工作 |
| maxThreads | maximumPoolSize | 最大工作线程数量 |
| maxIdleTime | keepAliveTime | 空闲线程超过该时间就会被回收 |
| acceptCount | 操作系统accept队列 | 与线程池无关,控制请求连接等待队列长度 |
这里的minSpareThreads是Tomcat默认10,maxThreads默认200。也就是说,Tomcat线程池的corePoolSize是10,maximumPoolSize是200,空闲回收时间由maxIdleTime控制。理解了这张表,你在调优Tomcat时就会更清楚自己是在动线程池的哪个环节。
手写版本对应地可以这么做:初始化线程池时,corePoolSize设为你的"最小空闲线程数",maximumPoolSize设为"最大线程数",keepAliveTime设为"空闲回收时间",这个映射关系直接移植到代码里。
5. 手写Tomcat引入线程池后必做的三件事
5.1 监控线程池状态,别等OOM才反应
线程池引入之后,最危险的情况不是线程池不够用,而是线程池在运行一段时候后陷入"假死":队列越积越长,活动线程长期打满,但代码没有任何报错。这种问题只能靠监控提前发现。
手写Tomcat阶段我不会上一整套监控系统,只需要在启动时额外起一个定时任务,定期打印线程池关键指标:
executor.execute(() -> { while (running.get()) { int poolSize = executor.getPoolSize(); int activeCount = executor.getActiveCount(); int queueSize = executor.getQueue().size(); long completedCount = executor.getCompletedTaskCount(); // 简单日志输出 System.out.printf( "[tomcat-pool] pool=%d active=%d queue=%d completed=%d%n", poolSize, activeCount, queueSize, completedCount); Thread.sleep(10000); } });重点关注三个指标:activeCount是否长期接近maximumPoolSize,queueSize是否持续增长,completedCount是否长时间不增加。如果三者同时出现,基本可以确定业务处理堵住了。
给线程命名这件事也是从这一步体现出价值的。用jstack查看线程栈时,如果所有线程都叫pool-1-thread-1,很难判断是Tomcat的线程还是业务线程。我习惯在ThreadFactory里加上业务前缀,比如http-bio-,这样排查线程池相关问题时能直接过滤。
5.2 优雅停机:shutdown和shutdownNow怎么选
手写Tomcat如果只关注启动和运行,很容易忽略停机。但kill -9杀进程会造成正在处理的请求突然中断,这在真实环境里是不接受的。
正确的关闭顺序应该是:
- 把
running标志置为false,让Acceptor循环退出,停止接收新连接。 - 关闭
ServerSocket,让阻塞在accept()上的线程立即返回。 - 调用线程池的
shutdown(),不再接收新任务,但允许已提交任务执行完。 - 调用
awaitTermination(timeout, unit)等待一段合理时间,如果任务还没执行完,再调用shutdownNow()强制中断。
代码大致是:
public void shutdown() throws IOException { running.set(false); serverSocket.close(); executor.shutdown(); if (!executor.awaitTermination(30, TimeUnit.SECONDS)) { executor.shutdownNow(); } }一个容易踩的坑是:shutdown()不是立即停止线程池,而是"不再接收新任务"。如果你还继续往里提交任务,会收到RejectedExecutionException。所以停机逻辑里running标志的检查必须在提交任务之前,否则Acceptor刚跳出循环,别的线程又往池里塞任务,关不干净。
另外,shutdownNow()会中断正在执行的任务,但线程池里的线程收到中断后,如果代码没有响应中断,靠Thread.interrupt()是停不下来的。所以业务任务里需要适当检查线程中断标志,或者依赖Socket.setSoTimeout来打破长时间阻塞的读取操作。
5.3 线程池大小该设多少:从实测数据聊起
关于线程池大小,最常见的讨论是"CPU核心数+1"还是"CPU核心数*2"。这个经验公式在纯计算场景下有一定参考价值,但Web容器基本是IO密集型:线程大部分时间阻塞在Socket读写、数据库查询、远程调用上,真正的CPU计算占比反而不高。
Tomcat默认把maxThreads设成200是有道理的:大多数Web请求的瓶颈在IO等待,200个线程可以让同一时刻有200个请求并发推进,而不会把CPU直接打满。手写Tomcat起步阶段,我会直接把corePoolSize设成10,maximumPoolSize设成200,队列容量设为1000左右,先跑起来再根据压测调整。
具体调参建议这样操作:用wrk或JMeter压到目标并发,观察前面说的监控指标。如果activeCount打到200但queueSize还在涨,说明线程数不够,可以逐步加到300、400,直到请求延迟稳定。如果线程没到最大,但CPU已经接近100%,说明业务代码本身太重,加线程只会加剧上下文切换,正确做法是优化业务或增加机器。这里没有万能公式,只有压测数据最可靠。
至于队列大小的经验值,我建议不要设置太小。太小会让突发的短请求直接触发拒绝策略,太大又会导致拥堵时延迟过高。一个相对安全的起步值是maxThreads * 5,压测后根据实际延迟要求再上下调整。注意,这只是一个基于常见实践的参考值,不是标准答案。
对我自己来说,从每请求一线程改成线程池之后,最直观的变化是:同样用300并发压测,原来线程数会冲到300以上且CPU飙到90%,改为固定线程池后线程数稳定在80左右,CPU降到50%上下,响应时间的尾部延迟也短了很多。之后我再也没有回到过"来一个请求就new一个线程"的写法。线程池不是银弹,它只是让手写容器从"能跑"变成"可控"的第一步,但这一步值得反复咀嚼。