面试题:如果让你实现 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 的生命周期要拆清楚:
- 实例化;
- 属性填充/依赖注入;
- Aware 回调,例如
BeanNameAware、BeanFactoryAware; BeanPostProcessor#postProcessBeforeInitialization;- 初始化:
@PostConstruct→InitializingBean#afterPropertiesSet→ 自定义init-method; BeanPostProcessor#postProcessAfterInitialization,AOP 代理通常在这个阶段生成;- 使用;
- 销毁:
@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