news 2026/11/2 5:42:09

SpringBoot启动流程详解:run方法、自动配置与内嵌容器

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot启动流程详解:run方法、自动配置与内嵌容器

1. 为什么我建议你花一个下午读懂 SpringBoot 启动流程

不少人在项目里用了两三年 SpringBoot,写 Controller、写 Service、配 application.yml 都很熟,但一旦碰上"启动卡住不动""Bean 找不到""自动配置没生效""启动要 40 秒"这类问题,就只能靠猜、靠搜、靠一层层加日志试。我自己早年也是这样,直到有一次线上服务重启后迟迟不探活成功,日志停在某个 banner 之后一片空白,排查了大半天才发现是某个初始化器里做了一次远程调用。那次之后我花了一个完整的周末,把 SpringApplication 的 run 方法从上到下逐行读了一遍,很多原本模糊的东西一下就串起来了。

这篇内容就是把那次阅读和后续几年的踩坑整理出来。核心关键词是 SpringBoot 启动原理,涉及的内容包括 SpringApplication 的构造过程、run 方法的完整主干、Environment 的准备、BeanDefinition 的加载、refresh 的十二道工序、自动配置的触发链路、内嵌 Web 服务器的创建时机。它不解决某一个具体的业务问题,而是解决"你看不懂启动日志、定位不了启动异常、优化不了启动速度"这一类问题。适合两类人:一是已经能写业务代码、想往深处走一步的中级开发者;二是团队里负责技术选型、要给新人做分享、需要给启动优化做决策的人。零基础也不用怕,我会尽量用生活化的类比把每一步的意图讲清楚,再把可以照着做的命令、参数和排查手法列出来。

我个人的判断标准很简单:能不能对着启动日志大致说出"现在跑到哪一步了"。如果能,你就已经跨过了大多数人的门槛;如果还不能,那读完这篇应该就差不多了。

1.1 三个真实场景引出的困惑

先摆三个我自己遇到过的场景,你看看有没有共鸣。

第一个场景,一个新同事在 Service 构造器里注入了一个 Mapper,然后又在构造器里调用了 Mapper 的查询方法,报错信息是代理对象还没准备好。他觉得很冤枉:"我明明注入了啊。" 这背后其实是 Bean 的生命周期和依赖注入顺序问题,而依赖注入顺序又完全由容器刷新阶段的finishBeanFactoryInitialization决定。

第二个场景,某个自动配置类的@ConditionalOnMissingBean不生效,明明自己写了一个同名 Bean,结果还是被自动配置的默认实现覆盖了。这类问题几乎全部指向"配置类的解析顺序"和"条件求值时机",跟你写没写注解没关系,跟你写在哪个类里、谁先被解析有直接关系。

第三个场景,某个服务启动耗时从 12 秒涨到 35 秒,没有任何报错,就是慢。后来用 startup 端点抓了一条时间线,发现是某个 starter 在Environment准备阶段读了大量远程配置,还有一批 Bean 在预实例化阶段做了网络探测。

这三个场景对应启动流程里的三个不同的阶段。你只要知道"哪个阶段干什么",定位问题的效率会发生质的变化。

1.2 先建立一张三段式的心智地图

在读具体代码之前,我先给你一个粗粒度的心智模型,把它记在脑子里,后面所有的细节都往这个架子上挂。

整个启动过程可以粗暴地分成三段:准备阶段、装配阶段、刷新阶段。

准备阶段做的是"环境"和"元信息"的活:解析命令行参数、加载配置文件、决定这是不是一个 Web 应用、准备好事件监听器和初始化器。这一段的特点是还没有任何业务 Bean 被创建,所有出问题几乎都和外部资源有关,比如配置文件格式、占位符解析、远程配置中心连接。

装配阶段做的是"登记"的活:把主配置类注册成 BeanDefinition、把自动配置类解析出来、把条件注解求值一遍、把该留的留下、该排除的排除。这一段的关键词是BeanDefinition,注意是"定义"不是"实例",此时 Bean 还没有 new 出来。

刷新阶段做的是"生产"的活:真正 new 对象、注入依赖、调初始化方法、启动内嵌容器、发布就绪事件。绝大多数"Bean 相关"的报错都发生在这里。

用一个生活化的类比:准备阶段是买菜和看菜谱,装配阶段是把菜谱写进菜单、决定今天做哪几道,刷新阶段才是真正下锅。你在厨房里闻到糊味,回头找原因,得先搞清楚是哪一步出的问题——是菜谱抄错了(配置错误),还是菜单漏了菜(Bean 定义缺失),还是火候没控制好(初始化顺序问题)。

后面几节,我会按照这个三段式,把每一段的代码细节、参数含义和踩坑点拆开讲。

2. 从 new SpringApplication 到 run 方法之前

很多人以为SpringApplication.run(Application.class, args)这一行就是全部,其实这一行内部先 new 了一个SpringApplication对象,再调用它的 run 方法。这个构造过程本身就包含了不少关键动作,而且里面有一个非常容易被忽略的点:构造器里会去加载spring.factories(新版是.imports文件)里的初始化器和监听器,如果你的类路径里有第三方的 starter 在这里加了东西,它会在这个时刻就被实例化。

2.1 构造器里的五行关键代码

抛开一些防御性判断,SpringApplication的构造器做的事情可以概括成下面这几步,我按执行顺序列出来,并说明每一步的影响。

第一,把传入的primarySources(也就是那个带@SpringBootApplication的启动类)放进一个LinkedHashSet。用LinkedHashSet而不是普通Set,目的是保证顺序且去重,因为后面注册 BeanDefinition 的时候要按顺序来,主配置类必须最先被解析,否则@ComponentScan扫出来的东西顺序就乱了。

第二,WebApplicationType.deduceFromClasspath(),推断应用类型。这是决定"要不要起内嵌容器"的关键,后面单独讲。

第三,设置bootstrapRegistryInitializers,这是 SpringBoot 2.4 之后引入的引导上下文机制,主要用于在Environment还没准备好的时候就能注册一些轻量的对象。

第四,通过getSpringFactoriesInstances加载ApplicationContextInitializer和ApplicationListener两类扩展点。这里用的是SpringFactoriesLoader,会扫描类路径下所有META-INF/spring.factories文件里对应 key 的配置,实例化并排序。

第五,deduceMainApplicationClass(),通过分析当前线程的调用栈,找到main方法所在的类。这一步在打包成 fat jar 之后偶尔会因为栈信息不完整而返回 null,不影响启动,但会让日志里打印的主类名变成空的。

这里有一个非常实用的观察点:如果你的启动日志里打印 banner 之后立刻卡住,大概率是第四步加载的某个监听器在starting事件里做了耗时操作。因为ApplicationStartingEvent是在run方法刚进来就发布的,那时候连Environment都没有。

注意:自定义ApplicationListener如果在构造器里做了重活(比如建立数据库连接池),它会拖慢整个启动,而且异常堆栈会非常难看懂。这类组件应该改成在ApplicationReadyEvent里初始化,或者实现SmartLifecycle。

2.2 WebApplicationType 是怎么推断出来的

这个方法虽然只有十几行,但它决定了整个应用是 Servlet 应用、Reactive 应用还是一个普通的非 Web 应用,影响面极大。

判断逻辑是这样的:先看类路径上有没有org.springframework.web.reactive.DispatcherHandler且没有org.springframework.web.servlet.DispatcherServlet且没有org.glassfish.jersey.servlet.ServletContainer,三个条件同时满足就判定为REACTIVE;如果javax.servlet.Servlet或者jakarta.servlet.Servlet不存在,则判定为NONE;其余情况判定为SERVLET。

这套判断里有个坑我踩过:项目里同时引了 WebFlux 和 WebMVC 的依赖,结果判定成SERVLET,Flux 那套路由完全不生效,而且不报错,就是访问 404。原因就是上面那个"且没有 DispatcherServlet"的条件不满足。当你需要强制指定类型时,可以用spring.main.web-application-type=reactive显式覆盖,这个属性在SpringApplication的属性绑定阶段会被读取。

还有一类场景是"我明明是个定时任务服务,为什么启动还起了 Tomcat"。这通常是因为某个依赖间接引入了spring-boot-starter-web,而deduceFromClasspath只看类路径不看你的意图。想彻底不启容器,可以设成none,但更推荐的做法是在依赖里用exclusions把 starter-web 排掉,从根上解决。

另外,判断用的是类是否存在,而不是看有没有对应的配置。所以哪怕你把server.port设成 -1(表示随机端口),也依然会起容器,只是端口随机而已,别指望靠这个关掉 Tomcat。

2.3 ApplicationContextInitializer 与 ApplicationListener 的加载细节

这两个扩展点的加载方式一样,但用途完全不同,很多人会混淆。

ApplicationContextInitializer是在prepareContext阶段被调用的,拿到的是已经创建好但还没 refresh 的ConfigurableApplicationContext。它的典型用途是往容器里手工塞一些东西,比如设置Environment的激活 profile、注册自定义的作用域(如@ThreadScope)。SpringBoot 内部的ContextIdApplicationContextInitializer、ConfigurationWarningsApplicationContextInitializer、ServerPortInfoApplicationContextInitializer都是通过这个机制加载的。

ApplicationListener则是事件驱动模型的核心,它监听的是ApplicationEvent的各种子类,包括ApplicationStartingEvent、ApplicationEnvironmentPreparedEvent、ContextRefreshedEvent等。SpringBoot 内部有一个EventPublishingRunListener,它把SpringApplicationRunListener的回调转成标准事件广播出去,所以你在spring.factories里注册的监听器,能收到整个启动生命周期的所有事件。

这里有一个值得记住的顺序,在排查"我的监听器为什么没收事件"时非常有用。整个启动过程里,事件发布的先后是:ApplicationStartingEvent→ApplicationEnvironmentPreparedEvent→ApplicationContextInitializedEvent→ApplicationPreparedEvent→ (容器 refresh)ContextRefreshedEvent→ApplicationStartedEvent→AvailabilityChangeEvent(存活状态)→ApplicationReadyEvent→AvailabilityChangeEvent(就绪状态)。

如果你用的是@EventListener注解注册的监听器,它注册在 BeanFactory 里,那么它只能收到ApplicationPreparedEvent之后的事件,因为注解式监听器需要容器完成 Bean 注册才能被识别。想让监听器收到更早的事件,必须用spring.factories或者SpringApplication.addListeners()手工注册。这个区别,我在给团队做分享时反复强调,因为它解释了为什么"我明明写了@EventListener(ApplicationStartingEvent.class),就是不触发"。

3. SpringApplication.run 主干的逐步拆解

run方法体大概一百多行,我把它拆成几块来看,每块之间的边界正好对应一个阶段结束。理解了这个主干,你再去看任何一篇源码分析都不会迷路。

3.1 prepareEnvironment:配置文件的优先级真相

prepareEnvironment的调用链是这样:创建或者获取ConfigurableEnvironment,配置propertySources,然后发布ApplicationEnvironmentPreparedEvent,由EnvironmentPostProcessorApplicationListener转交给一系列EnvironmentPostProcessor去处理,其中最核心的是ConfigDataEnvironmentPostProcessor,它负责加载application.properties、application.yml以及各种 profile 变体。

这里必须说清楚配置源的优先级,因为这是最容易出事故的地方。从高到低大致是:命令行参数(--server.port=8081)、SPRING_APPLICATION_JSON里的内容、JNDI 属性、系统属性(-Dserver.port=8081)、操作系统环境变量、random.*随机值、profile 相关的application-{profile}配置、application配置、最后是@PropertySource注解引入的配置和默认属性。

看到这里你应该明白了:为什么有时候在application.yml里改了端口不生效——很可能是环境变量里有个SERVER_PORT在覆盖;为什么本地跑得好好的,到了容器里就变了——环境变量的优先级高于配置文件。我自己有过一次排查两小时的经历,最后发现是某台机器的启动脚本里残留了一个SPRING_PROFILES_ACTIVE。

还有一个容易忽略的细节:spring.profiles.active在配置文件内部指定,会比外部指定的优先级低。也就是说,如果你在application.yml里写了spring.profiles.active: dev,同时用--spring.profiles.active=prod启动,最终生效的是prod。这个设计是合理的,但很多人第一次遇到会懵。

关于yml的多文档块,还有一个坑:用---分隔的多个文档块,后面的会覆盖前面的同名 key,但如果是list类型,它会整体替换而不是追加。这和很多人直觉中的"合并"不同。想追加 list,只能用 profile 分组 +spring.profiles.group的方式,或者干脆改成 Map 结构。

提示:排查配置问题最快的办法是加上--debug启动,SpringBoot 会打印ConditionEvaluationReport,把每个自动配置类的条件匹配结果全部列出来,包括哪些@ConditionalOnProperty没匹配上,非常直观。

3.2 createApplicationContext:容器类型如何被选中

Environment准备好之后,run方法会调用createApplicationContext,根据之前推断的webApplicationType决定用哪个容器实现。SERVLET对应ServletWebServerApplicationContext,REACTIVE对应ReactiveWebServerApplicationContext,NONE对应AnnotationConfigApplicationContext(在较新的版本里会根据是否有引导注册表初始化器选择AnnotationConfigServletWebServerApplicationContext或普通注解上下文)。

选择用的是BeanUtils.instantiateClass反射实例化,而不是 new,这是为了支持自定义。你可以通过SpringApplication.setApplicationContextClass()或者spring.main.application-context-class属性指定自己的容器类型,虽然用到的场景不多,但在做一些框架级定制时会很有用。

这一阶段还有一件事在做:applicationContext.setEnvironment(environment),把刚才准备好的环境对象塞进去。这个动作意味着,从此刻开始,容器里的所有 Bean 都能读到配置文件里的值,但前提是这些 Bean 在Environment之后被创建。

我得强调一下这里的"之后"。有些人在ApplicationContextInitializer里想读配置,是可以读到的,因为此时Environment已经设置进容器了。但如果你想读的是某个 Bean 里注入的@Value,那得等 refresh 之后。这两者的区别是新手最容易混淆的点。

3.3 prepareContext:BeanDefinition 是怎么塞进容器的

这个阶段做的事情,用一句话概括就是:把主配置类登记进容器,然后让所有的初始化器和监听器有机会插手。

具体顺序是:设置Environment→ 应用postProcessApplicationContext(配置beanNameGenerator和resourceLoader)→ 执行所有的ApplicationContextInitializer→ 发布ApplicationContextInitializedEvent→ 打印启动信息(logStartupInfo)→注册主配置类的 BeanDefinition→ 发布ApplicationPreparedEvent。

中间那步load值得单独说。run方法会创建一个BeanDefinitionLoader,把primarySources里的类逐个注册进去。这个 loader 里面有几个不同的 reader:AnnotatedBeanDefinitionReader处理注解类,XmlBeanDefinitionReader处理 XML,GroovyBeanDefinitionReader处理 Groovy 配置。也就是说,即便你用 SpringBoot 了,@ImportResource("classpath:beans.xml")依然是支持的,因为 loader 会走 XML 分支。

关键在于:此时注册的只是一个 BeanDefinition,@ComponentScan还没有真正扫描。很多人以为主类一注册进去,它上面注解的所有包就被扫了,其实扫描发生在 refresh 阶段的ConfigurationClassPostProcessor里。这解释了为什么在ApplicationContextInitializer里getBean一个被扫描出来的组件会报错——因为那时候它连定义都还没有。

如果你想在这个阶段动态注册一个 BeanDefinition,标准的做法是拿BeanDefinitionRegistry,手动registerBeanDefinition。但要注意的是,这时候注册的类如果依赖了后续扫描出来的 Bean,需要自己处理好顺序,或者干脆改成ImportBeanDefinitionRegistrar的方式,让它在配置类解析阶段执行,顺序上更稳妥。

3.4 refreshContext:容器刷新的十二道工序

refreshContext内部调用的就是AbstractApplicationContext.refresh(),这是整个 Spring 体系里最核心的方法,十二个步骤我在下面列出来,每一步都附上它在 SpringBoot 场景下对应做什么。

第一步prepareRefresh,设置启动时间、初始化PropertySource里的占位符、清理缓存、校验必须的属性是否存在。如果你在@Value里写了一个找不到的占位符且没给默认值,报错通常发生在这里。

第二步obtainFreshBeanFactory,对于注解上下文来说,这一步行为了"创建一个DefaultListableBeanFactory",并设置序列化 id。

第三步prepareBeanFactory,注册一些内置的依赖,比如BeanFactory、ApplicationContext、ResourceLoader、ApplicationEventPublisher本身就可以被注入,就是这一步的功劳。它还注册了ApplicationContextAwareProcessor,用来处理Aware系列接口的回调。

第四步postProcessBeanFactory,留给子类做定制。ServletWebServerApplicationContext会在这里注册WebApplicationContextServletContextAwareProcessor,把ServletContext注入能力打开。

第五步invokeBeanFactoryPostProcessors,这是重头戏。ConfigurationClassPostProcessor在这个阶段被调用,它负责解析主配置类上的@ComponentScan、@Import、@Bean、@PropertySource等注解,把扫描到的类和自动配置类全部转成 BeanDefinition 注册进去。自动配置的触发就在这一步,具体链路下一节展开。

第六步registerBeanPostProcessors,把所有的BeanPostProcessor实例化并注册。注意后处理器本身也是 Bean,但它们必须优先于普通 Bean 实例化,否则普通 Bean 在创建时就享受不到后处理能力。这就是为什么@PostConstruct能生效而你自己写的普通 Bean 里的初始化逻辑要晚一步。

第七步initMessageSource,初始化国际化消息源,没配就用默认的DelegatingMessageSource。

第八步initApplicationEventMulticaster,初始化事件广播器,默认是SimpleApplicationEventMulticaster。

第九步onRefresh,这是 SpringBoot 起内嵌容器的位置,ServletWebServerApplicationContext会在这里创建并启动 WebServer,详细内容第五节讲。

第十步registerListeners,把容器里所有的ApplicationListener类型的 Bean 注册到广播器上。

第十一步finishBeanFactoryInitialization,预实例化所有非懒加载的单例 Bean。这是启动耗时的主要来源,也是各种依赖注入、循环依赖、初始化方法报错的高发区。注意循环依赖在 SpringBoot 2.6 之后默认是禁止的,除非你用spring.main.allow-circular-references=true打开,这个默认值变更坑过不少人。

第十二步finishRefresh,发布ContextRefreshedEvent,调用LifecycleProcessor.onRefresh(),启动所有Lifecycle类型的 Bean。内嵌容器的"开始接收流量"实际发生在这个阶段,因为WebServerStartStopLifecycle是在这里被触发的。

把这十二步记住之后,看启动异常堆栈就很简单了:堆栈里出现invokeBeanFactoryPostProcessors,那是配置类解析或者自动配置的问题;出现finishBeanFactoryInitialization,那是某个 Bean 的创建或者注入出了问题;出现onRefresh,那是端口、SSL 证书、Servlet 容器配置的问题。

4. 自动配置的触发链路与条件装配

自动配置是 SpringBoot 最"魔法"的部分,也是问题最多的部分。我见过太多人对着一个@ConditionalOnMissingBean反复折腾,其实只要理解了它的执行时机和排序规则,一切都很自然。

4.1 @EnableAutoConfiguration 到底 import 了什么

@SpringBootApplication是一个组合注解,由@SpringBootConfiguration、@EnableAutoConfiguration、@ComponentScan三个组成。其中@EnableAutoConfiguration又通过@Import(AutoConfigurationImportSelector.class)把选择器引入。

AutoConfigurationImportSelector实现的是DeferredImportSelector,注意"Deferred"这个词。它的含义是:在所有普通的配置类都解析完之后,再执行它的selectImports方法。这个设计非常精妙,因为它保证了用户自己写的@Configuration总是先被解析,注册的 BeanDefinition 先进入容器,之后自动配置类解析时,@ConditionalOnMissingBean才能正确地"发现用户已经定义了"。这就是为什么用户的 Bean 总能覆盖默认配置的根本原因,不是什么约定,而是导入时机决定的。

selectImports做的事情分三步。第一,读取类路径下所有自动配置候选类,新版 SpringBoot 读的是META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件,老版本读的是spring.factories里的EnableAutoConfigurationkey。第二,用一个叫AutoConfigurationImportFilter的过滤器组做一轮粗筛,默认有三个:OnClassCondition、OnBeanCondition、OnWebApplicationCondition。第三,把剩下的候选类排序后返回,排序依据是@AutoConfigureOrder、@AutoConfigureBefore、@AutoConfigureAfter以及字母序。

这里有一个非常实用的知识点:第一轮粗筛(也就是过滤器的评估)是不看@ConditionalOnProperty的,它只看类是否存在、Bean 是否存在、Web 类型是否匹配。真正的属性条件、缺失 Bean 条件,要在后续配置类解析阶段由ConditionEvaluator逐个求值。所以你在--debug报告里会看到两种结果:一种是"Negative matches",一种是"Exclusions",前者是被条件判掉的,后者是被exclude属性或者过滤器直接排除的。

4.2 条件注解在什么阶段被求值

条件注解的求值时机,取决于它挂在哪里。

挂在类上(配置类级别),那么在ConfigurationClassParser.processImports和processConfigurationClass时就会评估,用的是ConditionEvaluator.shouldSkip。这个阶段如果判定为不匹配,整个类包括里面的所有@Bean方法都不会被解析。

挂在@Bean方法上,则是在ConfigurationClassBeanDefinitionReader.loadBeanDefinitionsForBeanMethod时评估。这个阶段类已经确定了,只判断单个方法要不要生成 BeanDefinition。

两种时机的差别会带来一个真实的问题:如果一个配置类上有@ConditionalOnProperty不匹配,但它内部的某个@Bean方法上也有条件注解,那么方法级别的条件根本不会被求值,因为类已经跳过了。日志里也只会显示类级别的跳过原因,看不到方法级别的,排查时容易误判。

另外,@ConditionalOnMissingBean在两个阶段的语义也不同。类级别上使用时,它检查的是"容器里是否已经有对应类型的 BeanDefinition";方法级别使用时检查的是"当前已经注册的 BeanDefinition 里有没有"。由于自动配置是最后解析的,用户配置在前面,所以这两个检查通常都能得到正确结果。但如果你在自动配置类之间互相依赖,就得靠@AutoConfigureAfter显式指定顺序,否则谁先谁后是不确定的,@ConditionalOnMissingBean的结果也可能不确定。

我踩过的一个典型坑:两个自动配置类都注册了同类型的 Bean,都写了@ConditionalOnMissingBean,结果在不同的机器上生效的还不一样,最后发现是排序不稳定导致的。解决办法很简单,在其中一个上标注@AutoConfigureBefore或者用@AutoConfigureOrder给个明确的数字。

4.3 排序、覆盖与排除的三个实用技巧

第一个技巧,排除自动配置的三种写法,按推荐程度排序。最推荐的是在@SpringBootApplication(exclude = XxxAutoConfiguration.class)上写,编译期就能检查类名是否正确;次推荐是在application.yml里写spring.autoconfigure.exclude,方便按环境区分;最不推荐的是在spring.factories里全局排除,因为会影响所有引用这个模块的应用,后患无穷。

第二个技巧,用@AutoConfigureTestDatabase之类的测试专用注解时,注意它们的排除是不是生效了。测试环境的自动配置裁剪经常出问题,原因就是测试注解的优先级和spring.autoconfigure.exclude冲突。稳妥的做法是在测试类上用@ImportAutoConfiguration显式列出需要的自动配置,而不是靠排除。

第三个技巧,自定义 starter 时的排序声明。如果你写了一个自动配置类,希望它在某个官方自动配置之后执行(比如你想覆盖数据源的一些默认值),可以用@AutoConfigureAfter(DataSourceAutoConfiguration.class)。如果你的配置只是提供一些默认值,希望用户能覆盖,那就在@Bean方法上加@ConditionalOnMissingBean,让用户定义的先注册。两件事配合起来用,才是完整的覆盖逻辑。

场景写法说明
想在官方配置之后执行@AutoConfigureAfter类级别注解,指定相对顺序
想提供可被覆盖的默认值@ConditionalOnMissingBean+@Bean方法级别,用户定义优先生效
想彻底关闭某个自动配置@SpringBootApplication(exclude=...)编译期校验,推荐
想按环境关闭spring.autoconfigure.exclude配置中心可动态调整
想调整自动配置的全局顺序@AutoConfigureOrder(数字)数字越小越靠前

5. 内嵌 Web 服务器在启动流程中的坐标

"内嵌 Tomcat 到底什么时候起来的"这个问题,我在面试和团队交流中问过不下二十个人,能准确说出在onRefresh阶段的不到三分之一。这个知识点的重要性在于:它决定了你在端口被占用、证书加载失败、Servlet 初始化异常时,应该去堆栈的哪个位置找线索。

5.1 ServletWebServerFactory 的装配过程

内嵌容器的创建不是一个直接 new 的过程,而是一个"工厂的工厂"的模式。整个链路大概是:ServletWebServerFactoryAutoConfiguration这个自动配置类,会根据类路径上存在哪个容器(Tomcat、Jetty、Undertow、Netty),通过@ConditionalOnClass注册对应的ServletWebServerFactory实现。

以 Tomcat 为例,注册的是TomcatServletWebServerFactory。同时还会注册一个TomcatServletWebServerFactoryCustomizer,它的作用是把ServerProperties(也就是server.*开头的配置)绑定到工厂上。端口、上下文路径、连接超时、最大连接数、访问日志、SSL 配置,都是通过这个定制器设置进去的。

这里有一个容易误解的点:server.port并不是在创建容器时直接用的,而是通过属性绑定先赋给ServerProperties,再在customize方法里factory.setPort(...)。所以如果你想在运行期动态决定端口,可以在WebServerFactoryCustomizer里做,而不是去改Environment,因为顺序上定制器执行得更晚、更可靠。

ServletWebServerApplicationContext有一个getWebServerFactory方法,它会去容器里找唯一的一个ServletWebServerFactoryBean。如果你自己注册了多个(比如同时引了 Tomcat 和 Jetty),它会直接抛异常,提示你只能有一个。这个错误信息很直白,但要解决就得用exclusions排掉一个。

5.2 端口绑定到底发生在哪一行

严格来说,Tomcat 的start()是在TomcatWebServer的构造器里被调用的。也就是说,new TomcatWebServer(tomcat)这个动作发生时,端口就已经绑定了。

而TomcatWebServer的创建发生在TomcatServletWebServerFactory.getWebServer(...)的内部,这个方法由ServletWebServerApplicationContext.createWebServer()调用,createWebServer()又由onRefresh()调用。

所以结论是:端口被占用的异常,抛出点在refresh的onRefresh阶段,而不是finishRefresh阶段。这一点经常被搞混,因为很多资料说"容器在 finishRefresh 时启动",其实finishRefresh里启动的是WebServerStartStopLifecycle,它调用的webServer.start()在 Tomcat 的实现里是空的(因为构造时已经启动了),真正有意义的动作是发布"服务器已启动"的事件。

DispatcherServlet的注册则更早一点。创建 Web 服务器时,会传入一组ServletContextInitializer,这些初始化器来自ServletContextInitializerBeans,它会把容器里所有Servlet、Filter、EventListener类型的 Bean 包装成注册项。DispatcherServletAutoConfiguration会把DispatcherServlet注册成一个 Bean,于是它就被自动注册进去了。这就是为什么你什么都不配,/路径也能被处理——DispatcherServlet是通过自动配置注册的。

5.3 线程池、端口参数与常见配置陷阱

关于内嵌容器参数,有几个坑值得单独说。

端口设成 0表示随机分配一个可用端口,这在测试和灰度场景下很有用,但要注意拿不到端口号的时候,服务注册中心会注册一个错的值。正确做法是监听WebServerInitializedEvent,从中取getWebServer().getPort(),或者用@LocalServerPort注入。生产环境乱用随机端口是自找麻烦。

server.tomcat.max-threads的默认值是 200,这个数字对大多数业务够用,但对慢查询多的系统可能偏小。调整时要注意,线程数不是越大越好,因为线程之间的上下文切换和内存占用是有成本的。我一般的做法是先看压测时的线程池活跃数,再按峰值上浮 30% 设置。

server.servlet.context-path修改之后,如果用了 Actuator,端点路径也会跟着变。很多人改了 context-path 发现健康检查探针 404,就是因为探针地址没同步改。更稳妥的做法是用management.server.port单独开一个管理端口,业务和管理分离,互不影响。

优雅停机的配置需要server.shutdown=graceful和spring.lifecycle.timeout-per-shutdown-phase=30s配合使用。只配前者的话,默认超时是 30 秒,但很多长连接场景下不够,会出现请求被强制中断的情况。

SSL 证书相关的问题大多发生在onRefresh阶段,报错信息里通常能看到 keystore 相关字样。用 PEM 格式时要特别注意server.ssl.certificate和server.ssl.certificate-private-key两个属性的配对,少一个就会启动失败,而且失败位置比较深,堆栈容易被截断。

6. 启动耗时定位与线上问题排查实录

前面讲的都是"原理",这一节讲"怎么用"。原理的价值在于,当你遇到问题时,你能知道去哪一层找,而不是漫无目的地加日志。

6.1 用 ApplicationStartup 抓启动时间线

SpringBoot 内置了一套启动追踪机制,通过ApplicationStartup接口实现。默认用的是DefaultApplicationStartup,什么都不记录。想记录的话,在main方法里加一行:

public static void main(String[] args) { SpringApplication app = new SpringApplication(Application.class); app.setApplicationStartup(new BufferingApplicationStartup(2048)); app.run(args); }

这里的2048是缓冲区大小,表示最多记录 2048 个步骤,超过会丢弃。启动步骤多的应用可以适当调大,但要考虑内存开销,一般两千到四千之间够用。

配合spring-boot-starter-actuator,启动之后访问/actuator/startup端点,就能拿到一份完整的时间线,包括每一步的起止时间、耗时、父步骤。我通常的做法是把它拉到本地,用脚本按耗时倒序排列,一眼就能看出前十大耗时项。

这套机制有两个局限需要注意。第一,它只能记录 SpringBoot 自己埋点的部分,你自己代码里的耗时需要手动加StartupStep。第二,缓冲区满了之后新的步骤会被丢弃,所以看到的时间线可能不完整,这时候要么调大缓冲区,要么用--debug配合日志分析。

更轻量的替代方案是启动参数加-Dspring.jmx.enabled=true然后接 JConsole,或者直接用jstack在启动过程中抓几次线程栈,看主线程卡在哪个方法上。后者虽然原始,但在很多"启动卡死"的场景下最有效,因为没有侵入性,也不需要改代码。

6.2 六类高频启动异常与排查路径

下面这张表是我这几年整理出来的,基本覆盖了日常遇到的大部分情况。

异常关键词大概率阶段排查方向
Could not resolve placeholder环境准备 / refresh 早期检查配置源优先级、占位符默认值
NoSuchBeanDefinitionException预实例化阶段检查扫描范围、条件注解是否误判
BeanCurrentlyInCreationException预实例化阶段循环依赖,考虑拆解或延迟注入
Port already in useonRefresh端口冲突,查占用进程或改端口
Failed to configure a DataSource自动配置数据源依赖缺失或配置不全
IllegalStateException: Failed to load ApplicationContext兜底往下看 cause,一定是更具体的原因

对于NoSuchBeanDefinitionException,我想多说两句。这个异常有一个特别让人困惑的变体:报错信息里说找不到某个 Bean,但你明明在代码里看到了这个类的定义。这种情况十有八九是条件注解把它判掉了。比如@ConditionalOnClass依赖的类在运行时不存在,或者是@ConditionalOnProperty的属性值不匹配。用--debug看条件评估报告,这类问题一般两分钟内就能定位。

循环依赖的问题,除了拆解代码之外,还有个常见的解法是用@Lazy注入。但要注意,@Lazy生成的是代理对象,你在这个代理上调用方法之前,真实的 Bean 还没创建。如果循环依赖的两方在构造器里互相调用,@Lazy也救不了,必须改成 setter 注入或者用ObjectProvider延迟获取。

端口冲突的处理,很多人第一反应是kill -9,但更稳妥的做法是先看清楚占用的进程是什么。同一台机器上跑多个实例时,建议用规范化的端口规划,比如按环境分段,避免冲突。容器化部署时注意hostNetwork模式下端口是共享的,容易和宿主机上的服务撞车。

6.3 常见问题速查表

除了上面那六类,还有一些低频但折磨人的问题,我整理成一张速查表。

现象可能原因快速验证方式
启动日志停在 banner 后不动早期监听器做阻塞操作jstack抓主线程栈
启动正常但接口全 404context-path 配置或扫描范围错访问/actuator/mappings看映射
配置文件改了不生效环境变量或命令行覆盖--debug看 propertySources
自动配置没生效条件不满足或未引入依赖打开条件评估报告
启动特别慢但无异常懒加载未开或 Bean 太多startup 端点看时间线
内存占用飙高预实例化 Bean 过多-Xlog:gc观察 GC 曲线

关于启动慢,还有一个经常被忽略的开关:spring.main.lazy-initialization=true。打开之后所有 Bean 默认懒加载,启动时间能缩短一大截,但代价是第一个请求会变慢,而且配置错误会延迟到运行期才暴露。我的建议是:开发环境可以开着提高迭代速度,生产环境慎重,除非你配合了完善的监控告警,否则不建议。

还有一个反向的优化手段是spring.jmx.enabled=false(默认就是 false,有些老项目手工打开了)、spring.main.banner-mode=off、关闭不必要的 Actuator 端点。这些加起来通常能省个几百毫秒到一两秒,属于顺手就能做的优化。

6.4 我踩过的几个坑和一条实用经验

第一个坑,@PostConstruct里调用远程接口。这个方法是在 Bean 初始化阶段执行的,执行时上下文还没刷新完,如果远程接口慢,整条启动链就被它拖住了。而且一旦远程不可用,直接启动失败。这类逻辑的正确位置是ApplicationReadyEvent或者SmartLifecycle.start()。

第二个坑,在ApplicationContextInitializer里注入 Bean。拿到的上下文还没 refresh,任何getBean调用都会报错或者拿到不完整的状态。这里只适合做一些纯粹的环境层面的事情。

第三个坑,用CommandLineRunner做数据初始化,结果和ApplicationRunner的执行顺序弄混了。两者的执行顺序可以通过@Order控制,但它们在同一个callRunners里被合并排序,所以@Order是跨类型生效的。如果你的初始化逻辑有依赖关系,务必显式标注顺序号。

最后分享一条我认为最有价值的经验:给启动流程加一个"断点式"的日志习惯。具体做法是在主配置类里加几个@EventListener,分别监听ApplicationEnvironmentPreparedEvent、ApplicationPreparedEvent、ApplicationStartedEvent,每个都打印一行带时间戳的日志。这几行日志几乎零成本,但每次启动异常时,你能立刻知道卡在哪个阶段。我把它加进了团队的脚手架模板,后来新人排查启动问题的时间平均缩短了一半以上。

这个习惯延伸一下还能做更多事,比如在ApplicationStartedEvent里打印当前激活的 profile、生效的端口、关键配置项的值(注意脱敏)。这些信息在排查"为什么这台机器行为和那台不一样"时,比任何文档都管用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/2 5:41:36

WePaper智能论文排版工具测评与使用指南

1. 论文排版工具现状与痛点解析又到了一年一度的毕业季,各大高校图书馆里熬夜赶论文的身影逐渐增多。作为经历过三次毕业论文洗礼的老学长,我深知论文排版这个看似简单的环节,往往能消耗掉研究生们30%以上的精力。去年帮学弟调试LaTeX模板到凌…

作者头像 李华
网站建设 2026/11/2 5:41:35

VoiceStudio实战全攻略:从音色训练到批量配音的完整指南

这个项目我从第一次接触到现在大概用了四个月,期间跑了不下两百条配音样条,踩过不少坑,也摸清了不少门道。这篇文章不打算写成一本说明书,而是想从一个实际使用者的角度,把VoiceStudio的适用场景、核心工作原理、完整操…

作者头像 李华
网站建设 2026/11/2 5:40:07

回形针的工程密码:从钢丝力学到AI安全思想实验

不知道你有没有过这种时刻:开会无聊,从桌上捻起一枚回形针,下意识把它掰开、绕弯、再扣回去,反反复复。就这么一个几乎不被注意的小金属圈,全球产量是按吨来计的,英文叫paperclip,中文叫回形针&…

作者头像 李华
网站建设 2026/11/2 5:40:06

系统化备赛指南:从训练计划到实战技巧

1. 备赛日常:从零开始的系统化训练指南参加比赛从来不是临时抱佛脚就能成功的事情。作为一名经历过数十场各类竞赛的老手,我深刻体会到备赛过程中系统化训练的重要性。真正的备赛日常应该是一个精密设计的系统工程,涉及时间管理、技能提升、心…

作者头像 李华
网站建设 2026/11/2 5:39:29

芯片测试技术革新:机器学习与数字孪生的应用

1. 芯片测试的行业现状与核心挑战在半导体行业,芯片测试环节正面临着前所未有的技术压力。随着制程工艺不断逼近物理极限,7nm以下工艺节点的芯片缺陷率呈现非线性上升趋势。根据国际半导体技术路线图(ITRS)的数据显示,28nm工艺的缺陷密度约为…

作者头像 李华