《Spring Boot 入门》3.2 内嵌服务器与启动流程

对比传统 war 部署与内嵌 Tomcat,拆解 SpringApplication.run() 的环境准备、容器创建、Bean 加载与服务器启动四步,逐行解读 4.1.1 真实启动日志,并给出端口占用、Bean 创建失败、依赖缺失三类常见启动问题的日志特征与排查方法。

本节目标:理解内嵌 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 依赖。

这条链路有三个痛点:

  1. 运行环境是外部依赖。服务器上必须先有正确版本的 Tomcat,版本不一致时行为会变。
  2. 启动入口在容器手里。你的代码没有 main 方法,是 Tomcat 在合适的时机调用你的 Servlet,调试时很难断到入口。
  3. 部署步骤重。改一次代码就要重新打包、拷贝、重启,反馈慢。

Spring Boot 的做法是反过来:把 Tomcat 当作一个普通依赖打进应用,由应用自己 main 方法启动它。这就是「内嵌服务器」。

内嵌服务器意味着什么

维度传统 war + 外部 TomcatSpring 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() 做的事情可以粗略分成四个阶段,它们与启动日志的顺序一一对应:

  1. 环境准备。创建 Environment,读取 application.properties/application.yml、命令行参数、系统环境变量,并确定激活的 profile。这一步产出的就是你后面能用 @Value 读到的那些配置。
  2. 创建应用上下文。根据应用类型(Servlet / Reactive)创建对应的 ApplicationContext,对 Web 应用就是 ServletWebServerApplicationContext。
  3. 加载 Bean 与自动配置。扫描启动类所在包,注册 Bean,执行自动配置;这个过程完成后,你的 HelloController 才真正存在于容器里。
  4. 启动内嵌服务器。创建并启动 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.TomcatWebServero.s.boot.tomcat.TomcatWebServer
优雅停机o.s.b.w.e.tomcat.GracefulShutdowno.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 确认依赖是否真的存在。

排查顺序建议

遇到启动失败时,按这个顺序看日志,通常一次就能定位:

  1. 先找 APPLICATION FAILED TO START 这一段,它的 Description 往往已经写明原因;
  2. 找不到就看有没有异常堆栈,最内层的 Caused by 才是根因;
  3. 都没有,再按日志停在「四步」的哪一步反推:停在 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计