本节目标:理解内嵌 Tomcat 与传统 war + 外部容器的区别,拆解
SpringApplication.run()的四个阶段,逐行读懂 4.1.1 的真实启动日志,会改端口与 context-path,并能根据日志判断三类最常见的启动失败。
适用版本:Spring Boot 4.1.x(Java 21)
一个 war 包的时代
在 Spring Boot 之前,一个 Java Web 应用的发布流程大致是这样的:用 Maven 打成 war 包,把 war 拷到已经装好的 Tomcat 的 webapps/ 目录下,重启 Tomcat,Tomcat 再把 war 解压、加载里面的 web.xml 和 WEB-INF/lib 依赖。
这条链路有三个痛点:
- 运行环境是外部依赖。服务器上必须先有正确版本的 Tomcat,版本不一致时行为会变。
- 启动入口在容器手里。你的代码没有
main方法,是 Tomcat 在合适的时机调用你的Servlet,调试时很难断到入口。 - 部署步骤重。改一次代码就要重新打包、拷贝、重启,反馈慢。
Spring Boot 的做法是反过来:把 Tomcat 当作一个普通依赖打进应用,由应用自己 main 方法启动它。这就是「内嵌服务器」。
内嵌服务器意味着什么
| 维度 | 传统 war + 外部 Tomcat | Spring Boot 内嵌服务器 |
|---|---|---|
| 服务器从哪来 | 服务器上预装 | 作为 jar 依赖随应用发布 |
| 启动入口 | 容器调 Servlet | 应用的 main 方法 |
| 产物 | war | 可执行 jar(java -jar) |
| 版本控制 | 运维负责 | 由 pom.xml 锁定 |
| 本地运行 | 先装 Tomcat | 直接运行 main |
代价是每个应用都带一份服务器,包更大;收益是「构建产物即运行环境」,从开发到生产的差异被压到最小。第 3.3 节会看到,这个可执行 jar 正是为这种「自带一切」的模型设计的。
内嵌服务器可以换吗
可以。Spring Boot 默认用 Tomcat,但只要把对应的 starter 换掉,就能切换成 Jetty 或 Reactor Netty。4.x 的依赖大版本是 Tomcat 11.0、Jetty 12.1,都基于 Servlet 6.1 / Jakarta EE 11。
需要注意两点:一是 4.0 起 Undertow 已被移除(它不兼容 Servlet 6.1),不要再选它;二是切换服务器通常只需在 pom.xml 里排除默认 starter 再引入目标 starter,日常入门阶段用默认的 Tomcat 即可。
SpringApplication.run() 到底做了什么
第 2 章生成的 DemoApplication 里那一行,是整个应用的起点:
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
SpringApplication.run() 做的事情可以粗略分成四个阶段,它们与启动日志的顺序一一对应:
- 环境准备。创建
Environment,读取application.properties/application.yml、命令行参数、系统环境变量,并确定激活的 profile。这一步产出的就是你后面能用@Value读到的那些配置。 - 创建应用上下文。根据应用类型(Servlet / Reactive)创建对应的
ApplicationContext,对 Web 应用就是ServletWebServerApplicationContext。 - 加载 Bean 与自动配置。扫描启动类所在包,注册 Bean,执行自动配置;这个过程完成后,你的
HelloController才真正存在于容器里。 - 启动内嵌服务器。创建并启动 Tomcat,把它绑定到配置的端口与 context path 上。到这一步,应用才真正能接收请求。
理解这四步的价值在于:当启动卡住或失败时,你能根据「日志停在哪一步」快速缩小范围。
逐行读懂 4.1.1 的真实启动日志
下面是一段在 Spring Boot 4.1.1 + Java 21 上实测得到的启动日志(项目名为 probe,依赖与我们的 demo 完全相同):
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v4.1.1)
2026-10-09T15:42:06.435+08:00 INFO 43496 --- [ main] com.example.probe.ProbeApplication : Starting ProbeApplication v0.0.1-SNAPSHOT using Java 21.0.12.1 with PID 43496
2026-10-09T15:42:06.436+08:00 INFO 43496 --- [ main] com.example.probe.ProbeApplication : No active profile set, falling back to 1 default profile: "default"
2026-10-09T15:42:07.027+08:00 INFO 43496 --- [ main] o.s.boot.tomcat.TomcatWebServer : Tomcat initialized with port 8080 (http)
2026-10-09T15:42:07.039+08:00 INFO 43496 --- [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2026-10-09T15:42:07.039+08:00 INFO 43496 --- [ main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/11.0.24]
2026-10-09T15:42:07.059+08:00 INFO 43496 --- [ main] b.w.c.s.WebApplicationContextInitializer : Root WebApplicationContext: initialization completed in 589 ms
2026-10-09T15:42:07.285+08:00 INFO 43496 --- [ main] o.s.boot.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path '/'
2026-10-09T15:42:07.421+08:00 INFO 43496 --- [ main] com.example.probe.ProbeApplication : Started ProbeApplication in 1.101 seconds (process running for 1.749)
2026-10-09T15:42:07.840+08:00 INFO 43496 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 0 ms
2026-10-09T15:42:08.968+08:00 INFO 43496 --- [ionShutdownHook] o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
2026-10-09T15:42:09.015+08:00 INFO 43496 --- [tomcat-shutdown] o.s.boot.tomcat.GracefulShutdown : Graceful shutdown complete
逐行解读(忽略前缀的 ASCII 横幅和版本行,它们是纯装饰):
Starting ProbeApplication v0.0.1-SNAPSHOT using Java 21.0.12.1 with PID 43496:阶段一的起点。日志里带着应用名、版本、JDK 版本和进程号,排查「跑的是哪个 jar」时非常有用。No active profile set, falling back to 1 default profile: "default":没有指定 profile,回退到default。这解释了为什么application.properties里的值能生效——它就是 default profile 的配置。o.s.boot.tomcat.TomcatWebServer : Tomcat initialized with port 8080 (http):阶段四的开始。Tomcat 实例被创建并初始化到 8080 端口。注意包名o.s.boot.tomcat,这是 4.x 的新位置。o.apache.catalina.core.StandardService : Starting service [Tomcat]与Starting Servlet engine: [Apache Tomcat/11.0.24]:这是 Tomcat 自己打的日志,暴露了内嵌版本号 11.0.24。要确认内嵌服务器版本,看这一行。Root WebApplicationContext: initialization completed in 589 ms:阶段三完成。Bean 与自动配置都加载完毕,用时 589 毫秒。Tomcat started on port 8080 (http) with context path '/':Tomcat 正式监听 8080,根路径/。这一行出现后,接口才可访问。Started ProbeApplication in 1.101 seconds (process running for 1.749):应用启动完成,从main到此处耗时约 1.1 秒。启动慢时,优先优化这一行之前的时间。Completed initialization in 0 ms:线程名变成nio-8080-exec-1,说明这是处理第一个请求时才初始化的DispatcherServlet——Spring Boot 默认延迟到首次请求才初始化它。- 最后两行
Commencing graceful shutdown/Graceful shutdown complete:按下Ctrl+C后触发的优雅停机,等待在途请求结束再关闭。
这段日志来自与
demo依赖完全相同的最小工程;把类名换成DemoApplication后,你的日志结构与此逐行一致。
4.x 模块化的日志证据:包名变了
日志里的包名是「4.x 模块化重构」最直接的证据。对照 3.x:
| 组件 | 3.x 包名 | 4.x 包名 |
|---|---|---|
| Tomcat 启动器 | o.s.b.w.embedded.tomcat.TomcatWebServer | o.s.boot.tomcat.TomcatWebServer |
| 优雅停机 | o.s.b.w.e.tomcat.GracefulShutdown | o.s.boot.tomcat.GracefulShutdown |
根因是 4.0 把框架按技术拆成了独立模块:模块名 spring-boot-<technology>,根包 org.springframework.boot.<technology>。Tomcat 支持被收进 spring-boot-tomcat 模块,包名自然就变成了 org.springframework.boot.tomcat。日志里的 o.s.boot.tomcat 正是它的缩写。
这条规律对排查问题很有用:看到 o.s.boot.<x>,就知道它属于某个独立模块;如果某个类在 3.x 的 o.s.b.w.* 里找不到,先想想它在 4.x 是不是搬到了 o.s.boot.<x>。
改端口与 context-path
默认端口 8080 经常被占用。三种改法,优先级从低到高:
application.properties:
server.port=9090
server.servlet.context-path=/api
application.yml 等价写法:
server:
port: 9090
servlet:
context-path: /api
命令行参数(优先级最高,适合临时覆盖):
java -jar demo-0.0.1-SNAPSHOT.jar --server.port=9090
改完后日志会变成 Tomcat started on port 9090 (http) with context path '/api',接口地址也随之变为 http://localhost:9090/api/hello。
还有一个实用技巧:把端口设为 0,让操作系统分配一个随机空闲端口。启动日志里会打印实际使用的端口,这在本机同时跑多个实例、或写集成测试时非常方便。
启动失败最常见的三类原因
启动失败不可怕,可怕的是不会读日志。下面三类覆盖了绝大多数情况。
一、端口被占用
日志特征(通常在阶段四):
APPLICATION FAILED TO START
Description:
Web server failed to start. Port 8080 was already in use.
Action:
Identify and stop the process that's listening on port 8080 or configure this
application to listen on another port.
原因是 8080 上已有进程(另一个实例、或别的服务)。解决办法:停掉占用进程,或换端口(见上一节)。排查占用者可用 lsof -i :8080(macOS / Linux)或 netstat -ano | findstr :8080(Windows)。
二、Bean 创建失败
日志特征(通常停在阶段三):
***************************
APPLICATION FAILED TO START
***************************
Description:
Parameter 0 of constructor in com.example.demo.OrderService required a bean of type
'com.example.demo.PaymentClient' that could not be found.
关键线索是 required a bean of type ... that could not be found——某个类要注入的依赖没被注册。常见原因是漏了 @Service / @Component,或那个类不在扫描范围内。Spring Boot 还会打印 Action 段落给出建议,照做通常能解决。
三、依赖缺失
日志特征(可能在启动早期就抛出,甚至出现在 banner 之前):
java.lang.NoClassDefFoundError: org/springframework/boot/autoconfigure/SpringBootApplication
at com.example.demo.DemoApplication.main(DemoApplication.class:8)
Caused by: java.lang.ClassNotFoundException: ...
NoClassDefFoundError / ClassNotFoundException / NoSuchMethodError 都指向同一件事:运行时 classpath 里缺东西。常见于手动用 java -cp 启动、依赖没被打进 jar、或版本冲突。先用 mvn dependency:tree 确认依赖是否真的存在。
排查顺序建议
遇到启动失败时,按这个顺序看日志,通常一次就能定位:
- 先找
APPLICATION FAILED TO START这一段,它的Description往往已经写明原因; - 找不到就看有没有异常堆栈,最内层的
Caused by才是根因; - 都没有,再按日志停在「四步」的哪一步反推:停在 Bean 加载多半是配置问题,停在服务器启动多半是端口问题。
优雅停机
日志末尾的 GracefulShutdown 不是装饰。收到 SIGTERM(Ctrl+C 或 kill)后,Spring Boot 会先停止接收新请求,等待正在处理的请求完成,再关闭 Tomcat。这对「正在下单却被打断」的场景至关重要。
默认的优雅停机有一个宽限期(server.shutdown.grace-period,默认 30 秒),超时后强制关闭。开发时如果发现 Ctrl+C 后要等一会儿才退出,就是这个宽限期在起作用。
让启动更快:惰性初始化
启动日志里 initialization completed in ... ms 那段,主要花在创建 Bean 上。如果本地开发时嫌启动慢,可以打开惰性初始化:
spring.main.lazy-initialization=true
开启后,Bean 只在第一次被用到时才创建,启动会明显变快。但它是一把双刃剑:错误会被推迟到运行时才暴露——本来启动就该失败的配置问题,会变成某个请求上的 500。所以它适合本地开发,不建议在生产环境默认打开。
如何确认「服务已经就绪」
日志出现 Tomcat started on port 8080 只是「端口在监听」,不等于「应用能正常处理请求」。区分这两个概念,对写启动脚本和容器探针都很重要:
- 端口在监听:Tomcat 已绑定端口,对应上面那行日志。
- 应用已就绪:所有 Bean 创建完毕、自动配置完成,对应
Started ...Application in ...那行。
在容器编排里,这两个状态分别对应 liveness 和 readiness 探针。4.x 默认启用了健康探针端点,后续章节会展开。当下你只需要记住:以 Started 那行为准,而不是以端口行为准。
小结
内嵌服务器的本质,是把「运行环境」从运维手里收回到构建产物里;SpringApplication.run() 则把启动拆成环境准备、容器创建、Bean 加载、启动服务器四步,启动日志的顺序与这四步严格对应。读懂日志里的包名(o.s.boot.tomcat)能确认 4.x 的模块化位置,读懂 Tomcat started on port ... 能确认服务就绪,读懂 APPLICATION FAILED TO START 后的 Description 能定位绝大多数启动失败。改端口与 context-path 的三种途径、以及优雅停机的宽限期,都是日常会反复用到的细节。
既然应用能跑起来了,下一个问题就是:怎么把它打包成一个能直接发出去的产物?
阅读导航:上一节:3.1 写第一个 REST 接口 · 下一节:3.3 打包成可执行 jar 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。