欢迎光临
我们一直在努力

Tomcat架构深度拆解:Connector和Container到底怎么配合的?

你每天都在用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中最精简的配置结构:

<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内部有几个关键角色,我用代码来说明:

// 简化后的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去处理。

// 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的核心逻辑:

// 简化后的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连接。

// 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事件(有数据可读)。

// Poller.register() —— 将新连接注册到Selector
public void register(SocketChannel socketChannel) {
NioSocketWrapper socketWrapper = new NioSocketWrapper(socketChannel);
// 注册OP_READ事件
socketChannel.register(selector, SelectionKey.OP_READ, socketWrapper);
}

Poller线程在另一个死循环中轮询Selector:

// 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后,开始真正处理请求:

// 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

// 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:

// 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链

// 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链

// 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,这里会做两件关键的事:

// 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接口

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中配置

<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:

@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。

    来看一段验证代码:

    // 打印某个类的加载器
    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%的人在这三个参数上都会犯至少一个错误。

    赞(0)
    未经允许不得转载:171主机测评 » Tomcat架构深度拆解:Connector和Container到底怎么配合的?
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址