1. 主页 > 血脉觉醒 >

Tomcat启动流程深度拆解:从脚本到端口监听的完整链路

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 类的方法调用映射起来。比如遇到 标签就调用 setServer 方法,遇到 标签就创建 Connector 实例并添加到当前 Service 上。

然后,设置 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 里的 和 也是在解析阶段创建并注册的。如果你自定义了 LifecycleListener,它的 lifecycleEvent 方法会在组件初始化和启动时被调用,这是做启动扩展的常用入口。

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。重点看 的 port 属性(默认 8005,是 shutdown 端口)、 的 port 属性(默认 8080,是 HTTP 端口)、 的 appBase 属性(默认 webapps)。这三个地方是最容易出问题的。

第三步,执行启动命令。用 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 的各个模块串起来的,回头看,这一步走得值。