news 2026/9/30 11:52:50

手写Tomcat线程池改造:从每请求一线程到高并发可控

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写Tomcat线程池改造:从每请求一线程到高并发可控

手写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()的流程是标准而且固定的,我建议直接把它背下来:

  1. 如果当前工作线程数小于corePoolSize,创建新核心线程执行任务。
  2. 如果当前线程数已经大于等于corePoolSize,尝试把任务放入workQueue队列。
  3. 如果队列已经满了,再尝试创建新线程,直到线程数达到maximumPoolSize。
  4. 如果线程数已经等于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配置对应线程池概念作用
minSpareThreadscorePoolSize保证至少有多少个空闲线程等待工作
maxThreadsmaximumPoolSize最大工作线程数量
maxIdleTimekeepAliveTime空闲线程超过该时间就会被回收
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杀进程会造成正在处理的请求突然中断,这在真实环境里是不接受的。

正确的关闭顺序应该是:

  1. 把running标志置为false,让Acceptor循环退出,停止接收新连接。
  2. 关闭ServerSocket,让阻塞在accept()上的线程立即返回。
  3. 调用线程池的shutdown(),不再接收新任务,但允许已提交任务执行完。
  4. 调用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一个线程"的写法。线程池不是银弹,它只是让手写容器从"能跑"变成"可控"的第一步,但这一步值得反复咀嚼。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 11:52:48

基于PHP的微信AI智能客服系统:从部署到二次开发全攻略

做微信客服系统这块快六年&#xff0c;见过太多“能跑就行”的代码&#xff0c;这次整理仓库翻出一套PHP原创的微信AI智能客服系统源码&#xff0c;结构清爽&#xff0c;扩展点留得清楚&#xff0c;适合做二次开发的伙伴直接拿去改。它能解决的问题很具体&#xff1a;让微信公众…

作者头像 李华
网站建设 2026/9/30 11:52:47

SpringBoot家教兼职管理系统:预约状态机、时间冲突检测与数据库设计

写这篇东西之前&#xff0c;我先说实话。市面上打着“源码文档视频”旗号的Java毕设项目一抓一大把&#xff0c;但真正能让你从零跑起来、写进论文里、答辩时讲清楚的&#xff0c;其实不多。大学生家教兼职管理系统这个题目&#xff0c;属于典型的“看起来简单、做起来琐碎”的…

作者头像 李华
网站建设 2026/9/30 11:51:43

彻底理解 Bash Shell:从核心概念到自动化实战

1. 先搞清楚 shell 和 bash 的区别与联系 如果你跟着我的学习路线一路看到这里&#xff0c;应该已经接触过不少命令了。这篇是系列笔记里的第九篇&#xff0c;主题是彻底理解 bash shell。我见过太多人把终端、Shell、bash、命令行这几种说法混在一起用&#xff0c;聊天时讲&qu…

作者头像 李华
网站建设 2026/9/30 11:50:49

操作系统408复习:进程/内存/文件/I/O计算链路

操作系统这门课&#xff0c;我在不同阶段完整过了三遍&#xff1a;大二为了期末、大三为了408、后来读研又陪着学弟学妹复盘了一遍。三次下来最深的体会是&#xff0c;它根本不是一门"背多分"的课。真正拉开差距的&#xff0c;是你能不能把进程调度、地址翻译、页面置…

作者头像 李华
网站建设 2026/9/30 11:50:39

Linux下安装Redis全攻略:环境准备、编译配置与生产实践

有时候运维工作拼的不是手速&#xff0c;而是规划。以我这些年帮客户搭建缓存服务的经验来看&#xff0c;安装 Redis 本身花的力气只占三成&#xff0c;剩下七成都花在版本选择、环境确认、参数预判这些“看不见的准备工作”上。很多新手上来就下载源码包、解压、make&#xff…

作者头像 李华
网站建设 2026/9/30 11:46:50

OpenClaw接入硅基流动API:Ubuntu服务器部署AI代理全攻略

最近折腾个人AI助手&#xff0c;把OpenClaw接到了硅基流动API上&#xff0c;整体跑通了&#xff0c;顺手记录一下部署和集成的全过程。如果你也准备在Ubuntu服务器上搞一套自己的AI代理&#xff0c;或者正在纠结怎么把大模型API接进现有工具链&#xff0c;这篇内容应该能帮你省…

作者头像 李华