在刚开始接触Spring Boot的时候,我相信绝大多数人跟我一样,第一反应都是:以前用SSM搭项目,要配web.xml、spring.xml、数据源、事务管理器,折腾半天才能跑起来。现在一个main方法加一个@SpringBootApplication,什么外部容器都不用装,直接启动就能访问接口。这背后到底发生了什么?网上讲启动流程的文章不少,但很多要么只贴源码调用链,要么只讲自动配置的原理,很少有人把这套链路里的核心组件串起来讲清楚。
这篇东西我打算换一种讲法,从一次真实的启动过程切入,把“Spring Boot是怎么从main方法变成可以对外提供服务的应用”这件事拆开看,顺便把启动链路里真正在干活的一些组件——自动配置、条件装配、内嵌容器、日志、连接池、健康检查、配置加载——挨个梳理一遍。同时会结合Spring Boot 3.x的新变化和一些实际项目里的排查经验(比如慢启动、循环依赖、日志配置失效这类高频问题)。不管你是刚学Spring Boot的新手,还是已经写了几年业务代码但没仔细研究过启动过程的老手,这份笔记应该都能让你有所收获。
1. 从main方法到应用可用:Spring Boot启动全链路拆解
先说一个很多人忽略的事实:Spring Boot应用的入口其实非常朴素,就是一个普通的main方法,里面只有一句SpringApplication.run(...)。但就是这一句代码,牵起了后面一整套启动链路。
@SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }很多新手会以为@SpringBootApplication只是三个注解的“组合体”,然后就没有然后了。实际上,启动过程可以分为两个阶段:new SpringApplication()是准备阶段,run()才是真正干活的阶段。
1.1 new SpringApplication():启动前的“侦查工作”
第一步是实例化SpringApplication。很多人不知道,这个构造函数里就已经做了一堆关键决定:
- 通过
WebApplicationType.deduceFromClasspath()推断当前应用的类型。它检查classpath里有没有javax.servlet.Servlet或jakarta.servlet.Servlet,以此判断是Servlet Web应用;有没有ReactiveWebApplicationContext相关的类,判断是不是WebFlux响应式应用;两者都没有,就是非Web应用。 - 读取
ApplicationContextInitializer和ApplicationListener。这里涉及SpringFactoriesLoader,它会扫描classpath下所有META-INF/spring.factories或META-INF/spring/...imports文件,把配置的类加载出来。这就是Spring Boot的可扩展机制,也是后面自动配置类能生效的底层通道之一。 - 设置主类来源:通过
main方法栈信息推断包含main方法的类。
这个阶段并不复杂,但决定了后续创建什么类型的ApplicationContext,也决定了哪些初始化器和监听器会被触发。
1.2 run()方法的主干流程
run()方法按时间顺序大概分这么几步:
- 创建
StopWatch并启动计时。这也是为什么你在启动日志里能看到类似Started DemoApplication in 2.34 seconds的原因。 - 通过
SpringApplicationRunListeners广播starting事件。这些监听器本身就是ApplicationListener的子接口,负责在启动的不同节点广播事件。 - 解析并准备
ApplicationArguments,也就是main方法传入的args参数。 - 准备
ConfigurableEnvironment(环境对象),把系统属性、环境变量、application.properties/yml里的配置都加载进来,并广播environmentPrepared事件。 - 打印Banner,Logo是默认的Spring Boot图案,可以用
spring.banner.location替换成自己的。 - 创建
ApplicationContext。根据前面推断出来的Web应用类型,创建AnnotationConfigServletWebServerApplicationContext或AnnotationConfigReactiveWebServerApplicationContext,非Web场景则用AnnotationConfigApplicationContext。 - 上下文准备阶段:设置环境,执行
ApplicationContextInitializer,通过BeanDefinitionLoader把@SpringBootApplication主类变成Bean定义注册进去。 - 刷新上下文:调用
context.refresh()。这一步是启动过程真正的核心,绝大多数自动配置、Bean创建、内嵌Web容器启动都在这里完成。 - 刷新完成后,调用
SmartInitializingSingleton,触发CommandLineRunner、ApplicationRunner,最后广播started事件,返回ConfigurableApplicationContext。
这些步骤里,最值得深挖的是第8步context.refresh(),尤其对于Web应用来说,内嵌Tomcat就是在这一步启动的。
1.3 内嵌Web容器是怎么被拉起来的
内嵌容器的启动逻辑,藏在ServletWebServerApplicationContext里。这个类自己重写了onRefresh(),当Spring容器刷新到一定阶段,它会调用createWebServer(),先通过ServletWebServerFactory拿到一个工厂(Tomcat对应TomcatServletWebServerFactory,Jetty对应JettyServletWebServerFactory),然后调用工厂的getWebServer()创建真正的Web服务器实例。
初学者最容易搞不明白的点是:为什么Spring Boot能自动选到Tomcat?因为默认的spring-boot-starter-web依赖里带了tomcat-embed-core等内嵌Tomcat包,classpath里已经有Tomcat的类了,自动配置类ServletWebServerFactoryAutoConfiguration配合@ConditionalOnClass里的条件,发现类存在,就创建一个ServletWebServerFactory的Bean。如果你换成spring-boot-starter-jetty去掉Tomcat,容器的选择也会跟着变,这就是“约定优于配置”的体现。
另外有件事经常被忽略:内嵌容器的端口、压缩、SSL之类的配置,是在ServletWebServerFactoryAutoConfiguration里通过ServerProperties绑定server.*前缀的配置项实现的。所以你在application.yml里写server.port=8081,最终影响的是TomcatServletWebServerFactory里的port属性。
2. 自动配置机制:启动背后那套“按需装配”是怎么实现的
启动链路的下一步,就是Spring Boot最核心的设计——自动配置。说直白点,它解决的是“少写配置甚至不写配置也能把项目跑起来”的问题。但自动配置并没有传说中那么神秘,拆开看就是“扫描配置文件里的类名 + 一堆条件注解 + 注册Bean”。
2.1 @SpringBootApplication的三合一
@SpringBootApplication本质上由三个注解组成:
@SpringBootConfiguration:表示当前类是配置类,底层是@Configuration。@EnableAutoConfiguration:开启自动配置,这是所有自动配置的入口。@ComponentScan:组件扫描。
这里有个特别容易踩的坑:@ComponentScan默认扫描的是@SpringBootApplication所在类的包及其子包。所以如果你的主类放在com.example,而某个配置类放在com.example.config,没问题;但如果放在com.other,就扫不到了。很多“为什么我的Bean没有注册”“为什么自动配置没生效”的问题,根因都是这个。
@EnableAutoConfiguration则通过@Import(AutoConfigurationImportSelector.class)引入了一个“自动配置导入选择器”。这个选择器的同名方法会去加载一批自动配置类的全限定名列表,然后交给Spring容器。
2.2 自动配置类是怎么被找到的
Spring Boot 2.x及更早版本,自动配置类类名写在META-INF/spring.factories文件里,内容大致是这样的:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.example.MyAutoConfiguration,\ ...Spring Boot 2.7之后,官方引入了新的加载机制,自动配置类名迁移到了META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件里,一个类名一行,不再使用Key=Value这种形式。到了Spring Boot 3.x,老方式基本被移除了。
为什么要换?老方式的问题在于:spring.factories文件同时承载了太多不同用途的配置(监听器、初始化器、自动配置类混在一起),而且Spring Boot框架本身的jar包和第三方jar包的spring.factories都会被打包读取,加载过程不够清晰。独立的AutoConfiguration.imports文件既方便框架扫描,也方便开发者调试。
2.3 @Conditional条件装配:自动配置的“决策大脑”
光有自动配置类还不够,必须得有条件判断,否则引入一个Redis依赖,结果没配连接参数也强行建连接池,应用岂不是要启动失败?Spring Boot提供了一组@Conditional注解来负责这件事,常用的有这么几种:
| 注解 | 判定逻辑 | 典型使用场景 |
|---|---|---|
@ConditionalOnClass/@ConditionalOnMissingClass | classpath里存在/不存在指定类 | 判断是否引入相关依赖 |
@ConditionalOnProperty | 配置项是否存在、是否为指定值 | 按application.yml开关装配 |
@ConditionalOnBean/@ConditionalOnMissingBean | 容器里是否已经存在指定Bean | 用户自定义Bean优先级更高 |
@ConditionalOnWebApplication | 是否Web应用 | Web专用配置 |
@ConditionalOnExpression | SpEL表达式结果 | 复杂逻辑判断 |
以DataSourceAutoConfiguration为例,它上面标着@ConditionalOnClass(DataSource.class)和@ConditionalOnMissingBean(type = "io.r2dbc.spi.ConnectionFactory"),意思就是:当classpath里有DataSource类,而且容器里还没有ConnectionFactory时,才进行数据源相关的自动配置。这里的注解组合,确保了用户如果自己定义了DataSource,自动配置就不会覆盖掉。
可以说,自动配置类像一本“菜单”,每个菜后面都写了“什么时候上这个菜”,而@Conditional系列注解就是那个决定上不上菜的厨师。
2.4 自定义一个Starter:从写代码到被自动装配
明白了自动配置原理,自己写一个Starter就很简单了。常规套路是拆成两个模块:
xxx-spring-boot-starter:空依赖模块,主要把自动配置模块和对应功能模块的依赖引过来。xxx-spring-boot-autoconfigure:真正写自动配置类和业务代码的地方。
自动配置类写法大致是:
@AutoConfiguration @ConditionalOnClass(MyService.class) @ConditionalOnProperty(prefix = "my", name = "enabled", havingValue = "true", matchIfMissing = true) @EnableConfigurationProperties(MyProperties.class) public class MyServiceAutoConfiguration { @Bean @ConditionalOnMissingBean public MyService myService(MyProperties properties) { return new MyService(properties.getPrefix()); } }然后在META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports里写一行自动配置类全限定名。这样使用者只要引入Starter,配置好my.*前缀的参数,就能直接用MyService了。
这里分享一个经验:自定义自动配置类一定要想清楚“什么时候该启用、什么时候该退让”。比如@ConditionalOnMissingBean就是给用户留后门,让用户能用自己的实现覆盖你的默认实现;@ConditionalOnProperty负责给用户一个总开关。这两层缺一不可,否则你的Starter在真实项目里很容易跟用户自己的Bean打架。
3. 围绕启动链路的相关组件清单与选型建议
自动配置机制本身是骨架,真正让一个应用具备生产能力的,是围绕启动链路工作的那些组件。这一节我把它们按启动过程中的角色理一遍,也顺便回答一下“这些组件到底是怎么被启动起来的”这个问题。
3.1 配置管理:yml/properties的加载顺序与配置优先级
Environment对象在启动早期就被准备好,但它不是一次性把所有配置都读进来的,而是按照优先级从高到低依次叠加。整体优先级大概是:
- 命令行参数(比如
--server.port=8082) SPRING_APPLICATION_JSON环境变量里的JSON配置- Servlet Web应用参数、JNDI属性(如果有)
- Java系统属性(
System.getProperties()) - 操作系统环境变量
application-{profile}.yml/application-{profile}.properties- 不带profile的
application.yml/application.properties @PropertySource注解加载的配置
这里面有个特别容易忽略的点:application.yml里的配置其实优先级并不高,命令行参数和环境变量反而能直接覆盖它。很多团队在部署时希望不用改包里的配置文件,就是利用这个特性。
至于多环境配置,application-{profile}.yml配合spring.profiles.active是标配。Spring Boot 3.x里spring.profiles相关的配置名有所调整,比如spring.profiles.group可以组合多个profile,用起来方便不少。
我顺手提一下WebSocket的yml配置问题,因为从热搜词看问的人特别多。WebSocket在Spring Boot里的配置通常分成两块:一是server容器层面不一定需要额外配置,二是握手拦截器、消息代理等在配置类里写。比如STOMP风格的WebSocket,需要在配置类里@EnableWebSocketMessageBroker并实现WebSocketMessageBrokerConfigurer,yml里配置的其实更多是spring.web.websocket相关的消息代理端点。
3.2 日志组件:Logback在启动过程中“上岗”的时间点
Spring Boot默认使用Logback作为日志实现,这一点通过spring-boot-starter-logging默默引入。日志系统的初始化在整个启动周期里比很多人想象的要早——实际上在SpringApplication.run()早期,还没创建ApplicationContext之前,日志系统就通过LoggingApplicationListener初始化好了。这意味着你在启动早期阶段就已经能看到日志输出。
配置方式上,application.yml里logging.level、logging.file.name等配置项会被绑定到LoggingApplicationListener,由它来控制Logback的初始化。如果你想用完全自定义的日志策略,官方推荐把配置文件命名为logback-spring.xml而不是logback.xml。原因在于logback.xml在Spring环境完全准备好之前就被Logback原生的初始化机制读走了,很多Spring属性扩展(比如${log.path}占位符)在logback-spring.xml里才能正确解析。
这个坑我踩过不止一次。以前为了省事直接在logback.xml里用${LOG_PATH},结果某些环境变量取不到,日志文件路径一直是相对路径。换成logback-spring.xml并配合springProperty标签,问题就消失了。
3.3 连接池、缓存、对象存储等常用组件与启动的关系
先说连接池。Spring Boot官方默认的数据源连接池是HikariCP,它通过DataSourceAutoConfiguration自动装配。默认情况下,只要你的数据源依赖里只包含HikariCP,自动配置就会创建一个HikariDataSource,并把spring.datasource.hikari.*配置绑定上去。HikariCP的初始化并不等同于真正建立数据库连接,它是懒初始化的,第一次getConnection()时才真正连库。
缓存组件里,Caffeine是当前很热门的本地缓存实现。Spring Boot对缓存的自动配置思路一致:classpath里有Caffeine依赖,且主配置类带有@EnableCaching,就会创建CaffeineCacheManager。配置主要靠spring.cache.caffeine.spec,比如写maximumSize=500,expireAfterWrite=5m就能定义缓存容量和过期策略。
对象存储MinIO的集成虽然不属于Spring Boot核心自动配置,但它可以作为一套很好的“自定义组件接入”范例。大家在热搜词里看到很多“Spring Boot集成MinIO”的需求,本质就是:启动时创建一个MinioClient的Bean,这个Bean依赖spring.minio.endpoint、spring.minio.access-key这些配置项。如果MinIO服务没启动,注意不要用@PostConstruct强制做连通性测试——那会导致整个应用启动失败。更好的做法是提供一个单独的健康检查接口,或者在真正上传文件时才建立连接,保证本地开发时MinIO不存在也不影响应用启动。
3.4 接口与通信组件选型:WebSocket、gRPC、OpenFeign
这一块在热搜词里也占了不少比重,尤其是“Spring Boot集成WebSocket的yml配置”和“gRPC协议与Spring Boot”这两个。我的看法是:WebSocket适合服务端主动推送或长连接通信,比如在线协作、公告实时推送、校园讲座预约系统的座位动态等;gRPC适合内部服务之间的高性能RPC调用,有严格的接口定义和多语言互通需求;OpenFeign则是Spring Cloud体系里服务间HTTP调用的标配。
如果你在Spring Boot 3.x里用OpenFeign,要特别注意版本对应关系。比如io.github.openfeign.querydsl这个扩展,它依赖OpenFeign的版本以及Spring Cloud中spring-cloud-openfeign的版本,三者的兼容矩阵不同。从经验看,Spring Boot 3.2/3.3配合Spring Cloud 2023.x时,OpenFeign的常用版本是4.x,querydsl相关的集成模块更新节奏相对较慢,建议直接看它的GitHub Releases确认支持范围,不要凭感觉选版本。
4. Spring Boot 3.x时代的变化:迁移要点与新特性落地
很多项目还停留在Spring Boot 2.7,而Spring Boot 3.x已经成了新项目的默认选择。从启动原理角度来说,3.x并没有推翻之前那套机制,但它有几处底层变化会直接影响到你手里的项目,这里重点说几个跟“启动和组件”强相关的点。
4.1 Java 17+与Jakarta EE的“硬性升级”
Spring Boot 3.0起基线是Java 17和Spring Framework 6,并且把原来的javax.*命名空间迁移到了jakarta.*。这对老项目的冲击是“硬性”的:你代码里所有import javax.servlet.*、javax.persistence.*都要改成jakarta.*。
我见过最难受的迁移是项目中引用的第三方库还停留在旧命名空间,比如一些老牌工具包仍然编译在javax.servlet之上,这会导致启动时报NoClassDefFoundError或ClassNotFoundException。解决思路只有一个:等对应组件出新版本,或者替换实现。所以在决定升级之前,先盘点中间件依赖的适配情况,比盲目升级靠谱得多。
自动配置本身在这轮变化里也有调整,比如AutoConfiguration.imports就是2.7引入、3.x正式定型的。如果你维护自己的Starter,升级3.x时必须同步迁移。
4.2 Java 21虚拟线程在Spring Boot 3.5里的启用方式
Java 21正式发布了虚拟线程,Spring Boot 3.2开始提供支持,到了3.5这版,支持变得更成熟。启用方式非常简单,在application.yml里配置:
spring: threads: virtual: enabled: true开启后,Tomcat处理HTTP请求的线程池会换用虚拟线程,@Async异步方法也会使用虚拟线程。带来的直观感受是高并发场景下线程资源占用明显下降,因为虚拟线程的创建成本远低于平台线程。
但这里有一条红线:如果你的代码里有大量阻塞式JDBC调用、synchronized块或本地调用(JNI),虚拟线程的收益会大打折扣。虚拟线程适合IO密集场景,但碰上CPU密集计算并不会自动变快。另外,老代码里一些依赖“线程本地变量”的逻辑要小心,虚拟线程大量创建后,线程本地变量的使用需要重新审视。
4.3 Spring Security配置迁移
Spring Boot 3.x把Spring Security 5.7之后逐步废弃的WebSecurityConfigurerAdapter正式移除了,新的写法是基于SecurityFilterChain的Bean注册。很多从2.x迁移的同学会在这里迷茫,其实核心变化就一句话:以前是继承一个适配器类并重写方法,现在改成在配置类里手动声明一个SecurityFilterChainBean。
旧写法:
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/public/**").permitAll() .anyRequest().authenticated(); } }新写法:
@Configuration public class SecurityConfig { @Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth -> auth .requestMatchers("/public/**").permitAll() .anyRequest().authenticated()); return http.build(); } }新旧写法最大的差异在“Lambda DSL”上,也就是那种流式配置方式。如果你在迁移中遇到“方法找不到”“配置不生效”这类问题,多半是最老的一套authorizeRequests/antMatchers还残留在项目里,需要系统性清理。
4.4 Boot在微服务体系里的位置:与Spring Cloud的协作
讨论启动原理时,应当把Spring Boot和Spring Cloud区分开:Spring Boot负责“单个应用怎么启动”,Spring Cloud负责“一批服务怎么协同”。同一套启动链路之上,Spring Cloud的各个组件通过自动配置或者自定义注解加入自己的Bean。
比如服务注册发现依赖spring-cloud-starter-alibaba-nacos-discovery,启动时应用会额外注册一个NacosDiscoveryClient的Bean,并跟DiscoveryClient接口的实现产生关联。配置中心的原理也类似,NacosConfigManager会在环境准备阶段拿到远端配置,并参与到Environment的合并。
再比如工作流引擎Deer-flow这类第三方组件的接入,本质上也是基于Spring Boot的启动扩展点:要么自己写@Configuration导入相关Bean,要么提供一个Starter。所以搞懂启动原理,最大的实际收益就是:你不至于在一个新组件面前完全无从下手,甚至能靠自己判断它能不能被Boot管起来。
5. 启动排查实战:慢启动、循环依赖和日志问题
原理讲多了容易飘,回到实操。这一节记录的几个问题,是我在实际项目里真真切切遇到过、而且几乎每个Spring Boot项目都可能碰到的。排查思路比最终答案更重要,所以我把链路写出来。
5.1 启动慢:从日志时间线定位瓶颈
很多人遇到启动慢的第一反应是“机器不行”“中间件太多”,但负责任的做法是先看到底慢在哪一步。Spring Boot启动日志本身就有时间信息,你可以大致估算各阶段耗时。更高阶一点的做法是开启启动阶段指标:
- 在
application.yml里配置spring.datasource.hikari.initialization-fail-timeout这类参数,排除连接池初始化问题。 - 审查
@ComponentScan扫描范围。默认扫描主类所在包及其子包时,如果项目有很多无关的老代码包也堆在同一个主包下,启动时Beans的候选集就会异常庞大。 - 检查是否有大量
@Configuration类在启动阶段执行了耗时操作(比如@PostConstruct里做网络调用或资源预热)。这是慢启动最常见的元凶之一。
启动时连接外部中间件的超时时间也需要关注。比如MinIO不存在、Redis密码错误这类问题,如果连接池或客户端没有设置较短的超时时间,启动会被拖得很久。建议中间件客户端的连接超时都显式配置一下,不要全依赖默认值。
5.2 Bean注入控制与循环依赖
网上关于循环依赖的讨论很多,Spring Boot 3.x默认里三级缓存依然存在,但官方已经不太鼓励依赖它解决循环依赖了。原因在于,循环依赖会推迟Bean的完整初始化,可能让@PostConstruct这类逻辑被跳过或产生难以预期的对象状态。
我自己处理过的一个典型场景:两个Service互相调用,早期代码用了字段注入:
@Service public class OrderService { @Autowired private UserService userService; } @Service public class UserService { @Autowired private OrderService orderService; }这种写法在Spring Boot里默认能启动成功,因为三级缓存会提前暴露一个半成品Bean。但如果你把其中一个注入改成构造器注入:
@Service public class OrderService { private final UserService userService; public OrderService(UserService userService) { this.userService = userService; } }而UserService仍然依赖OrderService,启动会立刻报The dependencies of some of the beans in the application context form a cycle。
为什么推荐构造器注入?因为它能保证Bean在创建阶段就是完整依赖、不可变的,而不是先new一个对象再靠反射塞字段。如果你在维护老代码,实在改不动构造器,可以用@Lazy注入给其中一个依赖做延迟加载,或者把真正互相依赖的逻辑抽成一个新服务,从结构上打破环。这比靠三级缓存兜底干净得多。
5.3 日志配置失效的常见原因
日志相关的问题在热搜词里也占了一条:“spring boot日志”。我总结过几个高频翻车点:
logback.xml放在src/main/resources下,但用了Spring扩展属性(springProperty),结果变量解析不到。解决:改名为logback-spring.xml。- 已经配置了
logging.level.com.example=DEBUG,但控制台还是看不到DEBUG输出。检查是不是没有配置对应的appender级别。logging.level设置的是Logger级别,如果根Logger或对应appender的ThresholdFilter级别比它高,日志照样被过滤掉。 - 引入了
spring-boot-starter-logging,但又手动添加了log4j依赖,导致日志绑定冲突,启动时出现SLF4J的警告。这种情况要么统一Logback,要么显式排除某个依赖。
另有一个和启动相关的细节:如果你的项目里有多个logback.xml在classpath上(比如依赖了一个老jar包,它里面也带了一份),Logback初始化时会按classpath:logback.xml查找第一个找到的文件,实际生效的可能是那个老jar包里的配置。遇到这种问题,建议打开启动日志最前面的调试信息,或者在application.yml里显式指定logging.config: classpath:logback-spring.xml来锁定配置文件。
最后再分享一个小习惯:每次写完一个Spring Boot应用,第一次启动时先开--debug参数看一眼自动配置报告,里面会列出哪些自动配置类生效、哪些因为条件不匹配被排除。这一份报告比任何源码分析都直观,能帮你快速确认“某个组件到底有没有被启动链路接纳”。排查启动类问题,先看这份报告,再往下钻,效率高很多。