目标:你能把 Tomcat 的请求链路、线程模型、关键参数、常见故障串成一套可解释、可排障、可背诵的完整体系。
1. 先建立一张"主线地图"
一次 HTTP 请求进入 Tomcat,大体走这条链路:
- Connector:对外提供协议入口(HTTP/1.1、AJP 等)
- Endpoint:网络模型与 I/O 事件循环(NIO/NIO2/APR)
- Processor:协议解析(把字节流解析成 request/response 语义)
- Adapter(CoyoteAdapter):把 Coyote Request 适配成 Catalina 的 Request,进入容器
- Container(Engine/Host/Context/Wrapper):定位到目标 Servlet,走 Filter/Servlet
你把这条链路讲清楚,面试和排障基本都能兜住。
2. Connector:协议入口 + 线程模型的外壳
Connector 不只是"端口监听"。它管理:
- 监听端口与协议(
org.apache.coyote.http11.Http11NioProtocol等) - Endpoint(网络 I/O 与 acceptor/poller)
- Processor(协议解析器)
面试重点:
- Connector 是网络层与容器层的桥梁
- 网络事件由 Endpoint 驱动
- 协议解析由 Processor 完成
3. Endpoint:NIO 模型里最关键的三类线程
以 NIO 为例,你通常会看到:
- Acceptor :
accept()新连接 - Poller :
select()监听可读/可写事件 - Executor/Worker(业务线程池):真正执行请求处理(容器链路)
直觉:
- Poller 负责"发现有哪些 socket 可读"
- Worker 负责"读数据、解析协议、执行业务、写回响应"(实际实现可能分配在不同阶段,但从排障看这样理解最稳)
4. Processor:把字节流变成 HTTP 语义
Processor 主要做:
- 解析请求行/请求头(method、uri、headers)
- 处理 keep-alive、chunked
- 生成内部的 Request/Response 对象(Coyote 层)
常见现象与原因:
- 请求头过大/行过长:触发 400/连接被关闭(看
maxHttpHeaderSize等限制) - keep-alive 连接堆积:连接多但 QPS 不高(关注 keepAliveTimeout、maxKeepAliveRequests)
5. Adapter:Coyote -> Catalina 的关键"转接"
当 Processor 解析完协议,会通过 CoyoteAdapter 进入容器:
- 构造/复用 Catalina Request/Response
- 调用容器 pipeline(Engine->Host->Context->Wrapper)
从排障角度:
- 如果你看到 Tomcat 线程栈卡在容器 pipeline/Filter/Servlet,就说明"网络层已完成",瓶颈在应用层
6. 线程模型与容量模型:三段式理解
对典型 NIO Connector,可以用"3 段"理解吞吐上限:
- 连接建立:accept backlog(排队等 accept)
- 请求处理排队:worker 线程池(执行 servlet)
- 下游瓶颈:DB/缓存/HTTP 调用(决定平均处理时长)
核心公式(直觉版):
吞吐 ≈ 线程数 / 平均处理时长
平均处理时长由应用与下游决定,不是靠 Tomcat 魔法消失。
7. 关键参数:maxThreads、acceptCount、connectionTimeout、keepAlive
7.1 maxThreads:不是越大越好
maxThreads:业务线程池上限(请求真正执行的线程)
风险:
- 太小:线程池满 -> 排队变长 -> RT 上升
- 太大:上下文切换、GC 压力、下游被打爆 -> 反而更慢
建议:
- 先测出你的"单机可用 CPU"与"下游承载"
- 把
maxThreads定在"让 CPU 接近但不打满、且下游不崩"的区间
7.2 acceptCount:队列满了会怎样
acceptCount:当所有处理线程忙时,新连接/新请求的等待队列长度(不同版本/实现细节略有差异,但排队含义成立)
现象:
- 队列满:客户端看到连接失败/超时/502/503(取决于前置网关与客户端)
对照组:
- 错:只调大
acceptCount,让请求在 Tomcat 堆很久(RT 爆炸) - 对:把排队控制在合理范围,配合上游限流/快速失败
7.3 connectionTimeout:别把"慢"变成"永远不释放"
connectionTimeout:读取请求行/首包等待超时(常被误用)
典型坑:
- 设太大:慢客户端/攻击连接占用资源(slowloris 类问题)
- 设太小:弱网/大请求容易误杀
7.4 keepAliveTimeout / maxKeepAliveRequests:连接多但 QPS 不高的根因
keep-alive 的收益:
- 复用连接,减少握手成本
但也会带来:
- 大量空闲连接占用 fd 与内存
建议:
- 连接多但 QPS 低:重点看 keepAliveTimeout 是否过大
- 和上游(Nginx/网关)的 keep-alive 策略对齐
8. 可复现实验:用线程栈看你卡在网络层还是业务层
实验 A:业务慢(线程栈卡在你的 Controller/DAO)
- 现象:RT 高、Tomcat
http-nio-...-exec-*线程大量 RUNNABLE/BLOCKED - 线程栈:在业务方法/锁/DB 调用处
实验 B:连接堆积(线程栈更多在 Poller/Socket read)
- 现象:连接数高但 QPS 不高
- 线程栈:大量线程在 socket read/select(不同版本栈细节不同)
9. 对照组:线程数调大就一定能抗更多并发吗?
- 错直觉:
maxThreads越大越好 - 正确理解:
- 线程太多会增加上下文切换
- 如果瓶颈在 DB/下游,线程越多只会把压力放大并堆积
正确做法:
- 用压测找到 CPU/GC/DB 的瓶颈
- 让 Tomcat 线程池大小与下游能力匹配
10. 连接器:NIO vs NIO2 vs APR 的区别
10.1 差异集中在 Endpoint 层
Connector 结构里:
- 协议(HTTP/1.1)与容器链路基本一致
- 差异主要体现在 Endpoint 的 I/O 模型
你可以把它理解为:
- NIO/NIO2/APR 都是在"怎么收发字节"上不同
- 上层 Processor/Adapter/Container 的主线不变
10.2 NIO:Selector 驱动,成熟且默认
特征:
- 使用 selector 监听读写事件
- acceptor + poller + worker 这套模型清晰
适用:
- 绝大多数场景默认就够用
常见坑:
- 误以为 NIO 就不会阻塞:业务线程依旧可能阻塞在 DB/下游
- 盲目提高线程导致上下文切换
10.3 NIO2:异步 IO,但不等于"必然更快"
特征:
- 基于 NIO.2 的异步通道
- 更偏"回调/异步完成"模型
注意点:
- 业务处理仍然需要线程(最终还是要执行 servlet)
- 是否更快取决于具体负载、JDK 实现、线程配置
实战建议:
- 如果没有明确证据与收益预期,优先使用 NIO
10.4 APR:本地库 + OpenSSL,收益与成本并存
可能收益:
- TLS/加密相关性能更好(尤其是某些版本/配置下)
成本与风险:
- 需要安装 Tomcat Native
- 环境差异更大(Windows/Linux、lib 版本、权限)
- 出问题更难排查(native 层)
适用:
- 对 TLS 性能/特性有明确诉求,且有能力维护本地依赖
10.5 对照组:你应该怎么选
- 追求"稳定与可维护":
- 选 NIO(默认)
- 有明确 async io 收益评估:
- 再尝试 NIO2
- 有明确 TLS 诉求且能维护 native:
- 考虑 APR
11. 类加载机制与热部署:为什么会内存泄漏
11.1 Tomcat 的类加载是"隔离 + 可卸载"的设计
Tomcat 需要同时运行多个 web 应用(多个 war),因此需要:
- 应用之间类隔离
- 应用 reload/undeploy 后尽量能卸载 class 与相关资源
核心实现:
- 每个 Web 应用有自己的 WebAppClassLoader(或相关实现)
11.2 类加载层级(文字版)
常见层级(不同版本细节略有差异,但逻辑一致):
- Bootstrap / Platform / System(JDK)
- Common(Tomcat 公共库)
- Catalina(容器)
- WebAppClassLoader(每个应用一份)
关键点:
- Web 应用的 class 通常优先从自己的 classpath(
WEB-INF/classes、WEB-INF/lib)加载 - 这样同名类不会互相污染
11.3 热部署/重载是怎么工作的(直觉版)
当你 reload/重新部署:
- Tomcat 创建新的 WebAppClassLoader
- 新请求走新 classloader
- 旧 classloader 如果没有任何引用,才能被 GC 回收
所以"能不能卸载",取决于:
- 有没有 从容器/全局/线程 指向旧 classloader 的强引用
11.4 典型泄漏根因:线程与静态引用最常见
11.4.1 线程泄漏
- 你创建了线程池/定时任务,但应用 stop 时没有 shutdown
- 线程的 contextClassLoader 指向旧应用 classloader
- 导致旧 classloader 被引用,无法回收
11.4.2 静态缓存/单例
- static Map 缓存类/反射对象/driver
- 或第三方库在静态变量里持有 class/资源
11.4.3 JDBC Driver / ThreadLocal
- DriverManager 注册的 driver
- ThreadLocal 没清理,持有业务对象
11.5 可复现示例:一个忘记 shutdown 的定时任务
java
// 伪代码:应用启动时创建定时任务
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
// do something
}, 0, 1, TimeUnit.SECONDS);
如果 stop 时不 shutdown:
- 线程还在跑
- 引用仍在
- reload 越多,泄漏越大
11.6 对照组:正确的资源生命周期管理
- 错:在静态块/启动时创建线程池,但不释放
- 对:
- 使用容器管理的线程池(JNDI/ManagedExecutorService)或
- 在应用关闭时 shutdown(Spring 用
@PreDestroy/DisposableBean)
12. 线上排障 checklist(从现象到定位)
12.1 先定性:是"业务慢"还是"连接/协议层问题"
- 业务慢:线程栈在业务代码/DB
- 协议层:关注 header 限制、keep-alive、连接堆积
12.2 看指标
- RT/QPS/错误码
- 线程池是否打满(活跃线程 vs maxThreads)
- 连接数与 fd
12.3 抓线程栈
- 取 Top 5 栈,判断卡点(业务/锁/DB/网络)
12.4 决定动作
- 业务慢:SQL/锁/下游
- 连接堆积:超时、限流、keepAlive 参数
- 内存泄漏:多次 redeploy 后内存持续上升,抓 heap dump 看 WebAppClassLoader
13. 面试背诵稿(60 秒)
Tomcat 一次请求的主线是:Connector 对外提供协议入口,内部由 Endpoint 负责 accept/select 等 I/O 事件驱动;当 socket 可读时交给 Processor 做 HTTP 协议解析,把字节流解析成请求语义;随后通过 CoyoteAdapter 把 Coyote Request 适配到 Catalina 容器,进入 Engine/Host/Context/Wrapper,最终执行 Filter/Servlet。
调优上我会用容量模型解释:吞吐近似等于线程数除以平均处理时长,所以 maxThreads 不是越大越好,太大只会增加上下文切换并把压力打到 DB;acceptCount 是线程池满时的排队长度,队列太大等于把请求在 Tomcat 里堆着导致 RT 爆炸,应该配合上游限流与快速失败。connectionTimeout 和 keepAliveTimeout 影响连接占用与慢连接风险,需要和网关的 keep-alive 策略对齐。
热部署时 Tomcat 为每个 Web 应用创建独立的 WebAppClassLoader,reload 会创建新 classloader,旧的是否能回收取决于是否还有强引用;最常见的泄漏来源是应用自己创建的线程池/定时任务没有在 stop 时 shutdown,线程会持有旧 classloader;其次是 ThreadLocal、JDBC driver 注册、以及 static 缓存持有 class/资源。排障时我会先看线程池是否打满,再抓线程栈定位到底卡在 DB、锁还是外部调用,然后再决定是扩容/限流还是调整参数。