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

Tomcat架构深度拆解:Connector和Container到底怎么配合的? 你每天都在用Tomcat——Spring Boot内嵌的就是它。但你真的了解Tomcat是怎么工作的吗当一个HTTP请求打到Tomcat从Socket连接建立到最终你的Servlet代码执行中间到底经历了什么Connector和Container这两个名字你肯定见过但它们之间是怎么配合的Engine、Host、Context、Wrapper这一层层的容器到底在做什么这些问题的答案决定了你做性能调优时是真懂还是瞎调。一、Tomcat整体架构概览先上一张Tomcat的整体架构脑图ServerTomcat实例全局唯一 └── 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 port8005 shutdownSHUTDOWN Service nameCatalina !-- Connector负责接客 -- Connector port8080 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 / !-- Engine负责分活 -- Engine nameCatalina defaultHostlocalhost Host namelocalhost appBasewebapps unpackWARstrue autoDeploytrue !-- Context通常不显式配置由autoDeploy自动发现 -- /Host /Engine /Service /Server一个Server包着一个Service通常就一个Service里面左边挂Connector右边挂EngineEngine往下是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基于APRApache Portable Runtime使用JNI调用本地库NioEndpoint内部有几个关键角色我用代码来说明// 简化后的NioEndpoint核心结构 public class NioEndpoint extends AbstractJsseEndpointNioChannel,SocketChannel { // ① Acceptor线程专门接收新连接 private AcceptorNioChannel 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配合NioEndpointAjpProcessor解析AJP协议Processor解析完协议后会生成一个CoyoteRequest对象注意这不是最终的ServletRequest后面Adapter会转换。2.3 AdapterConnector和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模型经历了三个阶段这是面试高频考点但很多人只停留在背答案阶段模型版本支持原理现状BIOTomcat 7前默认一个连接占一个线程阻塞式IOTomcat 9已移除NIOTomcat 8后默认基于Selector多路复用AcceptorPoller线程池主流方案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事件 IteratorSelectionKey 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.comContext一个Web应用/order-serviceWrapper一个Servlet的包装器OrderServlet这个设计是典型的组合模式——每个容器都实现了Container接口可以包含子容器。Engine包含多个HostHost包含多个ContextContext包含多个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执行的全过程。这是这篇文章最核心的部分请仔细看。假设一个请求GEThttp://localhost:8080/myapp/hello?namelaoliang第一步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秒 IteratorSelectionKey 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慢客户端调小keepAliveTimeoutKeep-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核数×2IO密集型可以200-500acceptCount不要太大等于maxThreads就够了大了只是延迟报错而已maxConnections根据内存和业务峰值来定。5.2 Host核心配置关键属性说明appBaseWeb应用部署目录Tomcat会自动扫描这个目录下的WAR包unpackWARs是否自动解压WAR包到磁盘生产建议true性能更好autoDeploy运行时自动部署新应用生产环境建议关闭防止误操作reloadable是否监控class文件变化自动重载生产环境必须关闭性能杀手reloadabletrue在开发时很好用改了代码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 nameCatalina defaultHostlocalhost !-- 自定义Valve放在Engine层 -- Valve classNamecom.laoliang.tomcat.valve.TimingValve warnThreshold500 / Host namelocalhost appBasewebapps ... ... /Host /Engine第三步在Spring Boot中配置内嵌TomcatSpring Boot不能直接改server.xml但可以通过编程方式注册ValveConfiguration 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%的人在这三个参数上都会犯至少一个错误。