你每天都在用Tomcat------Spring Boot内嵌的就是它。但你真的了解Tomcat是怎么工作的吗?
当一个HTTP请求打到Tomcat,从Socket连接建立,到最终你的Servlet代码执行,中间到底经历了什么?Connector和Container这两个名字你肯定见过,但它们之间是怎么配合的?Engine、Host、Context、Wrapper这一层层的容器到底在做什么?这些问题的答案,决定了你做性能调优时是真懂还是瞎调。
一、Tomcat整体架构概览
先上一张Tomcat的整体架构脑图:
Server(Tomcat实例,全局唯一)
└── Service(对外提供完整服务,可多个)
├── Connector(连接器,负责接收请求,可多个)
│ ├── HTTP/1.1 Connector(默认8.0后NIO)
│ └── AJP Connector(与Web服务器集成,如nginx→tomcat)
└── Engine(容器顶层,处理Connector交过来的请求)
└── Host(虚拟主机,如localhost)
└── Context(一个Web应用)
└── Wrapper(一个Servlet的包装器)
这个结构的核心设计思想就一句话:连接和处理的分离。
Connector只管"接收请求、解析协议、生成Request对象",它不懂业务。Container只管"按照容器层级逐层路由、执行过滤器链、最终调用Servlet",它不管请求是从哪个端口来的。
这种分离带来极大的灵活性。你可以配多个Connector------一个HTTP端口对外服务,一个AJP端口和Nginx对接,它们共享同一个Engine,请求最终都走到同一套Container处理逻辑里。
来看server.xml中最精简的配置结构:
java
<Server port="8005" shutdown="SHUTDOWN">
<Service name="Catalina">
<!-- Connector:负责接客 -->
<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443" />
<!-- Engine:负责分活 -->
<Engine name="Catalina" defaultHost="localhost">
<Host name="localhost" appBase="webapps"
unpackWARs="true" autoDeploy="true">
<!-- Context通常不显式配置,由autoDeploy自动发现 -->
</Host>
</Engine>
</Service>
</Server>
一个Server包着一个Service(通常就一个),Service里面左边挂Connector,右边挂Engine,Engine往下是Host→Context→Wrapper逐层嵌套。这就是Tomcat的骨架。
老梁说:很多人搞不清什么时候该配多个Service。答案是------当你需要完全隔离两组连接器和容器时。比如你有一个对外的Web服务和一个内部管理接口,希望它们用不同的线程池、不同的超时策略,那就拆成两个Service。但99%的场景,一个Service就够了,别给自己加复杂度。
二、Connector组件详解
Connector是Tomcat的"门面",它负责网络通信和协议解析。Connector内部有三层核心结构:Endpoint → Processor → Adapter。
2.1 Endpoint:管Socket的那一层
Endpoint是真正的网络I/O层,它负责Socket的接收、读写和发送。Endpoint有两种实现:
- NioEndpoint:基于Java NIO,从Tomcat 8.0开始成为默认
- AprEndpoint:基于APR(Apache Portable Runtime),使用JNI调用本地库
NioEndpoint内部有几个关键角色,我用代码来说明:
java
// 简化后的NioEndpoint核心结构
public class NioEndpoint extends AbstractJsseEndpoint<NioChannel,SocketChannel> {
// ① Acceptor线程:专门接收新连接
private Acceptor<NioChannel> acceptor;
// ② Poller线程:轮询已建立连接的IO事件
private Poller[] pollers;
private int pollerThreadCount = Math.min(2, Runtime.getRuntime().availableProcessors());
// ③ Worker线程池:实际处理请求的业务线程
private Executor executor; // 默认使用StandardThreadExecutor
}
这三个角色的协作方式是理解Tomcat并发模型的关键,后面讲请求旅程时会详细展开。
2.2 Processor:管协议解析的那一层
Processor负责将Endpoint拿到的字节流按照特定协议解析成Tomcat内部的Request对象。主要的Processor实现:
- Http11Processor:解析HTTP/1.1协议
- Http11NioProcessor (旧版)/ 现在统一为Http11Processor配合NioEndpoint
- AjpProcessor:解析AJP协议
Processor解析完协议后,会生成一个CoyoteRequest对象(注意这不是最终的ServletRequest,后面Adapter会转换)。
2.3 Adapter:Connector和Container之间的桥梁
Adapter是Connector和Container之间唯一的粘合层。它的核心职责是:把CoyoteRequest/CoyoteResponse转换成ServletRequest/ServletResponse,然后交给Container去处理。
java
// CoyoteAdapter的核心方法
public void service(org.apache.coyote.Request req,
org.apache.coyote.Response res) {
// 1. 将CoyoteRequest转换为HttpServletRequest
Request request = connector.createRequest();
request.setCoyoteRequest(req);
// 2. 将CoyoteResponse转换为HttpServletResponse
Response response = connector.createResponse();
response.setCoyoteResponse(res);
// 3. 关联request和response
request.setResponse(response);
// 4. 关键!交给Engine处理 ------ 从这里开始进入Container的世界
connector.getService().getContainer().getPipeline()
.getFirst().invoke(request, response);
}
注意第4步,这行代码就是Connector把请求"交接"给Container的关键点。getPipeline().getFirst().invoke()触发的是Pipeline-Valve责任链的第一个Valve,从此请求在Container层逐层流转。
2.4 BIO/NIO/APR三种IO模型的演进
Tomcat的IO模型经历了三个阶段,这是面试高频考点,但很多人只停留在"背答案"阶段:
| 模型 | 版本支持 | 原理 | 现状 |
|---|---|---|---|
| BIO | Tomcat 7前默认 | 一个连接占一个线程,阻塞式IO | Tomcat 9已移除 |
| NIO | Tomcat 8后默认 | 基于Selector多路复用,Acceptor+Poller+线程池 | 主流方案 |
| APR | 需安装本地库 | JNI调用APR,性能接近Native | 大并发场景使用 |
BIO为什么被淘汰?核心原因是线程浪费 。BIO模式下,每个TCP连接都会占住一个工作线程,即使这个连接没有数据传输,线程也被阻塞在InputStream.read()上。1000个连接就要1000个线程,线程切换开销爆炸。
NIO用Selector多路复用解决了这个问题。Acceptor线程只管接收新连接,然后把Socket注册到Selector上;Poller线程轮询Selector,发现哪个Socket有数据可读,就把任务丢给工作线程池去处理。工作线程只在真正有数据可处理时才介入,大幅减少了空闲等待。
来看NioEndpoint中Poller的核心逻辑:
java
// 简化后的Poller.run()方法
public void run() {
while (true) {
// 阻塞等待selector事件
Iterator<SelectionKey> iterator =
selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
NioSocketWrapper socketWrapper =
(NioSocketWrapper) key.attachment();
// 有读事件就绪,交给工作线程池处理
if (key.isReadable()) {
// 关键:把SocketWrapper丢给线程池
getExecutor().execute(
new SocketProcessor(socketWrapper));
}
}
}
}
这段代码揭示了NIO的精髓:Poller不处理业务,只负责"发现活",真正干活交给Worker线程池。
BIO在Tomcat 9里已经彻底删掉了。但NIO和APR的选型,别盲目追APR。APR需要安装本地库(Linux上装apr和tomcat-native),部署复杂度上去了,且JVM层面的性能差距在现代JDK下已经很小。除非你是超高并发场景(比如单机上万QPS),否则NIO完全够用,部署简单,排查问题也方便。我见过好几个团队折腾APR配错了反而性能下降的,得不偿失。
三、Container组件详解
Container是Tomcat的"发动机",它负责处理Connector交过来的请求。Container有四层,从外到内依次是:Engine → Host → Context → Wrapper。
3.1 四层容器的职责
| 容器 | 职责 | 例子 |
|---|---|---|
| Engine | 全局唯一,处理所有Connector交来的请求 | Catalina引擎 |
| Host | 虚拟主机,根据Host头路由 | localhost、www.example.com |
| Context | 一个Web应用 | /order-service |
| Wrapper | 一个Servlet的包装器 | OrderServlet |
这个设计是典型的组合模式 ------每个容器都实现了Container接口,可以包含子容器。Engine包含多个Host,Host包含多个Context,Context包含多个Wrapper。
3.2 Pipeline-Valve责任链模式
每层Container内部都有一个Pipeline(管道),管道上挂着一串Valve(阀门)。请求到达某一层容器时,会先走过该层Pipeline上的Valve链,然后路由到下一层容器。
每个容器有一个Basic Valve (基础阀门),它是Pipeline链的最后一环,负责将请求路由到下一层容器。比如Engine的Basic Valve是StandardEngineValve,它的工作就是根据请求的Host头找到对应的Host,然后把请求丢过去。
我用一张文字流程图来展示完整的Pipeline-Valve链:

每一层的Basic Valve都干一件事:找到下一层该处理这个请求的子容器,然后把请求传过去。这就形成了从外到内逐层路由的效果。
Pipeline-Valve这个设计精髓在于可插拔。你可以在任意一层加自己的Valve,拦截请求做自定义处理。后面我会带你做一个自定义Valve的实验。但注意------Valve和Filter不是一回事。Valve是Tomcat层面的,作用在容器级别;Filter是Servlet规范层面的,作用在Wrapper内部。Valve比Filter更"靠前",连FilterChain都还没构建时就执行了。
四、一个HTTP请求在Tomcat中的完整旅程
理论讲完了,现在带你完整追踪一个HTTP请求从进入Tomcat到Servlet执行的全过程。这是这篇文章最核心的部分,请仔细看。
假设一个请求:GET http://localhost:8080/myapp/hello?name=laoliang
第一步:Acceptor接收TCP连接
Tomcat启动时,NioEndpoint会启动一个Acceptor线程(默认1个),它的工作就是一个死循环:不断调用ServerSocketChannel.accept()接收新的TCP连接。
java
// Acceptor的核心循环(简化版)
protected void run() {
while (!isStopped()) {
// 接收新连接
SocketChannel socketChannel = serverSock.accept();
// 设置为非阻塞模式
socketChannel.configureBlocking(false);
// 注册到Poller的Selector上
poller.register(socketChannel);
}
}
Acceptor不管请求内容,它只负责"开门迎客"。连接建立后,立刻丢给Poller。
第二步:Poller轮询IO事件
Poller线程内部有一个Selector。Acceptor把SocketChannel注册到Poller的Selector上,关注OP_READ事件(有数据可读)。
java
// Poller.register() ------ 将新连接注册到Selector
public void register(SocketChannel socketChannel) {
NioSocketWrapper socketWrapper = new NioSocketWrapper(socketChannel);
// 注册OP_READ事件
socketChannel.register(selector, SelectionKey.OP_READ, socketWrapper);
}
Poller线程在另一个死循环中轮询Selector:
java
// Poller核心循环(简化版)
public void run() {
while (true) {
selector.select(1000); // 阻塞最多1秒
Iterator<SelectionKey> keys = selector.selectedKeys().iterator();
while (keys.hasNext()) {
SelectionKey key = keys.next();
if (key.isReadable()) {
NioSocketWrapper socketWrapper =
(NioSocketWrapper) key.attachment();
// 取消读事件关注,避免重复触发
key.cancel();
// 关键:把任务提交到工作线程池
getExecutor().execute(
new SocketProcessor(socketWrapper));
}
}
}
}
Poller不读数据,不解析协议,只负责"发现哪些连接有数据了,然后丢给线程池"。这是NIO的核心优势------一个Poller线程可以管理上千个连接。
第三步:Worker线程池处理(SocketProcessor)
工作线程池拿到SocketProcessor后,开始真正处理请求:
java
// SocketProcessor核心逻辑(简化)
protected void doRun() {
// 1. 从Socket读取数据
NioChannel channel = socketWrapper.getSocket();
// 2. 交给Processor解析HTTP协议
processor.process(channel); // → Http11Processor
// Processor内部会调用Adapter
// 3. CoyoteAdapter.service() 把请求交给Container
adapter.service(coyoteRequest, coyoteResponse);
}
第四步:CoyoteAdapter转换并交给Container
java
// CoyoteAdapter.service() (关键部分)
public void service(org.apache.coyote.Request req,
org.apache.coyote.Response res) {
Request request = connector.createRequest();
request.setCoyoteRequest(req);
Response response = connector.createResponse();
response.setCoyoteResponse(res);
// 关键!从Connector跨越到Container的这行代码
connector.getService().getContainer()
.getPipeline().getFirst().invoke(request, response);
}
从这行代码开始,请求正式进入Container的世界。
第五步:Engine的Pipeline-Valve链
Engine的Pipeline上,第一个Valve被调用。假设你没加自定义Valve,那第一个就是Basic Valve------StandardEngineValve:
java
// StandardEngineValve.invoke()
public void invoke(Request request, Response response) {
Host host = (Host) request.getContext().getHost();
host.getPipeline().getFirst().invoke(request, response);
}
它根据请求的Host头(localhost:8080中的localhost部分)找到对应的Host容器,把请求传过去。
第六步:Host的Pipeline-Valve链
java
// StandardHostValve.invoke()
public void invoke(Request request, Response response) {
Context context = request.getContext();
context.getPipeline().getFirst().invoke(request, response);
}
根据URL路径/myapp/hello,找到myapp对应的Context容器。
第七步:Context的Pipeline-Valve链
java
// StandardContextValve.invoke()
public void invoke(Request request, Response response) {
Wrapper wrapper = request.getWrapper();
wrapper.getPipeline().getFirst().invoke(request, response);
}
根据Servlet映射规则(/hello映射到HelloServlet),找到对应的Wrapper容器。
第八步:Wrapper的Pipeline-Valve链
Wrapper的Pipeline比较特殊,它的Basic Valve是StandardWrapperValve,这里会做两件关键的事:
java
// StandardWrapperValve.invoke()(核心逻辑)
public void invoke(Request request, Response response) {
// 1. 加载Servlet实例(首次请求会实例化)
Servlet servlet = wrapper.allocate();
// 2. 构造FilterChain(这是Servlet规范中的Filter链)
ApplicationFilterChain filterChain =
new ApplicationFilterChain(request, response);
filterChain.addFilter(/* 从web.xml中匹配的Filter */);
// 3. 执行Filter链
filterChain.doFilter(request, response);
// Filter链的末尾会调用Servlet
// → servlet.service(request, response);
// → doGet() 或 doPost() ...
}
到这里,请求终于到达了你写的HelloServlet.doGet()方法。
完整旅程图
用一张图总结整个请求流转:
理解这个旅程图有个实战价值------排查请求耗时问题。如果请求很慢,你得知道耗时在哪一段。是在Acceptor排队(说明连接积压,可能acceptCount太小)、Poller没及时处理(说明Poller线程数太少)、Worker线程池打满(说明maxThreads不够)、还是Servlet里业务逻辑慢?每一段的瓶颈,对应不同的调优手段。盲目调maxThreads解决不了所有问题。
五、关键配置解析
5.1 Connector核心配置

逐个解释:
| 参数 | 含义 | 默认值 | 调优建议 |
|---|---|---|---|
maxConnections |
最大连接数(同时保持的TCP连接数) | NIO:10000 | 根据内存评估 |
acceptCount |
等待队列长度(连接数超过maxConnections后排队) | 100 | 和maxConnections配合 |
maxThreads |
工作线程池最大线程数 | 200 | 根据CPU密集/IO密集定 |
minSpareThreads |
最小空闲线程数(预热) | 10 | 低峰期保命 |
connectionTimeout |
连接建立后的读超时 | 20000ms | 慢客户端调小 |
keepAliveTimeout |
Keep-Alive空闲超时 | connectionTimeout | 影响连接复用 |
maxKeepAliveRequests |
一个Keep-Alive连接最多处理请求数 | 100 | 防止连接泄漏 |
maxConnections vs acceptCount vs maxThreads 是最容易搞混的三个参数,我画个图说清楚:

简单说:
maxConnections管的是Poller能照看多少连接acceptCount管的是满员后还能在门外排多少maxThreads管的是同时有多少线程在干活
老梁说:这三个参数的配合是调优的核心。我见过最典型的坑是------线上突发流量,maxConnections和acceptCount都很大(看起来"容错率高"),但maxThreads只有200。结果就是大量请求在Worker线程池前排长队,connectionTimeout超时后客户端已经断开了,Tomcat还在傻等处理,白白浪费线程。正确思路是:maxThreads根据你的应用类型来定(CPU密集型不超过CPU核数×2,IO密集型可以200-500),acceptCount不要太大(等于maxThreads就够了,大了只是延迟报错而已),maxConnections根据内存和业务峰值来定。
5.2 Host核心配置

关键属性说明:
appBase:Web应用部署目录,Tomcat会自动扫描这个目录下的WAR包unpackWARs:是否自动解压WAR包到磁盘(生产建议true,性能更好)autoDeploy:运行时自动部署新应用(生产环境建议关闭,防止误操作)reloadable:是否监控class文件变化自动重载(生产环境必须关闭,性能杀手)
reloadable="true"在开发时很好用,改了代码Tomcat自动重载。但在生产环境就是个坑------它后台会起线程定期扫描WEB-INF/classes目录的class文件变化,一旦有变化就重新加载整个Context。这个扫描本身就是性能开销,而且热重载容易导致内存泄漏(老ClassLoader卸载不干净)。生产环境请务必设为false。如果需要更新应用,走正规的重新部署流程。
六、实验:自定义一个Valve并加入Pipeline
理论理解了,来做个实验。我们写一个自定义Valve,记录每个请求的处理耗时,并把它加入Engine的Pipeline。
第一步:实现Valve接口
java
package com.laoliang.tomcat.valve;
import org.apache.catalina.connector.Request;
import org.apache.catalina.connector.Response;
import org.apache.catalina.valves.ValveBase;
import org.apache.catalina.valves.ValveContext;
import javax.servlet.ServletException;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicLong;
/**
* 自定义Valve:记录请求处理耗时
* 挂在Engine层,所有请求都会经过
*/
public class TimingValve extends ValveBase {
// 超过这个毫秒数就打WARN日志
private int warnThreshold = 1000;
@Override
public void invoke(Request request, Response response,
ValveContext valveContext)
throws IOException, ServletException {
long startTime = System.currentTimeMillis();
// 关键:调用getNext().invoke(),让请求继续往下走
// 如果不调用,请求到此为止
valveContext.invokeNext(request, response);
long elapsed = System.currentTimeMillis() - startTime;
String uri = request.getRequestURI();
String method = request.getMethod();
if (elapsed > warnThreshold) {
System.out.println("[WARN] Slow request: "
+ method + " " + uri + " took " + elapsed + "ms");
} else {
System.out.println("[INFO] "
+ method + " " + uri + " - " + elapsed + "ms");
}
}
public void setWarnThreshold(int warnThreshold) {
this.warnThreshold = warnThreshold;
}
}
第二步:在server.xml中配置
java
<Engine name="Catalina" defaultHost="localhost">
<!-- 自定义Valve,放在Engine层 -->
<Valve className="com.laoliang.tomcat.valve.TimingValve"
warnThreshold="500" />
<Host name="localhost" appBase="webapps" ...>
...
</Host>
</Engine>
第三步:在Spring Boot中配置(内嵌Tomcat)
Spring Boot不能直接改server.xml,但可以通过编程方式注册Valve:
java
@Configuration
public class TomcatValveConfig {
@Bean
public TomcatServletWebServerFactory servletContainer() {
TomcatServletWebServerFactory factory =
new TomcatServletWebServerFactory();
factory.addContextCustomizers(context -> {
// 注册到Context层
context.addValve(new TimingValve());
});
// 如果要注册到Engine层,需要这样:
factory.addEngineValves(new TimingValve());
return factory;
}
}
第四步:运行效果
启动后访问任意接口,控制台输出:
[INFO] GET /api/users - 23ms
[INFO] POST /api/orders - 156ms
[WARN] Slow request: GET /api/reports/slow-query took 2300ms
这个Valve的好处是它比任何Filter都更"靠前"------在Servlet还没实例化之前就执行了,可以监控到所有请求,包括静态资源。
老梁说 :自定义Valve是个实用技巧。除了打耗时日志,我还用它做过:①请求体大小限制(在Filter层做的话,文件上传可能还没读完就限制了);②灰度路由------根据Header把请求路由到不同Context;③全链路追踪ID注入------在最早的地方注入traceId。记住Valve的核心调用是
valveContext.invokeNext(),漏掉这一行请求就断了,这是新手最常犯的错误。
七、Tomcat类加载机制简介
最后聊一个面试必考、但很多人理解不到位的话题------Tomcat的类加载机制。
7.1 为什么Tomcat要破坏双亲委派?
Java标准的双亲委派模型是:子ClassLoader先委托父ClassLoader加载,父加载不到才自己加载。但Tomcat有两个特殊需求:
- Web应用隔离:同一个Tomcat上部署了两个应用A和B,它们都依赖Spring 5和Spring 4的不同版本,必须做到类隔离
- WEB-INF/lib优先加载:Web应用自带的jar包要优先于Tomcat公共lib目录的同名jar包
这两个需求都要求破坏标准双亲委派。
7.2 Tomcat类加载器层次

7.3 WebappClassLoader的加载顺序
WebappClassLoader打破了双亲委派,它的加载顺序是:
1. 检查本地缓存 → 已加载过?
2. 检查JVM缓存
3. 加载Java核心类(java.*、javax.*)→ 委托给System ClassLoader
(这一步是为了安全,防止Web应用覆盖核心API)
4. 加载WEB-INF/classes目录的class文件 ← 优先!
5. 加载WEB-INF/lib目录的jar包 ← 优先!
6. 委托给Common ClassLoader加载Tomcat公共类
关键在第4、5步------Web应用自己的类和jar包优先于 Tomcat公共类加载。这就是为什么你放在WEB-INF/lib里的Spring版本会"覆盖"Catalina Home里lib目录的同名jar。
7.4 Spring Boot内嵌Tomcat的类加载
Spring Boot打成Fat Jar后,类加载机制和传统Tomcat有区别。Spring Boot用LaunchedURLClassLoader替代了WebappClassLoader,但核心逻辑类似------优先加载BOOT-INF/classes和BOOT-INF/lib。
来看一段验证代码:
java
// 打印某个类的加载器
public class ClassLoaderTest {
public static void main(String[] args) {
// Spring的类
ClassLoader springLoader =
org.springframework.context.ApplicationContext.class
.getClassLoader();
System.out.println("Spring class loaded by: " + springLoader);
// JDK的类
ClassLoader jdkLoader =
String.class.getClassLoader();
System.out.println("JDK class loaded by: " + jdkLoader);
// 自己的类
ClassLoader myLoader =
ClassLoaderTest.class.getClassLoader();
System.out.println("My class loaded by: " + myLoader);
}
}
在Spring Boot应用中运行,你会看到:
- Spring的类由
LaunchedURLClassLoader加载 - JDK核心类由Bootstrap加载(显示为null)
- 你自己的类也由
LaunchedURLClassLoader加载
类加载器的问题排查是运维的基本功。我最常遇到的是jar包冲突 ------明明
WEB-INF/lib里有commons-lang3-3.12,运行时报NoSuchMethodError,一查发现Tomcat的lib目录里也有一个commons-lang3-3.1的旧版本被加载了。排查方法:用-verbose:class启动参数,或者在代码里System.out.println(StringUtils.class.getProtectionDomain().getCodeSource().getLocation())看类到底从哪个jar加载的。还有一个经典坑是SPI机制------JDBC驱动通过ServiceLoader加载,ServiceLoader用的是当前线程的ContextClassLoader,在Tomcat里这个ClassLoader和WebappClassLoader不一定一致,导致驱动找不到。遇到No suitable driver错误时往这个方向查。
理解了这个骨架,你做性能调优时就不会再瞎调。线程池满了、连接积压、请求慢------每个现象对应的是哪个环节的问题,你心里有了地图。
下一篇我们会深入Tomcat线程池调优,把maxConnections、acceptCount、maxThreads这三个参数的配合讲透,结合真实的压测数据来分析。90%的人在这三个参数上都会犯至少一个错误。