Spring Boot启动原理与运行时动态扩展机制

目录

Spring Boot 的启动流程、动态注册与配置刷新,共同回答一个问题:在什么时间修改哪一种容器状态,才能让依赖它的对象正确地使用变化?

这里最容易混淆三件事:注册 BeanDefinition、创建 Bean 实例、更新已有调用方持有的引用。前两件可以由容器完成,第三件通常还需要代理或业务路由机制。

本文以 Spring Boot 3.2、Spring Framework 6.1 和相应 Spring Cloud 2023.0 系列为理解基线。旧版本的 spring.factories 自动配置发现、Bootstrap 配置刷新路径会单独说明;下文流程是职责归纳,不把不同版本源码拼接成同一个实现。

启动时先建立环境,再建立对象关系

典型入口保持简单:

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

run 的工作可以按依赖顺序理解:

阶段 主要职责
准备启动对象 保存主配置来源、选择应用类型、加载相应扩展
准备 Environment 合并属性源,并按优先级决定实际配置值
创建与准备 Context 选择上下文类型、应用 Initializer、加载初始定义
refresh 执行定义后处理、注册实例后处理器、创建所需单例
启动回调 执行 ApplicationRunner/CommandLineRunner,推进就绪状态

应用类型推断不只是“有 Servlet 就一定选 Servlet”:探测条件与优先级依具体版本,也可由配置覆盖。ClassUtils.isPresent 可以在不初始化类的情况下探测可加载性,但不等于完全不加载类。

配置也不是把“系统属性→环境变量→文件→命令行”当作一条固定时序即可理解。多个来源提供同名键时,最终值由属性源优先级决定,profile、导入和测试配置还会影响结果。排障应检查最终 Environment 及来源。

扩展发现:spring.factories 与自动配置文件分工

spring.factories 为某类扩展维护实现列表,例如 ApplicationContextInitializer、ApplicationListener。Spring 的加载代码可以按扩展类型获取实现,并结合框架排序机制组织执行。

这不意味着 Java ServiceLoader 只支持单一实现或没有缓存。ServiceLoader 本就能发现同一服务的多个提供者,并在实例内维护加载缓存;Spring 选择自己的机制,是为了契合其扩展登记和实例化需求。ServiceLoader API

自动配置的入口需要区分版本:

版本范围 自动配置候选登记
较早的 Boot 2.x spring.factories 的 EnableAutoConfiguration 项
Boot 2.7 迁移期 引入 AutoConfiguration.imports,兼容旧入口
Boot 3.x 使用 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

完整路径中的 org.springframework.boot.autoconfigure 不能省掉。文件每行登记一个自动配置类;候选类还要经过条件与排序处理,并非出现在文件中就必定创建所有 Bean。自动配置登记

ConditionalOnClass 检查类可用性,ConditionalOnProperty 检查属性条件,ConditionalOnMissingBean 结合当时已处理的定义判断是否补齐默认实现。因此自动配置应安排在用户定义有机会生效之后,而不能把条件当成与处理顺序无关的静态布尔值。

refresh 为什么分开处理定义与实例

BeanDefinition 描述如何构造对象,Bean 实例才是参与运行的对象。将两者分开,允许容器先处理定义,再对普通 Bean 应用已注册的 BeanPostProcessor。

扩展点 主要操作对象
BeanDefinitionRegistryPostProcessor 增加或调整 BeanDefinition
BeanFactoryPostProcessor 调整工厂中的定义元数据
BeanPostProcessor 参与对象初始化前后处理,可返回代理
初始化与销毁回调 管理实例自身资源和状态

这不是“完整扫描全部定义后才创建第一个对象”的绝对规则。后处理器自己以及它们提前请求的依赖可能较早实例化,进而无法被尚未注册的处理器处理。所以应避免在定义处理阶段随意调用 getBean 创建业务对象。

AOT 可以提前分析并生成部分注册代码,但仍有运行时初始化和资源建立;不能说编译为 native image 就自动消除了所有反射、生命周期或启动成本。

启动期动态注册:让正常创建链路接手

如果实现选择在启动时已经确定,可以用条件配置;需要根据元数据批量登记对象时,可以使用 BeanDefinitionRegistryPostProcessor。

下面是局部示例,JpaUserDao/MyBatisUserDao 是项目中已有的实现类。它选择并登记定义,不在此阶段创建 DAO:

@Component
public class DynamicBeanRegistrar implements BeanDefinitionRegistryPostProcessor {
    @Override
    public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) {
        Class<?> impl = "mybatis".equals(System.getProperty("dao.type"))
                ? MyBatisUserDao.class : JpaUserDao.class;
        GenericBeanDefinition definition = new GenericBeanDefinition();
        definition.setBeanClass(impl);
        definition.setScope(BeanDefinition.SCOPE_SINGLETON);
        registry.registerBeanDefinition("userDao", definition);
    }

    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory factory) {
    }
}

实际项目还要避免与已有 userDao 重名。之后由容器按正常路径创建它,才有机会进行依赖注入、初始化和适用的 AOP 增强;“参与完整链路”也不意味着它必然满足每一个切面的匹配条件。

运行时注册:生命周期仍在,装配决策不会重放

DefaultListableBeanFactory 可以注册新的定义。之后通过 getBean 创建该对象,已有的 BeanPostProcessor 仍会参与,不能声称运行时定义天然绕过 AOP 和初始化。

与此不同,registerSingleton 直接登记一个现成对象,不会自动替它补齐常规创建和初始化过程。只调用 initializeBean 也不等于完成构造与依赖注入,且要使用它返回的对象,因为返回值可能是代理。

以下示例限定在受控、没有并发访问的演示阶段,不能作为线上热插拔方案:

DefaultListableBeanFactory factory =
        (DefaultListableBeanFactory) context.getBeanFactory();
BeanDefinitionBuilder builder = BeanDefinitionBuilder.genericBeanDefinition(beanClass);
for (Object argument : constructorArgs) {
    builder.addConstructorArgValue(argument);
}
factory.registerBeanDefinition(name, builder.getBeanDefinition());
Object created = context.getBean(name);

context 是 ConfigurableApplicationContext,beanClass/name/constructorArgs 是调用方已确认的注册参数。注册后创建与登记定义是两个动作,不能靠 unused 参数伪装成已配置构造器。

运行期真正增加的责任包括:

  • 已有单例不会自动重新注入新 Bean,预先收集的列表、路由表也不会自动重建
  • 新定义不自动重新触发所有启动时后处理器;例如新增配置类不能假定会再次经历完整配置类解析
  • 删除定义可能销毁关联单例,但其他对象仍可能持有旧引用,继续使用已经关闭的资源
  • 正在服务的工厂不应随意与注册修改并发交错;Spring 官方也不把这种用法作为受支持的通用运行模式

这些边界不能简化为“内部 ArrayList 没加锁,所以一定抛 ConcurrentModificationException”。实现中存在相应同步和复制处理,风险来自整体装配与并发契约,而非看到某个容器类型就能确定。Bean 登记说明

需要频繁切换插件时,可以让稳定 Bean 持有受控的业务实现注册表,明确新请求何时转向新版本、旧请求何时排空、旧资源何时释放。容器生命周期与业务切换协议分别处理,边界更容易验证。

配置刷新分为属性变化与目标重建

Spring Cloud 的 ContextRefresher 负责刷新环境,并触发相关刷新处理。历史 Bootstrap 路径和 Config Data 路径的重载方式不同,不能统一解释成“总是启动一个临时 SpringApplication”。

EnvironmentChangeEvent 可触发配置属性重新绑定和日志配置更新,但并非所有对象都可重绑。不可变对象、构造器绑定方式、删除属性和组件缓存,都需要检查具体版本支持。

RefreshScope 解决的是另一件事:调用方持有稳定代理,实际目标对象由 scope 缓存管理。刷新时销毁并移除缓存目标,后续对代理的方法访问再按需要创建新的目标;不是每次 getBean 都取得一个裸对象,也不是 refreshAll 立即重新创建所有目标。Spring Cloud Context

时点 调用方持有的引用 实际目标
注入后、尚未调用 作用域代理 可以尚未创建
第一次调用 同一个代理 创建并缓存目标
刷新失效后 仍是代理 旧目标销毁,缓存失效
后续调用 仍是代理 按新环境创建目标

真实实现还要处理销毁回调、锁与事件,不能用 ConcurrentHashMap.clear 就宣称复现了 RefreshScope。

类代理的 final 方法不能按普通可覆盖方法增强。对连接池、客户端等资源,还要检查组件是否支持刷新、旧资源如何关闭,以及新旧配置切换的一致性;例如不能给任意数据源加上注解就保证安全切换。

不同刷新能力不能互相替代

机制 改变什么 不自动完成什么
配置属性重绑 支持重绑的现有对象属性 任意依赖关系和配置类重解析
RefreshScope 通过代理管理目标失效与重建 所有业务对象的原子整体替换
运行期注册定义 新增可创建对象的描述 已有调用方引用切换
重启或重建上下文 重新执行更完整的装配流程 外部业务事务的自动迁移

配置重绑可能执行初始化处理,不能在前文介绍 initializeBean 后又写“重绑绝不会再次初始化”。Cloud Bus 广播刷新也不意味着所有实例在同一个原子时刻切换。

对于开关和只读参数,可以验证其可见性与生效时机;对于相互约束的多个参数,应考虑一次读取同一配置版本;对于客户端或连接池替换,则需要验证旧请求、失败回退和资源释放。刷新端点的暴露与访问权限也必须纳入部署配置。

选择扩展点时,优先放在信息已经齐备、依赖尚未固化的阶段。确实需要运行期变化时,再明确要更新的是定义、属性还是代理目标。这个区分比笼统地说“越晚生命周期越不完整”更能指导实现和排障。

评论