从后端开发的第一天开始,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/login,Tomcat 会让叫 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 等 |
| lib | Tomcat 自身的公共类库 | 不要随意往这里塞应用依赖 |
| logs | 运行日志输出 | catalina.out、localhost_access_log 在这 |
| webapps | 默认的应用部署目录 | 默认自带 ROOT、docs 等示例 |
| work | JSP 编译后的 class 文件 | 启动时自动生成,别去手动维护 |
| temp | Tomcat 运行期临时目录 | 可以定期清理 |
这里重点强调 lib 目录。lib 是 Tomcat 容器的公共类库目录,加载出来之后会由公共类加载器管理。很多同学习惯把项目的数据库驱动、公共依赖都丢进 lib,图省事,结果不同应用之间因为类版本冲突引发各种诡异问题。正确的做法是,应用自己的依赖放到项目构建产物的 WEB-INF/lib 里,lib 目录只放容器级别的基础库。
2.2 server.xml 里的核心元素
server.xml 是 Tomcat 的“骨架配置”。下面是一个常见的简化版本:
<Server port="8005" shutdown="SHUTDOWN"> <Service name="Catalina"> <Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" maxParameterCount="10000" /> <Engine name="Catalina" defaultHost="localhost"> <Host name="localhost" appBase="webapps" unpackWARs="true" autoDeploy="true"> </Host> </Engine> </Service> </Server>Server 端口默认 8005,shutdown 是关闭指令的字符串。生产环境里如果这个端口被外部访问,理论上任何人都可以通过发送 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 应用和外部 docBase
Tomcat 自带的 Manager 应用可以用来远程部署、停止、启动和移除应用。要使用 Manager,需要先在 conf/tomcat-users.xml 里配置对应的用户角色,比如 manager-gui 用于页面操作,manager-script 用于脚本调用。生产环境里 Manager 通常不应该暴露到外网,需要配合防火墙地址限制,避免任意来源的访问。
另一种方式是使用外部 docBase,也就是在 Host 下通过 Context 配置把应用指向工程构建目录或者任意磁盘路径。比如:
<Context path="/app" docBase="/data/webapps/app" reloadable="false" />这种方式在联调环境很方便,因为它不需要把 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 name="tomcatThreadPool" namePrefix="catalina-exec-" maxThreads="200" minSpareThreads="20" maxIdleTime="60000" prestartminSpareThreads="true" /><Connector port="8080" protocol="HTTP/1.1" executor="tomcatThreadPool" connectionTimeout="20000" acceptCount="200" maxConnections="1000" maxParameterCount="10000" />这种方式的优势在于线程池的配置和连接器解耦,多服务或者多连接器可以共用同一个线程池,也方便运维统一调整。配置里 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. .log;Web 应用里使用 java.util.logging 的输出会进入 localhost. .log;Manager 和 host-manager 的日志单独放在对应文件;访问日志由 AccessLogValve 配置生成。
正确做法是先根据时间点确定是容器启动问题、应用报错还是业务访问链路问题,再有针对性地去对应日志文件里找。排查启动报错时我会同时打开 catalina.out 和 localhost. .log,因为有些异常在应用日志里表现更明显,比如 Context 初始化失败、Listener 异常、Servlet 加载失败。
5.2 高频错误与排查思路
| 现象 | 常见原因 | 排查建议 |
|---|---|---|
| 启动报 Address already in use | 8080 或 8005 被占用 | netstat/lsof 看端口占用,改端口或杀进程 |
| 浏览器访问中文乱码 | 请求编码或响应编码不一致 | 检查页面编码、连接器 URIEncoding、Filter 设置的字符集 |
| 返回 404 | Context Path 不对或映射缺失 | 确认访问路径、Context 名称、web.xml 映射 |
| 访问 manager 报 403 | 角色未配置或地址限制 | 检查 tomcat-users.xml 和 RemoteAddrValve |
| ClassNotFoundException | 应用依赖缺失或类加载顺序被干扰 | 检查 WEB-INF/lib、lib 目录、依赖冲突 |
| OutOfMemoryError: Metaspace | 类加载过多或加载器泄漏 | 减少热部署,检查动态生成类,配合转储分析 |
| Too many open files | 系统文件句柄不足 | 调大 ulimit,检查连接数是否异常 |
乱码问题是一个重灾区,但也要分层次。Tomcat 8.0 之后连接器对于 URI 编码默认使用 UTF-8,如果还在用旧版本,可能需要在连接器上加 URIEncoding="UTF-8"。响应乱码往往是业务代码设置的 Content-Type 没有包含 charset,或者中间件改了编码。日志乱码则不一定和 HTTP 请求有关,通常是 JVM 默认文件编码和日志框架配置不一致,需要在启动参数中显式设置。常用的做法是在 bin/setenv.sh 里加一行:
export JAVA_OPTS="-Dfile.encoding=UTF-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 和 examples,manager 与 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,很多概念会一下子串起来。