" />
文章

面试题:如果让你实现 Spring IoC,你会注意哪些问题?

面试题:如果让你实现 Spring IoC,你会注意哪些问题?

面试指北 · 蔚来

题意

这道题表面问“实现 Spring IoC 要注意什么”,实际是在考察你是否理解 IoC 容器的本质,而不是只会背 @Autowired、@Service 的用法。

如果自己实现一个轻量 IoC 容器,至少要回答清楚一条主链路:Bean 的定义从哪里来、存在哪里、怎么按依赖创建、创建后怎么做初始化和代理、什么时候销毁,以及循环依赖、作用域、并发和异常怎么处理。面试时如果一上来只列注解,容易显得“会用 Spring”,但没有“实现容器”的视角。

比较稳妥的答法是先给出容器骨架,再展开关键难点:依赖注入、生命周期、作用域、循环依赖、自动装配歧义、AOP/事务集成、并发与资源管理。下面按这个顺序展开。

结论

实现一个 IoC 容器,核心闭环可以概括为:

加载配置 → 解析成 BeanDefinition → 注册到容器 → 按依赖实例化 → 属性填充/依赖注入 → 初始化与 BeanPostProcessor 扩展 → 放入单例池 → 使用 → 容器关闭时销毁。

简化后的主流程大致如下(只是表达思路,不是完整实现):

// 简化版:只表达 getBean 的主流程
Object getBean(String name) {
    Object bean = singletonObjects.get(name);
    if (bean != null) {
        return bean;
    }

    synchronized (getSingletonLock(name)) {
        bean = singletonObjects.get(name);
        if (bean != null) {
            return bean;
        }

        BeanDefinition bd = beanDefinitions.get(name);
        bean = instantiate(bd);              // 1. 实例化
        exposeEarlyReference(name, bean);    // 2. 提前暴露,解决 setter/字段循环依赖
        populateProperties(bean, bd);        // 3. 依赖注入
        bean = initialize(bean, bd);         // 4. 初始化 + BeanPostProcessor,可能生成代理
        singletonObjects.put(name, bean);    // 5. 放入单例池
        return bean;
    }
}

实现时最需要优先保证的是:依赖解析正确、单例创建并发安全、生命周期顺序正确、循环依赖可控、作用域不串、AOP 代理不丢。能做到“能创建对象”只是第一步,能排错、能扩展、能安全关闭,才更接近一个可用的容器。

要点展开

1. 先抽象 BeanDefinition 和注册表

实现 IoC 的第一步不是直接 new 对象,而是把“对象怎么创建”的元数据抽象出来。这个元数据通常叫 BeanDefinition,里面至少要有:

  • Bean 的名称、类型、别名;
  • 构造器参数、属性值、依赖引用;
  • 作用域(singleton、prototype、request、session 等);
  • 是否懒加载、是否 Primary;
  • 初始化方法、销毁方法;
  • 是否由 FactoryBean 创建;
  • 条件装配、Profile 等配置信息。

这些定义要放在一个注册表里,常见做法是 ConcurrentHashMap<String, BeanDefinition>,再加别名表。XML 配置、注解扫描、Java Config 最终都应该转成统一的 BeanDefinition,这样容器主流程才不会被配置来源绑死。

这里还要区分两个阶段:BeanDefinition 注册阶段和Bean 实例化阶段。BeanFactoryPostProcessor 是在 Bean 实例化之前修改 BeanDefinition 的,而 BeanPostProcessor 是处理已经创建出来的 Bean 实例的,两者不要混。

2. 依赖注入方式:构造器、Setter、接口注入

构造器注入适合强制依赖。对象创建时依赖必须齐备,天然符合“创建完就是完整对象”的原则,也方便做不可变设计。单个构造器时 Spring 可以省略 @Autowired;多个构造器时通常需要指定。构造器注入的另一个好处是:如果存在循环依赖,会在启动阶段暴露出来,而不是悄悄形成一个半成品对象。

Setter 注入适合可选依赖,或者对象创建后还需要重新配置的场景。字段注入写起来最方便,但依赖关系不够直观,单元测试时也不容易替换,所以 Spring 官方更推荐构造器注入。

接口注入在 IoC 理论里是第三种方式,但 Spring 并没有把它作为主流注入方式。Spring 更多是用接口回调做扩展,比如 Aware 系列、BeanPostProcessor、FactoryBean、ObjectProvider 等。面试时可以把接口注入作为背景知识提一句,但不要把它说成 Spring 的核心注入方式。

实现依赖解析时,不能只按类型简单匹配,还要考虑泛型、限定符、Primary、集合注入等情况。找不到依赖或多个候选时要给出清晰异常,而不是随便选一个。

3. 生命周期管理:创建时机、初始化和销毁

Bean 的生命周期要拆清楚:

  1. 实例化;
  2. 属性填充/依赖注入;
  3. Aware 回调,例如 BeanNameAware、BeanFactoryAware;
  4. BeanPostProcessor#postProcessBeforeInitialization;
  5. 初始化:@PostConstruct → InitializingBean#afterPropertiesSet → 自定义 init-method;
  6. BeanPostProcessor#postProcessAfterInitialization,AOP 代理通常在这个阶段生成;
  7. 使用;
  8. 销毁:@PreDestroy → DisposableBean#destroy → 自定义 destroy-method。

单例 Bean 默认在容器启动时创建,prototype 是每次 getBean 时创建,Web 作用域则跟随请求或会话。懒加载 @Lazy 可以让单例延迟到第一次使用时创建,启动会快一些,但首次调用会有延迟,也可能把配置错误推迟到运行期才暴露。

初始化失败时要有清理意识,避免半初始化对象残留在单例池里。容器关闭时也要统一销毁单例,释放线程池、连接、文件句柄等资源。注意:prototype Bean 容器不负责销毁,销毁回调需要调用方自己处理。

4. 作用域:单例、原型和 Web 作用域

singleton 是默认作用域,容器只创建一个实例并缓存。它适合无状态服务,但 Bean 本身不一定线程安全,单例里不要随意放可变成员变量。

prototype 每次获取都创建新对象,适合有状态或短生命周期对象。容器不缓存它,也不管理它的销毁。

Web 环境下还会用到 @RequestScope、@SessionScope、@ApplicationScope 等作用域,分别对应请求、会话和应用级别。这里有一个常见坑:单例 Bean 注入短生命周期 Bean。比如单例 Service 直接注入 request 作用域的 Bean,容器启动时可能拿不到真正的请求对象。解决方式是用 scoped proxy,例如 @Scope(proxyMode = ScopedProxyMode.TARGET_CLASS),或者用 ObjectProvider 延迟获取。

如果实现自定义作用域,需要把作用域的创建、缓存、销毁规则想清楚,否则很容易出现对象串用或泄漏。

5. 循环依赖:三级缓存解决的是 setter/字段注入

循环依赖是这道题的高频追问点,要区分清楚:

  • 构造器循环依赖:A 的构造器需要 B,B 的构造器又需要 A。因为对象还没实例化完,无法提前暴露引用,默认解决不了,启动时会抛 BeanCurrentlyInCreationException。可以通过重构、@Lazy 等方式打破。
  • Setter/字段循环依赖:A 先实例化,属性填充时发现需要 B;B 实例化后填充属性又发现需要 A。此时可以把 A 的早期引用提前暴露出去,让 B 先拿到一个“尚未完成属性填充的 A”,从而完成创建。
  • prototype 循环依赖:容器不缓存 prototype,无法提前暴露,通常也解决不了。

Spring 用三级缓存处理 setter/字段循环依赖:

  • 一级缓存 singletonObjects:完整的单例 Bean;
  • 二级缓存 earlySingletonObjects:早期引用;
  • 三级缓存 singletonFactories:存放 ObjectFactory,需要时生成早期引用。

流程可以简单理解为:A 实例化后先把 ObjectFactory 放入三级缓存;创建 B 时发现依赖 A,就从三级缓存拿到工厂生成 A 的早期引用,放入二级缓存;B 创建完成后放入一级缓存;A 再继续完成属性填充和初始化。三级缓存的意义不只是“多一层缓存”,而是延迟生成早期引用,给 AOP 代理留出空间,尽量保证 B 拿到的 A 和最终放入容器的 A 在代理语义上是一致的。

还要注意:AOP 不是“解决循环依赖”的手段,它只是让循环依赖场景更复杂。Spring Boot 2.6 之后默认禁止循环依赖,说明官方态度也是希望开发者优先重构依赖关系,而不是依赖容器兜底。

6. 自动装配与歧义处理

@Autowired 默认按类型注入。有多个同类型 Bean 时,常见处理方式是:

  • @Primary 指定默认优先的 Bean;
  • @Qualifier 按限定符缩小范围;
  • @Resource(name = "...") 默认按名称注入;
  • @Inject 配合 @Named 使用 JSR-330 语义;
  • 集合注入时会注入所有候选,@Order 可以控制集合中的顺序。

这里容易说错:@Autowired 本身不是“按名称自动装配”,它首先按类型找,多个候选时再借助 @Qualifier、@Primary

License:  CC BY 4.0