
从后端开发的第一天开始Tomcat 就会频繁出现在你的世界里。不管是本地调试、测试环境部署还是生产环境上一台 8080 端口突然没响应最后多半都要落到对 Tomcat 的理解上。很多人写业务代码很熟练但一遇到启动失败、性能瓶颈、类加载冲突就会卡住本质上还是对 Tomcat 的底层结构没吃透。这篇是我自己学习 Tomcat 的一份整理从架构、配置、部署、调优到日常排查适合刚入门想系统过一次 Tomcat 的后端同学也适合用过几年但没认真抠过细节的开发者翻阅。先从架构看清楚Tomcat 到底是干什么的1.1 Server、Service、Engine、Host、Context、Wrapper 的分层关系很长一段时间里我把 Tomcat 当成一个能跑 Web 应用的“黑盒”直到认真打开 conf/server.xml才意识到它内部是一套非常规整的容器体系。最上层是 Server代表一个完整的 Tomcat 实例也就是你启动一个进程时看到的东西。Server 下面可以有多个 Service每个 Service 负责把外部请求连接器和内部的引擎组合到一起。生产环境里通常一个 Service 就够了但确实可以配置多个 Service 来承载不同的连接器组合。一个 Service 里由两个关键部分组成若干 Connector 和一个 Engine。Connector 负责监听端口、接收网络连接、解析 HTTP 请求Engine 负责真正处理请求。Engine 下面再挂 Host也就是虚拟主机简单理解就是一个 Host 可以对应一个域名和一份应用集合。Host 下面的单位是 Context每个 Context 对应一个部署在 Tomcat 里的 Web 应用。Context 最终往下拆就是一个个 Wrapper每个 Wrapper 包装一个 Servlet 实例。如果用一个生活化的类比Server 是一栋运维大楼Service 是楼里的电梯系统Engine 是总调度台Host 是楼层Context 是楼层里的一个个商户Wrapper 则是每个商户里的具体服务员。请求进来之后电梯按楼层运送总台分配商户商户找对应的服务员处理。虽然类比不完全精确但能帮你快速理解多层容器的协作顺序。1.2 一次 HTTP 请求在 Tomcat 里到底走了多远很多人调试接口只关心 Controller 和 Service对请求进入 Tomcat 之后经历了什么没有概念。一次完整的请求生命周期是这样的客户端发起 TCP 连接Connector 内部的管理组件负责接收连接并将解析好的请求交给线程池中的某个线程执行。接着请求会进入 Engine 的 Pipeline经过各个 Valve 的过滤比如访问日志、远程地址过滤等然后根据请求的域名找到对应的 Host。Host 里会根据 URL 的 Context Path 去匹配应用比如访问 /app/loginTomcat 会让叫 app 的 Context 处理这段路径。Context 再根据 URL 的剩余部分找到具体的 Servlet 映射也就是 Wrapper。找到之后请求会经过 Servlet 规范里的 FilterChain最终执行 Servlet 的 service 方法生成响应再原路返回。这条链路的核心点是Tomcat 最擅长的不是业务逻辑而是把 HTTP 协议转换为对 Servlet 的调用。学习的时候只要抓住“Connector 接收、Engine 路由、Context 定位、Servlet 执行”这条主线后面看日志、排查问题都会顺很多。1.3 为什么要搞这么多层容器初看这套容器模型很多人第一反应是复杂。但实际上每一层都有清晰的价值。多层虚拟主机让一个 Tomcat 实例可以同时承载多个域名而且域名之间互不干扰。多个 Context 意味着可以部署多个应用应用之间通过不同的类加载器隔离一个应用的代码不会影响到另一个应用。Wrapper 的存在则让 Tomcat 能够统一管理 Servlet 的加载、实例化和回收。还有一点容易被忽略Tomcat 在容器层面采用 Pipeline-Valve 这种管道模式。你可以把 Valve 理解成处理链路上的过滤器节点像访问日志、请求过滤都能在最外层统一处理不需要侵入业务代码。理解这个模式之后你会更容易看懂自定义 Valve 的作用也能更好地理解为什么 Tomcat 的架构能支持这么多扩展功能。部署前一定要摸清的目录结构和配置2.1 安装目录里到底放着什么很多人拿到 Tomcat 安装包就直接解压启动缺少对整个目录的全局认识。Tomcat 的目录结构其实很清晰主要关注这些目录/文件作用运维注意点bin启动、关闭、管理脚本catalina.sh、setenv.sh 是重点conf全部核心配置server.xml、web.xml、context.xml 等libTomcat 自身的公共类库不要随意往这里塞应用依赖logs运行日志输出catalina.out、localhost_access_log 在这webapps默认的应用部署目录默认自带 ROOT、docs 等示例workJSP 编译后的 class 文件启动时自动生成别去手动维护tempTomcat 运行期临时目录可以定期清理这里重点强调 lib 目录。lib 是 Tomcat 容器的公共类库目录加载出来之后会由公共类加载器管理。很多同学习惯把项目的数据库驱动、公共依赖都丢进 lib图省事结果不同应用之间因为类版本冲突引发各种诡异问题。正确的做法是应用自己的依赖放到项目构建产物的 WEB-INF/lib 里lib 目录只放容器级别的基础库。2.2 server.xml 里的核心元素server.xml 是 Tomcat 的“骨架配置”。下面是一个常见的简化版本Server port8005 shutdownSHUTDOWN Service nameCatalina Connector port8080 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 maxParameterCount10000 / Engine nameCatalina defaultHostlocalhost Host namelocalhost appBasewebapps unpackWARstrue autoDeploytrue /Host /Engine /Service /ServerServer 端口默认 8005shutdown 是关闭指令的字符串。生产环境里如果这个端口被外部访问理论上任何人都可以通过发送 SHUTDOWN 字符串强制停止服务所以实际部署时要么用防火墙限制本机访问要么直接把 port 调整为 -1 来禁用外部关闭端口。Connector 的 redirectPort 是为了配合 HTTPS 用的。当 Tomcat 收到一个 HTTP 请求由于传输安全要求需要跳转到 HTTPS 时会发送重定向到 redirectPort 指定的端口。如果部署环境有统一的负载均衡或 HTTPS 网关这一项可以按实际情况调整。Host 的 appBase 指的是应用存放的目录unpackWARs 控制 war 包是否自动解压autoDeploy 控制是否自动部署。2.3 context.xml 和 web.xml 的职责边界很多初学者会把 context.xml 和 WEB-INF/web.xml 搞混。简单来说context.xml 是 Tomcat 容器语境下的配置作用是描述某个 Context 应用在容器内部的属性比如 docBase、reloadable、JNDI 数据源等。它不属于 Servlet 规范是容器提供的扩展配置。web.xml 则是 Servlet 规范的一部分描述这个 Web 应用对外提供的 Servlet 映射、过滤器、监听器、欢迎页、错误页、Session 超时等。Tomcat 读取配置的时候会合并全局的 conf/web.xml 和每个应用自己的 WEB-INF/web.xml后者可以覆盖前者的部分内容。部署应用时我特别建议优先使用应用自己的 context.xml 或者 conf/Catalina/localhost 下面的独立配置文件而不是直接修改 server.xml。一个应用一个配置文件维护和回滚都方便而且不容易因为手误把整个容器配置改坏。我自己踩过不少次直接改 server.xml 结果忘记备份回滚时手忙脚乱的坑。常用部署方式与类加载机制3.1 最经典的 war 包部署war 包部署是最传统也最容易理解的方式。把一个标准 web 应用的 war 包放到 Host 的 appBase 下面Tomcat 启动时发现 war 包如果 unpackWARs 为 true就会自动解压并部署。war 包的文件名很重要因为它决定了应用的 Context Path。比如 testapp.war 部署之后的访问路径通常是 /testapp想部署成根应用最简单的方式是改名为 ROOT.war 放到 appBase 下。更新版本时如果只是覆盖旧的 war 包Tomcat 不会每次都自动重新加载很容易出现旧文件缓存或者解压目录不完整的状况。稳妥的做法是先停止应用或者删除旧的解压目录和 war 包再放入新的 war 包最后启动或等待热部署触发。热部署虽然方便但在生产环境只要不是在低峰期尽量少依赖自动加载因为中途状态不可控。3.2 Manager 应用和外部 docBaseTomcat 自带的 Manager 应用可以用来远程部署、停止、启动和移除应用。要使用 Manager需要先在 conf/tomcat-users.xml 里配置对应的用户角色比如 manager-gui 用于页面操作manager-script 用于脚本调用。生产环境里 Manager 通常不应该暴露到外网需要配合防火墙地址限制避免任意来源的访问。另一种方式是使用外部 docBase也就是在 Host 下通过 Context 配置把应用指向工程构建目录或者任意磁盘路径。比如Context path/app docBase/data/webapps/app reloadablefalse /这种方式在联调环境很方便因为它不需要把 war 包复制到 webapps 下也能通过路径访问。但生产环境我不建议把 docBase 直接指向正在编译的工程目录否则一次重新构建触发文件变化Tomcat 很可能进入诡异的中间状态。生产环境里我的习惯是构建机打 war 包分发到目标机器再通过固定脚本部署到 docBase 指定目录。3.3 类加载器的优先级问题类加载是 Tomcat 学习中最容易栽跟头的部分。JVM 本身遵循双亲委派模型但 Tomcat 对 Web 应用类加载有特殊处理Web 应用类加载器会优先从应用自身的 WEB-INF/classes 和 WEB-INF/lib 中加载类之后才委派给父类加载器。这样设计是有道理的因为同一个 Tomcat 可以部署多个应用每个应用对同一个 jar 可以有不同版本互不冲突。但这也带来一个麻烦如果公共类加载器加载的类和 WEB-INF/lib 里加载的类是同一个类两个类之间会因为不是同一个 ClassLoader 加载而产生 ClassCastException。排查这类问题第一件事是确认你的 jar 是不是被 Tomcat 的 lib 目录提前加载了比如一些公共库、工具类甚至旧版本的框架。第二件事是检查应用依赖里是不是存在传递性依赖冲突。我之前调过一个接口本地自测没有问题一部署到测试环境就报 NoSuchMethodError折腾了半天最后发现 Tomcat lib 目录里躺着一个旧版公共库把新类加载给“抢”了。从那之后我每次排查类加载相关异常都会先看一眼 lib 目录和 catalina.properties 里的 loader 配置。连接器与线程池性能调优的主战场4.1 Connector 参数到底该怎么理解如果你只记一个调优参数往往会盯着 maxThreads 不放这其实不全面。Tomcat 的连接器有互相配合的一组参数各司其职。下面几个是重点参数作用说明maxThreads处理请求的最大工作线程数不是越大越好minSpareThreads最小空闲线程数保证低峰期有线程可用maxConnections最大连接数可同时建立的连接总数acceptCount等待队列长度连接数超过 maxConnections 后排队connectionTimeout连接超时时间超过时间未发送请求则断开maxParameterCount请求参数最大个数防大参数攻击的重要限制maxConnections 和 maxThreads 是最容易被混淆的两个值。maxThreads 指的是能并发处理请求的线程数而 maxConnections 指的是 Tomcat 愿意接收并维护的连接总数。当连接数超过 maxConnections 时新的连接会进入 acceptCount 指定的等待队列如果队列也满了新请求就会被拒绝。用快餐店类比就是maxThreads 是店员数量同时能接待几个顾客maxConnections 是店里的总座位数包括正在点餐和等餐的顾客acceptCount 是店外排队的区域。店员忙不过来顾客就坐在座位上等座位满了就店外排队队也排不下就只能扭头去别家。4.2 Executor 线程池配置Tomcat 默认的连接器会使用内部线程池但生产环境里更可控的做法是显式定义一个 Executor然后让 Connector 引用它Executor nametomcatThreadPool namePrefixcatalina-exec- maxThreads200 minSpareThreads20 maxIdleTime60000 prestartminSpareThreadstrue /Connector port8080 protocolHTTP/1.1 executortomcatThreadPool connectionTimeout20000 acceptCount200 maxConnections1000 maxParameterCount10000 /这种方式的优势在于线程池的配置和连接器解耦多服务或者多连接器可以共用同一个线程池也方便运维统一调整。配置里 prestartminSpareThreads 表示启动时就把最小空闲线程全部创建好避免流量尖峰到来时临时创建线程产生的延迟。4.3 一次压测调优的核心思路调优不是拍脑袋调参数而是先建立基线再做有根据的调整。我的做法一般是先用默认配置跑一轮压测记录 QPS、平均响应时间、错误率、CPU 使用率和内存变化。然后逐步调整线程池观察响应时间在哪个线程数位置开始劣化。如果 CPU 已经逼近打满而响应时间还在上升说明线程太多上下文切换正在消耗 CPU 资源。如果 CPU 不高但请求排队的很多说明瓶颈不在 Tomcat 线程而可能在数据库、外部调用或锁等待。只有找到真正的瓶颈调优才有意义。IO 密集型系统和服务型请求可以适当放宽线程数纯计算型应用要克制。另外要注意操作系统层面的限制。修改 Tomcat 的 maxThreads 到几千不代表系统能撑住连接数还要受到文件描述符 ulimit、系统连接队列、防火墙、负载均衡等多层限制。只调 Tomcat 不看系统等于只换了条更粗的水管但水厂出来还是细管。4.4 容易忽视的几个坑第一连接器协议的选择。Tomcat 从 8.5/9.x 开始默认使用 NIO 实现兼容性和性能都足够不需要盲目追求 APR/native。除非经过明确测试否则非原生环境里 APR 库版本和 zlib 依赖带来的问题可能会比收益更明显。第二访问日志不要在生产环境无脑全量打印到控制台。访问日志通过 Valve 配置写入本地文件还算好但如果还在每个请求打印业务日志流量大的时候更容易放大磁盘 IO 问题。第三长连接和 keep-alive 的处理。HTTP keep-alive 会占用连接连接数不够时即使工作线程闲着也可能表现成请求迟迟进不来。遇到类似情况除了添加线程也要检查连接超时配置和客户端连接复用方式。日志体系与常见问题排查手册5.1 看日志先找对位置Tomcat 的日志不是全在一个文件里。很多初学者只知道 catalina.out遇到问题就 tail -f 它其实很多时候问题根本不是从这里看的。Tomcat 日志主要分几类容器自身的启动、关闭、类加载异常会进 catalina.out 和 catalina. .logWeb 应用里使用 java.util.logging 的输出会进入 localhost. .logManager 和 host-manager 的日志单独放在对应文件访问日志由 AccessLogValve 配置生成。正确做法是先根据时间点确定是容器启动问题、应用报错还是业务访问链路问题再有针对性地去对应日志文件里找。排查启动报错时我会同时打开 catalina.out 和 localhost. .log因为有些异常在应用日志里表现更明显比如 Context 初始化失败、Listener 异常、Servlet 加载失败。5.2 高频错误与排查思路现象常见原因排查建议启动报 Address already in use8080 或 8005 被占用netstat/lsof 看端口占用改端口或杀进程浏览器访问中文乱码请求编码或响应编码不一致检查页面编码、连接器 URIEncoding、Filter 设置的字符集返回 404Context Path 不对或映射缺失确认访问路径、Context 名称、web.xml 映射访问 manager 报 403角色未配置或地址限制检查 tomcat-users.xml 和 RemoteAddrValveClassNotFoundException应用依赖缺失或类加载顺序被干扰检查 WEB-INF/lib、lib 目录、依赖冲突OutOfMemoryError: Metaspace类加载过多或加载器泄漏减少热部署检查动态生成类配合转储分析Too many open files系统文件句柄不足调大 ulimit检查连接数是否异常乱码问题是一个重灾区但也要分层次。Tomcat 8.0 之后连接器对于 URI 编码默认使用 UTF-8如果还在用旧版本可能需要在连接器上加 URIEncodingUTF-8。响应乱码往往是业务代码设置的 Content-Type 没有包含 charset或者中间件改了编码。日志乱码则不一定和 HTTP 请求有关通常是 JVM 默认文件编码和日志框架配置不一致需要在启动参数中显式设置。常用的做法是在 bin/setenv.sh 里加一行export JAVA_OPTS-Dfile.encodingUTF-8 -Xms1024m -Xmx2048m还要注意 Windows 环境下的日志编码问题和 Linux 环境不太一样尽量避免依赖系统默认编码。5.3 OOM 和线程卡死的初步定位遇到 OutOfMemoryError先分清楚是堆内存不足、元空间不足还是本地内存不足。Java heap space 一般是对象太多或内存泄漏可以加上转储参数-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/heapdump.hprof下次 OOM 时报错会自动生成堆转储文件再配合 heap dump 分析工具找泄漏对象。Metaspace 溢出多见于频繁热部署、动态生成大量类的场景因为类的元数据在元空间里无法及时回收。需要特别说明的是并不是设置了 -XX:MaxMetaspaceSize 就万事大吉合理避免无效的重复部署更重要。线程卡死、CPU 飙高的问题我会先用 top 找到对应 JVM 进程再用 JDK 自带的 jstack 导出线程快照看线程落在什么状态。大量 RUNNABLE 线程卡在同一个锁方法上基本可以确认是锁竞争大量 WAITING 线程则可能是线程池配置过小或者业务代码在等待外部资源。jstack 输出的内容比较多第一步看线程状态第二步看有没有业务线程同时卡在同一段代码上。这个操作虽然朴素但在排查生产问题里非常实用。我个人建议每个负责 Tomcat 的人都要熟练使用 jstack 和 jstat这两个命令是排查线上问题的基础工具。生产环境常用的加固与优化手段6.1 隐藏版本信息与移除默认组件安全问题里经常被忽略的一项是 Tomcat 的版本号。默认情况下Tomcat 会在 HTTP 响应头里暴露 Server 头可能包含类似 Tomcat/9.x.x 的信息。可以在连接器上配置 server自定义字符串 来隐藏版本或者让负载均衡层统一改写响应头。审计时看到默认版本号基本都会被要求整改。默认安装自带的 ROOT、docs、examples、manager、host-manager 这些应用属于冗余风险面。生产环境我一般会删除 docs 和 examplesmanager 与 host-manager 要么限制访问 IP要么直接删除。特别是 examples 里的示例 Servlet只适合本地学习不应该出现在线上。删除默认应用的操作很简单关键是养成分发新环境时先做一次清理的习惯。6.2 JVM 参数与内存模型考量Tomcat 本质是一个 Java 进程JVM 参数的合理与否直接影响稳定性。一个常见的建议是让 -Xms 和 -Xmx 相等。比如都设为 2g避免运行期频繁扩容带来的抖动。在元空间配置上要结合业务实际情况不要无脑设置一个特别大的 MaxMetaspaceSize否则会挤占系统内存。另外一个被忽略的维度是多实例部署。很多机器上会同时跑多个 Tomcat 实例配置总堆内存时需要为操作系统和其他进程留出余量。如果你在 16G 的机器上部署两个实例每个实例都设置 -Xmx8g启动不报错但高峰期很容易触发整机内存紧张。我的经验是按“总内存去除系统预留后再按实例拆分”的思路来分配。调优的顺序上先保证 JVM 基础参数合理再谈连接器和线程配置。很多同学一上来就动 maxThreads结果 GC 频繁导致请求超时反而把问题搞复杂了。6.3 健康检查与启停的实操体会上线前建议给应用准备一个不依赖外部依赖的健康检查接口负载均衡通过这个接口判断 Tomcat 实例是否存活。这个接口不能只返回一个静态 JSON至少要能反映应用上下文是否初始化完成。直接探测 8080 端口只能说明 Tomcat 活着不能说明应用能用。优雅停机的处理也值得注意。直接 kill -9 会导致请求中断和线程被强行终止一般应该使用 catalina.sh stop 触发正常关闭流程。对于流量比较大的服务停机前可以先把负载均衡上的流量摘掉让连接慢慢释放再执行关闭避免长事务或慢请求被腰斩。Tomcat 的关闭超时机制有一定保护作用但业务侧如果存在大量异步线程还是需要提前排查清楚。我自己在实际操作中的体会是Tomcat 学习最难的不是某个配置文件里某个参数怎么写而是把“连接、线程、容器、应用”这一整条链路串联起来看问题。刚开始学的时候我照着网上调优清单把 maxThreads 调到很大结果压测反而更差后来才明白线程数要跟着 CPU 和瓶颈走。如果你想快速建立手感建议搭一个最简环境开启访问日志对着一次完整的请求把 catalina.out、localhost 日志、应用日志看一遍然后再回头读 server.xml很多概念会一下子串起来。