1. Tomcat 启动流程全景拆解:从脚本到端口监听到底经历了什么
很多人用 Tomcat 部署 Web 应用,日常就是 startup.sh 一敲,浏览器一刷,页面出来了就完事。但真遇到启动慢、端口占用、应用初始化失败、日志刷一堆异常的时候,如果对启动流程没有整体认知,排查起来就是盲人摸象。我自己在几个中小型项目里反复踩过这类坑,后来干脆把 Tomcat 的启动链路完整梳理了一遍,从启动脚本一路跟到 Connector 监听端口,才算真正把这块吃透。
这篇内容适合三类人看:一是刚接触 Java Web 部署、只会点启动脚本的新手;二是遇到过启动异常但不知道从哪下手排查的开发者;三是想搞清楚 Tomcat 内部组件初始化顺序、为调优打基础的中级工程师。我会按照“整体设计思路 → 核心组件细节 → 完整实操流程 → 常见问题排查”的顺序展开,尽量把每一步背后的“为什么”讲清楚,而不是只丢结论。
需要先说明一点:Tomcat 的启动流程在不同大版本之间(比如 8、9、10)有细节差异,但主干逻辑是一致的。下面讲的内容以常见的 Tomcat 9 为主线,涉及差异的地方我会单独标注。另外,本文所有涉及路径、配置的地方都用通用写法,你按自己实际环境替换即可。
2. 启动流程的整体设计与思路拆解
2.1 为什么 Tomcat 要设计成“脚本 + Bootstrap + Catalina”三层结构
刚看 Tomcat 源码的人容易懵:明明是个 Java 程序,为什么启动入口不是直接一个 main 方法,而是先有个 shell 脚本,脚本里又调一个 Java 类,Java 类里再反射调用另一个类?这不是脱裤子放屁吗?
其实这套设计是有明确意图的。Tomcat 把启动拆成三层,核心目的是隔离“环境准备”和“容器运行”这两件事。
第一层是启动脚本(startup.sh / startup.bat),它负责的是操作系统层面的准备工作:找到 Java 可执行文件、拼装 JVM 参数、设置 CATALINA_HOME 和 CATALINA_BASE 这些环境变量、把标准输出重定向到日志文件。这些事情跟 Java 代码本身没关系,放在脚本里最合适。
第二层是 Bootstrap 类,它是整个 Java 进程的真正入口。它的职责非常克制:初始化一个共享的类加载器(commonLoader),然后用这个类加载器去加载 Catalina 类,最后通过反射调用 Catalina 的 load() 和 start() 方法。为什么要多这一层反射?因为 Tomcat 的类加载体系和普通 Java 应用不一样,它需要保证 Catalina 及其依赖的类由特定的类加载器加载,而不是由系统类加载器加载。如果直接在 Bootstrap 里 new Catalina(),类加载路径就乱了。
第三层是 Catalina 类,它才是真正干活的“总指挥”。它负责解析 server.xml、创建 Server 对象、初始化 Service、Engine、Connector、Host、Context 这一整条组件链,最后启动它们。
提示:理解这三层分工,是理解整个启动流程的钥匙。后面遇到任何启动问题,先判断是脚本层、Bootstrap 层还是 Catalina 层出的问题,排查范围立刻缩小一大半。
2.2 组件初始化顺序为什么不能随便调
Tomcat 的组件是有严格父子关系的:Server 包含 Service,Service 包含 Connector 和 Engine,Engine 包含 Host,Host 包含 Context。这个层级不是随便设计的,它决定了初始化和启动的顺序。
初始化(init)是自顶向下的:先创建 Server,再创建它内部的 Service,再创建 Service 里的 Connector 和 Engine,一层层往下。因为父组件需要持有子组件的引用,没有子组件,父组件就是个空壳。
启动(start)则是自底向上的:先启动最底层的 Context(也就是你的 Web 应用),再启动 Host,然后 Engine,再 Connector,最后 Server。为什么反过来?因为只有当底层组件都准备好、能对外提供服务了,上层组件启动才有意义。如果 Connector 先启动开始接收请求,但底下的 Context 还没初始化完,请求进来就会报 404 或者 500。
这个“初始化自顶向下、启动自底向上”的规律,是 Tomcat 生命周期设计的核心。Lifecycle 接口里的 init() 和 start() 方法,配合 LifecycleBase 抽象类里的事件通知机制,保证了整个链条有序推进。
2.3 类加载器体系为什么是启动流程里最容易被忽略的关键
大部分人讲 Tomcat 启动流程,讲到组件初始化就停了,但类加载器体系其实是绕不开的一环。Tomcat 没有用默认的双亲委派模型一把梭,而是自己定义了一套类加载器层级:
commonLoader:加载 Tomcat 自身和所有 Web 应用都能用的类,对应 lib 目录。
catalinaLoader:加载 Tomcat 内部实现类,通常就是 commonLoader 的引用。
sharedLoader:加载所有 Web 应用共享但 Tomcat 自身不用的类,对应 shared 目录(默认和 common 合并)。
webappLoader:每个 Web 应用独享,加载 WEB-INF/classes 和 WEB-INF/lib 下的类。
为什么要这么分?核心是为了应用隔离。如果所有 Web 应用都用同一个类加载器,A 应用引用的某个库版本和 B 应用冲突,就会互相干扰。Tomcat 给每个应用一个独立的 WebappClassLoader,并且默认打破了双亲委派——先尝试自己加载,加载不到再委托给父加载器。这样应用可以覆盖 Tomcat 提供的某些库版本,灵活性更高。
这个设计在启动流程里的体现就是:Bootstrap 先创建 commonLoader,再用它加载 Catalina;而每个 Context 启动时,会创建自己的 WebappClassLoader 去加载应用类。如果你在启动日志里看到 ClassNotFoundException,先判断是哪个加载器报的,基本就能定位是 Tomcat 库缺失还是应用依赖缺失。
3. 核心细节解析与实操要点
3.1 启动脚本里到底做了哪些事
以 Linux 下的 startup.sh 为例,它本身其实很短,核心就是调用 catalina.sh start。真正干活的是 catalina.sh。这个脚本里几个关键动作值得单独拎出来说。
第一,确定 CATALINA_HOME 和 CATALINA_BASE。CATALINA_HOME 是 Tomcat 安装目录,CATALINA_BASE 是实例运行目录。默认情况下两者相同,但生产环境经常用一套安装目录跑多个实例,这时候 CATALINA_BASE 就指向各自的实例目录,里面放 conf、logs、webapps、temp、work。这个区分非常重要,很多人改了 conf/server.xml 却不生效,就是因为改错了目录。
第二,拼装 JVM 参数。脚本会读取 JAVA_OPTS、CATALINA_OPTS、JAVA_HOME 等环境变量。这里有个容易混淆的点:JAVA_OPTS 在启动和关闭时都会用到,而 CATALINA_OPTS 只在启动时用。所以像内存大小、GC 参数这类只跟运行相关的配置,应该放 CATALINA_OPTS;而像调试端口这种,放 JAVA_OPTS 也行,但要注意关闭时也会带上。
第三,确定启动类。脚本最终会执行类似这样的命令:
BASH
复制
1
exec "$_RUNJAVA" $JAVA_OPTS $CATALINA_OPTS \
2
-Dcatalina.base="$CATALINA_BASE" \
3
-Dcatalina.home="$CATALINA_HOME" \
4
-Djava.io.tmpdir="$CATALINA_TMPDIR" \
5
org.apache.catalina.startup.Bootstrap "$@" start
注意最后那个 org.apache.catalina.startup.Bootstrap,这就是 Java 进程的真正入口。"$@" 会把脚本收到的参数透传进去,start 是告诉 Bootstrap 要执行启动动作。
注意:如果你在脚本里看到 -Djava.util.logging.config.file 或 -Djava.util.logging.manager 这类参数,说明 Tomcat 用的是 JULI 日志框架。日志配置不对会导致启动日志不输出或者输出到奇怪的地方,排查启动问题时先确认日志到底写哪去了。
3.2 Bootstrap 的 main 方法里发生了什么
Bootstrap 的 main 方法逻辑不复杂,但每一步都很关键。我按执行顺序拆一下。
第一步,创建 Bootstrap 实例,并把 catalina.home 和 catalina.base 系统属性保存下来。这两个属性后面创建类加载器时要用。
第二步,调用 initClassLoaders()。这个方法会依次创建 commonLoader、catalinaLoader、sharedLoader。创建逻辑是读 conf/catalina.properties 里的 common.loader、server.loader、shared.loader 配置,默认情况下 server.loader 和 shared.loader 为空,所以 catalinaLoader 和 sharedLoader 都指向 commonLoader。
第三步,把当前线程的上下文类加载器设置为 catalinaLoader。这一步很关键,因为后面反射调用 Catalina 时,需要保证 Catalina 及其依赖由正确的加载器加载。
第四步,用 catalinaLoader 加载 org.apache.catalina.startup.Catalina 类,然后反射创建实例,并调用它的 setParentClassLoader 方法,把 sharedLoader 传进去。
第五步,根据命令行参数决定调用 Catalina 的哪个方法。如果是 start,就依次调用 load() 和 start();如果是 stop,就调用 stop()。
这里有个细节值得注意:Bootstrap 里维护了一个 catalinaDaemon 字段,它就是反射创建的 Catalina 实例。后续所有操作都通过反射调用这个实例的方法。为什么要用反射而不是直接引用?因为 Bootstrap 是由系统类加载器加载的,而 Catalina 是由 commonLoader 加载的,两者不在同一个类加载器命名空间里,直接引用会报 NoClassDefFoundError。
3.3 Catalina 的 load 方法如何构建整个组件树
Catalina.load() 是整个启动流程里最核心的一步,它负责把 server.xml 变成一个活的组件树。流程大致如下。
首先,创建 Digester 对象。Digester 是 Tomcat 用来解析 XML 的工具,它把 XML 标签和 Java 类的方法调用映射起来。比如遇到
然后,设置 Digester 的规则。这些规则在 Catalina 的 createStartDigester() 方法里定义,包括各种标签对应的类名、属性名、方法名。这一步决定了 server.xml 里能写哪些标签、每个标签对应什么组件。
接着,调用 digester.parse(inputSource) 解析 server.xml。解析过程中,Digester 会按照规则创建 Server、Service、Connector、Engine、Host 等对象,并把它们按父子关系组装起来。解析完成后,Catalina 就拿到了一个完整的 Server 对象。
最后,调用 getServer().init()。这会触发整个组件树的初始化,按照前面说的“自顶向下”顺序,依次初始化 Server、Service、Engine、Host、Context、Connector 等。
提示:server.xml 里的
3.4 Connector 初始化时到底准备了什么
Connector 是 Tomcat 对外的门面,它负责接收网络请求并转交给 Engine 处理。初始化阶段,Connector 主要做两件事。
第一,创建 ProtocolHandler。ProtocolHandler 是协议处理器,常见的有 Http11NioProtocol、Http11AprProtocol、Http11Nio2Protocol 等。它决定了 Tomcat 用什么方式处理 HTTP 请求。比如 NIO 模式用 Java NIO,APR 模式用本地库,NIO2 用异步 IO。选哪种模式,直接影响并发能力和资源占用。
第二,创建 Endpoint。Endpoint 是 ProtocolHandler 内部的通信端点,负责实际的 socket 监听和读写。以 NIO 为例,NioEndpoint 会创建 ServerSocketChannel、Poller、Acceptor 等组件。初始化阶段,Endpoint 会绑定端口(bind),但还没有开始接收连接(accept)。
这里有个常见误区:很多人以为 Connector 初始化完就能接收请求了,其实不是。初始化只是把端口绑定了,真正开始 accept 连接是在 start 阶段。所以如果你在初始化阶段看到端口已经被占用,说明是 bind 失败,而不是 accept 失败。
3.5 Context 启动时应用是怎么被加载的
Context 代表一个 Web 应用,它的启动过程是整条链路里最复杂的一环,因为涉及到应用类的加载、Servlet 的初始化、监听器的触发等。
Context 启动时,首先会创建 WebappLoader,也就是前面说的 WebappClassLoader。它会扫描 WEB-INF/classes 和 WEB-INF/lib,把应用类加载进来。然后解析 web.xml(如果存在),创建 Servlet、Filter、Listener 等对象的定义。
接着,Context 会触发 ServletContextListener 的 contextInitialized 方法。这是应用自己初始化逻辑的入口,很多框架(比如 Spring)就是通过这个监听器启动的。如果你的应用启动慢,大概率是卡在这一步。
最后,Context 会初始化所有 load-on-startup 大于等于 0 的 Servlet。这些 Servlet 的 init 方法会被调用,完成各自的初始化。load-on-startup 值越小,优先级越高,越早初始化。
注意:Context 启动失败是启动流程里最常见的问题来源。日志里如果出现 Error deploying web application 或者 Context [/xxx] startup failed,基本就是应用自身的问题,跟 Tomcat 核心关系不大。排查时重点看应用日志,而不是 Tomcat 日志。
4. 完整实操流程与关键环节实现
4.1 从零开始手动走一遍启动流程
光看理论容易忘,我建议你跟着下面这个流程手动走一遍,每一步都去日志里找对应的输出,走完一遍基本就刻在脑子里了。
第一步,确认环境变量。执行 echo $CATALINA_HOME 和 echo $CATALINA_BASE,确认两个目录都存在且指向正确。如果 CATALINA_BASE 没设置,默认等于 CATALINA_HOME。
第二步,检查 conf/server.xml。重点看
第三步,执行启动命令。用 catalina.sh run 而不是 startup.sh,因为 run 会把日志直接输出到控制台,方便观察。启动过程中你会看到类似这样的日志序列:
TEXT
复制
1
Server version name: Apache Tomcat/9.0.x
2
Server built: ...
3
Server number: 9.0.x.0
4
OS Name: Linux
5
...
6
Creating Server component
7
Creating Service component
8
Creating Connector component
9
Creating Engine component
10
Creating Host component
11
...
12
Starting Servlet engine: [Apache Tomcat/9.0.x]
13
Deploying web application directory [/xxx/webapps/ROOT]
14
Deployment of web application directory [/xxx/webapps/ROOT] has finished in [xxx] ms
15
Starting ProtocolHandler ["http-nio-8080"]
16
Server startup in [xxx] milliseconds
第四步,对照日志理解每一步。Creating Server component 对应 Catalina.load() 里的解析阶段;Starting Servlet engine 对应 Engine 的启动;Deploying web application 对应 Context 的启动;Starting ProtocolHandler 对应 Connector 的启动;最后的 Server startup in 表示整个流程完成。
第五步,验证端口监听。执行 netstat -tlnp | grep 8080 或 ss -tlnp | grep 8080,确认端口处于 LISTEN 状态。如果没监听,说明 Connector 启动失败。
4.2 关键参数的计算与选择过程
启动流程里涉及不少参数,选错了轻则性能差,重则启动失败。我挑几个最关键的说说怎么算、怎么选。
JVM 堆内存。-Xms 和 -Xmx 建议设成一样的值,避免运行期动态扩容带来的抖动。具体设多大,看应用规模和机器内存。一个粗略的经验公式是:单实例堆内存不超过物理内存的 50%,且要留出足够空间给操作系统、直接内存、线程栈。比如 8G 内存的机器,堆可以设 4G 左右。如果是容器环境,还要考虑容器内存限制,-Xmx 不能超过容器限制,否则会被 OOM Killer 干掉。
线程池大小。Connector 的 maxThreads 默认 200,minSpareThreads 默认 10。maxThreads 不是越大越好,因为每个线程都要占内存(默认栈大小 1M 左右,可通过 -Xss 调整)。如果 maxThreads 设成 1000,光线程栈就要 1G 内存。一般建议根据压测结果调整,从 200 起步,观察 CPU 和内存使用率再决定加还是减。
连接超时。connectionTimeout 默认 20000 毫秒(20 秒),keepAliveTimeout 默认跟 connectionTimeout 一样。这两个值影响连接资源的释放速度。如果应用响应慢,可以适当调大;如果追求高并发,可以调小,让空闲连接尽快释放。
acceptCount。当所有工作线程都在忙时,新连接会进入等待队列,acceptCount 就是这个队列的长度,默认 100。队列满了之后,新连接会被拒绝。这个值要跟 maxThreads 配合看,如果 maxThreads 是 200,acceptCount 是 100,那最多能扛 300 个并发连接。
4.3 启动日志的逐行解读实操
启动日志是最好的老师,但很多人只是扫一眼有没有 Exception 就关了。我拿一段典型的启动日志,逐行讲讲怎么读。
TEXT
复制
1
03-Jan-2025 10:00:00.123 INFO [main] org.apache.catalina.startup.VersionLoggerListener.log Server version name: Apache Tomcat/9.0.70
这行是 VersionLoggerListener 输出的,它是个 LifecycleListener,在 Server 初始化时触发。看到这行说明 Server 已经开始初始化了。
TEXT
复制
1
03-Jan-2025 10:00:00.456 INFO [main] org.apache.catalina.core.StandardService.startInternal Starting service [Catalina]
这行说明 Service 开始启动了。注意是 startInternal,说明已经过了初始化阶段,进入启动阶段。
TEXT
复制
1
03-Jan-2025 10:00:00.789 INFO [main] org.apache.catalina.core.StandardEngine.startInternal Starting Servlet engine: [Apache Tomcat/9.0.70]
这行说明 Engine 开始启动,接下来会部署 Host 下的所有 Web 应用。
TEXT
复制
1
03-Jan-2025 10:00:01.234 INFO [main] org.apache.catalina.startup.HostConfig.deployDirectory Deploying web application directory [/opt/tomcat/webapps/ROOT]
这行说明 HostConfig 开始部署 ROOT 应用。HostConfig 是 Host 的监听器,负责扫描 appBase 目录下的应用。
TEXT
复制
1
03-Jan-2025 10:00:03.456 INFO [main] org.apache.catalina.startup.HostConfig.deployDirectory Deployment of web application directory [/opt/tomcat/webapps/ROOT] has finished in [2222] ms
这行说明 ROOT 应用部署完成,耗时 2222 毫秒。如果这个时间特别长,说明应用初始化慢,要去查应用自身的启动逻辑。
TEXT
复制
1
03-Jan-2025 10:00:03.567 INFO [main] org.apache.coyote.AbstractProtocol.start Starting ProtocolHandler ["http-nio-8080"]
这行说明 Connector 开始启动,ProtocolHandler 是 http-nio-8080,也就是 NIO 模式的 HTTP 协议,监听 8080 端口。
TEXT
复制
1
03-Jan-2025 10:00:03.678 INFO [main] org.apache.catalina.startup.Catalina.start Server startup in [3555] milliseconds
这行是 Catalina 输出的,表示整个启动流程完成,总耗时 3555 毫秒。这个数字是衡量启动性能的核心指标。
提示:把启动日志按时间戳排序,算出每个阶段的耗时,就能定位启动瓶颈。常见瓶颈有:应用初始化慢(Deploying web application 到 has finished 之间)、类加载慢(大量 ClassNotFoundException 重试)、端口绑定慢(bind 耗时异常)。
4.4 用 LifecycleListener 做启动扩展的实操
如果你想在启动流程里插入自己的逻辑,比如启动时打印一些环境信息、检查某些依赖是否就绪,最规范的做法是实现 LifecycleListener 接口,然后在 server.xml 里注册。
先写一个简单的监听器:
JAVA
复制
1
package com.example.listener;
2
3
import org.apache.catalina.LifecycleEvent;
4
import org.apache.catalina.LifecycleListener;
5
6
public class StartupCheckListener implements LifecycleListener {
7
@Override
8
public void lifecycleEvent(LifecycleEvent event) {
9
String type = event.getType();
10
if (Lifecycle.BEFORE_START_EVENT.equals(type)) {
11
System.out.println("准备启动,当前时间:" + System.currentTimeMillis());
12
} else if (Lifecycle.AFTER_START_EVENT.equals(type)) {
13
System.out.println("启动完成,当前时间:" + System.currentTimeMillis());
14
}
15
}
16
}
然后在 server.xml 的
XML
复制
1
这样启动时就会在 Server 启动前后触发你的逻辑。注意 Lifecycle 接口里定义了很多事件类型,常用的有 BEFORE_INIT_EVENT、AFTER_INIT_EVENT、BEFORE_START_EVENT、AFTER_START_EVENT、BEFORE_STOP_EVENT、AFTER_STOP_EVENT 等。你可以根据需要选择监听哪个。
注意:LifecycleListener 的 lifecycleEvent 方法是在启动主线程里同步执行的,如果你的逻辑很耗时,会拖慢整个启动过程。所以监听器里只做轻量级操作,重活交给异步线程或者应用自身的初始化逻辑。
5. 常见问题与排查技巧实录
5.1 端口被占用:从报错到定位的完整思路
端口占用是启动失败里最高频的问题,报错信息通常是 java.net.BindException: Address already in use。看到这个别急着改端口,先搞清楚是谁占的。
第一步,确认是哪个端口。报错信息里一般会带端口号,比如 8080。如果没带,去 server.xml 里找
第二步,查占用进程。Linux 下用 lsof -i:8080 或 netstat -tlnp | grep 8080,Windows 下用 netstat -ano | findstr 8080 再配合 tasklist 查进程名。
第三步,判断占用者。如果是另一个 Tomcat 实例,说明你重复启动了,先停掉旧的。如果是其他程序(比如某个开发工具自带的服务器),要么改 Tomcat 端口,要么停掉那个程序。
第四步,如果确认没有进程占用但还是报 BindException,可能是 TIME_WAIT 状态的连接还没释放。用 netstat -tlnp | grep 8080 看有没有 TIME_WAIT,有的话等一会儿再启动,或者调整内核参数(net.ipv4.tcp_tw_reuse)加速回收。
提示:生产环境建议给 Tomcat 配一个明确的端口规划表,把 HTTP、HTTPS、AJP、shutdown 端口都列清楚,避免多个实例冲突。shutdown 端口默认 8005,很多人忘了改,结果两个实例的 shutdown 端口撞了,停一个把另一个也停了。
5.2 应用启动失败:如何快速定位是 Tomcat 问题还是应用问题
启动日志里出现 Context [/xxx] startup failed 时,很多人第一反应是 Tomcat 坏了,其实大概率是应用自身的问题。区分方法很简单:看异常堆栈的顶层包名。
如果堆栈里出现 org.apache.catalina、org.apache.coyote、org.apache.tomcat 这些包,说明是 Tomcat 自身的问题,比如配置错误、类加载冲突。如果堆栈里出现 org.springframework、com.example 这些包,说明是应用的问题,比如 Bean 创建失败、数据库连不上。
还有一种情况是 ClassNotFoundException 或 NoClassDefFoundError,这时候要看是哪个类找不到。如果是 Tomcat 的类找不到,检查 lib 目录是否完整;如果是应用的类找不到,检查 WEB-INF/lib 下依赖是否齐全,或者有没有版本冲突。
我遇到过一个典型案例:应用在本地跑得好好的,部署到服务器就报 NoSuchMethodError。排查发现是服务器上 Tomcat 的 lib 目录里有一个旧版本的公共库,跟应用 WEB-INF/lib 里的新版本冲突了。因为 commonLoader 先加载了旧版本,应用的新版本就没机会加载。解决办法是把冲突的库从 Tomcat lib 里移除,或者调整类加载顺序。
5.3 启动慢:从日志时间戳找出真正的瓶颈
启动慢是另一个高频问题,尤其是应用多了之后。排查方法就是看日志时间戳,算出每个阶段的耗时。
我整理了一个常见瓶颈对照表,你可以对照自己的日志找:
瓶颈位置
日志特征
常见原因
解决方向
类加载阶段
大量 ClassNotFoundException 重试
依赖缺失或类加载器配置不当
补齐依赖,检查 catalina.properties
应用部署阶段
Deploying web application 到 has finished 耗时长
应用初始化逻辑重,如扫描大量类、连数据库
优化应用启动逻辑,延迟初始化
端口绑定阶段
Starting ProtocolHandler 前耗时长
端口被占用重试,或 DNS 解析慢
检查端口,配置 -Djava.net.preferIPv4Stack=true
随机数生成阶段
启动卡在 SecureRandom 相关日志
JVM 熵源不足
配置 -Djava.security.egd=file:/dev/./urandom
其中随机数生成这个坑特别隐蔽。Tomcat 启动时会初始化 SecureRandom,如果 JVM 熵源不足,会阻塞很久。表现就是启动日志停在某一行不动,过几十秒甚至几分钟才继续。解决办法就是加上 -Djava.security.egd=file:/dev/./urandom 这个参数,让 JVM 用非阻塞的熵源。
5.4 常见问题速查表
为了方便你快速对照,我把启动流程里最常见的问题整理成一张表:
问题现象
可能原因
排查命令/方法
解决措施
BindException: Address already in use
端口被占用
lsof -i:端口
停掉占用进程或改端口
Context startup failed
应用初始化异常
看应用日志堆栈
修复应用代码或配置
ClassNotFoundException
依赖缺失
检查 lib 和 WEB-INF/lib
补齐依赖
NoSuchMethodError
版本冲突
对比类加载路径
移除冲突库
启动卡住不动
熵源不足或死锁
jstack 看线程栈
配置熵源参数
启动后 404
应用未部署成功
看 Deploying 日志
检查 appBase 和 webapps
启动后端口不监听
Connector 启动失败
看 ProtocolHandler 日志
检查 Connector 配置
启动日志不输出
日志配置错误
检查 logging.properties
修正日志配置
提示:排查启动问题时,养成“先看日志、再看配置、最后看代码”的习惯。90% 的问题在日志里都有明确线索,只是很多人没耐心读完。
5.5 几个我踩过的坑和独家经验
第一个坑是 CATALINA_BASE 和 CATALINA_HOME 混用。有次我改了 CATALINA_HOME/conf/server.xml,重启后配置不生效,折腾半天才发现实际用的是 CATALINA_BASE 下的配置。后来我养成习惯,启动前先 echo 这两个变量,确认改的是对的那个。
第二个坑是 shutdown 端口没改。两个实例都用默认的 8005,停 A 的时候把 B 也停了。后来我在 server.xml 里给每个实例配了不同的 shutdown 端口,并且把 shutdown 命令设成 SHUTDOWN 之外的值,避免误操作。
第三个坑是 work 目录没清理。有时候应用改了代码,重启后还是旧行为,原因是 work 目录里缓存了编译后的 JSP 和类。后来我养成习惯,重大变更后先删 work 目录再启动。
第四个坑是 JVM 参数放错地方。有次把 -Xmx 放到了 JAVA_OPTS 里,结果关闭 Tomcat 时也带上了这个参数,虽然不影响功能,但看着别扭。后来统一把内存参数放 CATALINA_OPTS,调试参数放 JAVA_OPTS。
第五个坑是日志级别设太高。有次排查问题,把日志级别调到 DEBUG,结果启动日志刷了几万行,真正有用的信息被淹没了。后来我学会用 grep 过滤,只看 WARN 和 ERROR,或者按类名过滤。
6. 启动流程的扩展与调优方向
6.1 从启动流程看性能调优的切入点
理解了启动流程,调优就有了方向。启动阶段的调优主要围绕三个目标:启动更快、占用更少、运行更稳。
启动更快,可以从减少应用初始化时间入手。比如把一些非必要的初始化逻辑改成懒加载,把耗时的远程调用放到启动后异步执行。Tomcat 层面可以调整 HostConfig 的 deployOnStartup 和 autoDeploy 参数,控制部署行为。
占用更少,主要是控制内存和线程。堆内存按需设置,不要盲目调大。线程池大小根据实际并发调整,maxThreads 和 acceptCount 配合好。类加载器层面,如果应用不多,可以考虑合并 sharedLoader 和 commonLoader,减少加载器数量。
运行更稳,关键是异常处理。启动阶段的异常要能快速定位,运行阶段的异常要能优雅降级。Connector 的 maxConnections、acceptCount、connectionTimeout 这些参数,决定了 Tomcat 在压力下的表现。
6.2 容器化环境下的启动流程差异
现在越来越多项目跑在容器里,Tomcat 的启动流程在容器环境下有一些差异需要注意。
第一,CATALINA_BASE 通常挂载到卷上,方便持久化日志和配置。但要注意卷的权限,如果容器内用户没有写权限,启动会失败。
第二,JVM 参数要考虑容器内存限制。JDK 8u191 之后支持 -XX:+UseContainerSupport,能自动感知容器内存。但为了保险,还是显式设置 -Xmx 比较好。
第三,启动探针和就绪探针的配置要跟启动流程匹配。如果启动耗时较长,initialDelaySeconds 要设够,否则容器还没启动完就被判定为失败,反复重启。
第四,日志输出建议直接到标准输出,由容器运行时收集,而不是写到文件。这样 kubectl logs 或 docker logs 能直接看到启动日志,排查方便。
6.3 启动流程的监控与告警思路
生产环境里,启动流程本身也需要监控。核心指标有几个:启动耗时、启动成功率、启动阶段各环节耗时。
启动耗时可以通过解析日志里的 Server startup in [xxx] milliseconds 获取。如果超过阈值,触发告警。启动成功率可以通过健康检查接口判断,启动失败时告警。各环节耗时可以通过在 LifecycleListener 里打点,把数据上报到监控系统。
我自己的做法是在 StartupCheckListener 里记录 BEFORE_START 和 AFTER_START 的时间戳,算出总耗时,然后写到一个监控文件里,由采集程序定期读取。这样既能监控启动性能,又不用改 Tomcat 源码。
6.4 后续可以深入的方向
如果你已经把启动流程摸透了,接下来可以往这几个方向深入:一是研究 ProtocolHandler 的具体实现,理解 NIO、NIO2、APR 三种模式的差异和适用场景;二是研究 WebappClassLoader 的类加载细节,搞清楚热部署和类冲突的底层原理;三是研究 Lifecycle 事件机制,学会用监听器做各种启动扩展;四是研究 Digester 的解析规则,理解 server.xml 里每个标签背后的类和方法。
这些方向每一个都够写一篇长文,但只要你把启动流程这个主干搞清楚了,往哪个方向延伸都不会迷路。我自己就是从启动流程入手,慢慢把 Tomcat 的各个模块串起来的,回头看,这一步走得值。
