☰
Spring IoC与DI核心原理:从依赖注入到循环依赖实战解析
2026/10/1 4:43:31 网站建设 项目流程

1. IoC与DI的核心思想:到底解决了什么问题

先聊一个最基础也最容易被忽略的问题:Spring 的 IoC(Inversion of Control,控制反转)和 DI(Dependency Injection,依赖注入)到底是干什么的?

很多刚接触 Spring 的人都会陷入一个误区,觉得 IoC 就是个“对象工厂”,DI 就是“自动赋值”,背完概念就觉得自己懂了。但实际上,这两个词的背后藏着一个非常深刻的设计思想转变,不理解这个思想,后面看源码、写扩展、排查问题都会觉得隔了一层。

先看传统写法。假设你有一个订单服务要调用用户服务查用户信息,在没有 Spring 之前,代码大概是这样的:

public class OrderService { private UserService userService = new UserService(); public void doSomething() { User user = userService.getUserById(1L); // ... } }

这段代码有什么问题?看起来没问题,但实际维护起来很痛苦。OrderService不仅要写业务逻辑,还得负责创建UserService对象,这意味着订单服务和用户服务的具体实现类耦合死了。如果哪天UserService的构造函数变了,加了个参数,你所有 new 过它的地方全要改。更别提如果UserService依赖了数据库连接池、Redis 客户端,那OrderService还得负责把这些依赖一层层搭好,这简直是一场灾难。

IoC 的核心思想就是把这个“创建对象、管理对象生命周期”的活儿从业务代码里剥离出来,交给一个容器统一处理。你的业务类不再自己new依赖对象,而是“声明”自己需要什么,容器在合适的时机把你需要的东西“注入”给你。

用一句话概括:对象不再主动去找它的依赖,而是被动地接收容器给它的依赖。这就是“控制反转”中“反转”二字的含义——控制权从业务代码手里,反转到了容器手里。

DI 是 IoC 的一种实现方式。IoC 是设计思想,DI 是具体落地手段。Spring 通过依赖注入来达到控制反转的目的。在面试中如果你只背了“IoC 反转了对象的创建权”这句话,而没有说出 DI 这个实现手段,很容易被追问到卡壳。

2. 三种依赖注入方式:构造器、Setter、字段,各自的门道

Spring 官方提供的依赖注入方式主要有三种:构造器注入、Setter 注入、字段注入。虽然都能把依赖塞进来,但适用场景和风险差别很大,这也是面试里很爱问的点。

2.1 构造器注入:Spring 官方推荐的方式

构造器注入是把所需的依赖作为构造函数的参数传入:

@Component public class OrderService { private final UserService userService; // @Autowired 在只有一个构造函数时其实可以省略 public OrderService(UserService userService) { this.userService = userService; } }

这种方式是 Spring 官方文档里明确推荐的,原因有三个。

第一,依赖不可变。final关键字修饰的字段在对象创建后无法被修改,这保证了依赖的稳定性,避免了某些代码在别处偷偷改掉你的依赖。

第二,依赖绝对不能为空。如果容器里没有UserService这个 Bean,Spring 在启动阶段就直接报错,而不是等你运行到某行代码调用时才空指针崩溃。这种“启动即失败”的特性,能把问题提前暴露在开发期。

第三,便于写单测。你不需要启动 Spring 容器,直接new OrderService(mockUserService)就能完成测试,非常简单干净。

2.2 Setter 注入:可选依赖的较好选择

Setter 注入通过@Autowired标注在 setter 方法上实现:

@Component public class OrderService { private UserService userService; @Autowired public void setUserService(UserService userService) { this.userService = userService; } }

这种方式的优势在于,允许对象创建后重新配置依赖,像是给对象留了一个“后门”。但是它的缺点也很明显:依赖不是final的,对象可能处于未完全注入的中间状态。Spring 官方支持它,但建议只在“依赖是可选场景”下使用,比如某些自动配置类里,依赖可能不存在时,用@Autowired(required = false)配合 Setter 比较合适。

2.3 字段注入:最方便但最不推荐的方式

字段注入就是最常见的写法:

@Component public class OrderService { @Autowired private UserService userService; }

说实话,这种写法真的省事,写业务代码时我也经常图快这么用。但心里要清楚它的问题:字段注入绕过了构造器和 Setter,在 Spring 之外的环境里,你没有办法给这个对象赋值,写单测时要么启动容器,要么用反射强行塞值。而且 IDE 和 Sonar 等静态检查工具也会对它亮黄牌警告。

真正反思一下,项目中如果依赖数量特别多,往往不是依赖注入方式的问题,而是这个类本身职责过重,该做拆分重构了。字段注入的便利性背后,隐藏着依赖关系不直观的问题——读者无法快速看清这个类到底依赖哪些东西。

3. 装配 Bean 的三种配置方式:XML、注解、Java Config

Spring 容器怎么知道要创建哪些 Bean、怎么注入依赖?这就要说到配置方式了。从 Spring 诞生到现在,配置方式大致经历了三个阶段,当下流行的写法你可能天天在用,但未必清楚每种方式的来历和适用边界。

3.1 XML 配置:老项目的标配

早年间的 Spring 项目全是 XML 配置,长这样:

<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd"> <bean id="userService" class="com.example.UserService"/> <bean id="orderService" class="com.example.OrderService"> <constructor-arg ref="userService"/> </bean> </beans>

XML 配置的优势在于无需重新编译即可调整装配关系,在早年间 Java 还没有注解功能时是唯一的选择。但它的问题也很致命:配置冗长、查错困难,Bean 多起来以后 XML 文件动辄上千行,而且编译器无法帮你检查引用是否正确,只能运行时报错。

现在除了维护老项目,已经很少有人从零开始写 XML 配置了。在我的实操经验里,XML 配置还残留在一些特殊场景中,比如某些第三方框架的集成配置、某些需要动态调整 Bean 定义的场景,但总的来说,它已经退出了主舞台。

3.2 注解配置:现代项目的默认选择

注解配置通过@Component、@Service、@Repository、@Controller等注解标注类,让 Spring 自动扫描注册 Bean:

@Service public class UserService { @Autowired private UserMapper userMapper; }

使用时要先开启组件扫描,Spring Boot 里默认扫描启动类所在包及其子包,所以主启动类的包位置要注意,否则扫描不到你的 Bean。这也是新手最常见的坑之一:启动类放在了错误的包层级,或者业务类放在了启动类所在包的兄弟包里,结果 Spring 一个 Bean 都没扫到,启动后一调用就报 NoSuchBeanDefinitionException。

注解配置的好处是零 XML、装配关系就在代码里,IDE 能帮你检查引用,重构时改名也不容易遗漏。缺点是装配关系分散在各个类中,想总览全局配置比较困难,新人入职时看项目结构不如 XML 时代一目了然。但在实际开发中,配合 IDEA 的 Spring 插件,各个依赖关系都能可视化展示,这个缺点其实被工具弥补了不少。

3.3 Java Config:类型安全的配置方式

Java Config 用@Configuration+@Bean注解来定义 Bean,这是一种类型安全的配置方式:

@Configuration public class AppConfig { @Bean public UserService userService() { return new UserService(); } @Bean public OrderService orderService() { return new OrderService(userService()); } }

Java Config 的核心优势是类型安全:如果引用类型搞错了,编译期就能发现。而且配置逻辑是代码,可以写判断、循环等逻辑,能灵活实现动态装配。Spring Boot 大量使用这种方式做自动配置,你在依赖里引入一个spring-boot-starter-web,容器里就自动有了DispatcherServlet、ViewResolver等一系列 Bean,背后就是功劳。

3.4 混合装配:实际项目中的常态

实例里,一个中型项目往往是三种方式混用的:Spring Boot 的自动配置负责底层基础设施,Java Config 负责显式配置某些自定义的、有特定初始化逻辑的 Bean,而业务 Bean 基本靠注解扫描搞定。理解每种方式的边界,才能写出既高效又易维护的代码。

4. Bean 的生命周期:从定义到销毁,容器帮你做了什么

面试高频问题是“Spring Bean 的生命周期”,源码级考察也不少。这篇文章把这块讲透,你对照着自己 Debug 一遍源码,基本就能应付大部分场景了。

一个 Bean 在容器中大致经历以下几个阶段:

  1. 解析 BeanDefinition:容器读取配置,生成 Bean 的定义信息(类名、作用域、是否懒加载、依赖关系等)。
  2. 实例化 Bean:通过构造器或工厂方法创建对象实例,这一步仅仅是new了一个对象,属性还没赋值。
  3. 属性填充(依赖注入):容器根据 Bean 定义,把依赖的其他 Bean 和各配置项填充进去。
  4. 初始化前的各种回调:依次执行 Aware 接口回调(BeanNameAware、BeanFactoryAware、ApplicationContextAware)、BeanPostProcessor的postProcessBeforeInitialization方法、@PostConstruct标注的方法、InitializingBean的afterPropertiesSet方法、自定义的init-method。
  5. 初始化完成:BeanPostProcessor的postProcessAfterInitialization方法执行完毕,Bean 可以投入使用了。
  6. 使用阶段:容器里所有的单例 Bean 被业务代码调用。
  7. 销毁阶段:容器关闭时,依次执行@PreDestroy标注的方法、DisposableBean的destroy方法、自定义的destroy-method。

很多人觉得这个生命周期很抽象,我换个生活化的类比:Bean 就像一件定制的西装。先量体(解析定义),然后裁剪(实例化),接着缝扣子修边(属性填充),再熨烫整理(初始化回调),最后包装好交到你手里(初始化完成)。你穿它出席各种场合(使用),最终旧了淘汰(销毁)。这个过程中,你只负责下单(声明需求),所有制作细节都是裁缝铺(容器)帮你完成的。

5. 三级缓存与循环依赖:Spring 解决棘手问题的精妙设计

Spring 最著名的设计之一就是“三级缓存解决单例 Bean 的循环依赖”。面试十有八九会问,但能真正讲清楚的候选人不多。先明确一个前提:Spring 只能解决单例作用域 + 构造器注入之外的循环依赖,原因往下看就会明白。

先说什么叫循环依赖。假设ServiceA依赖ServiceB,而ServiceB又依赖ServiceA,这就形成了循环:

@Component public class ServiceA { @Autowired private ServiceB serviceB; } @Component public class ServiceB { @Autowired private ServiceA serviceA; }

Spring 在创建ServiceA时发现需要ServiceB,于是去创建ServiceB;创建ServiceB时又发现需要ServiceA,于是又去创建ServiceA……如果没有任何机制,这就变成死循环了。

Spring 的解决方案是三个缓存容器(源码在DefaultSingletonBeanRegistry中):

  • 一级缓存singletonObjects:存已经完整创建好的单例 Bean。
  • 二级缓存earlySingletonObjects:存已经实例化但还未完成属性填充的早期 Bean。
  • 三级缓存singletonFactories:存单例工厂,用于在需要时生成早期 Bean 的代理对象。

处理流程简化如下:

  1. 创建ServiceA时,先实例化对象,得到一个原始对象instanceA。
  2. 把这个原始对象包装进singletonFactories三级缓存,存一个ObjectFactory,这个工厂能生成instanceA的早期引用(需要时还会生成 AOP 代理)。
  3. 开始填充ServiceA的属性,发现需要ServiceB,于是去创建ServiceB。
  4. 创建ServiceB,同样先实例化对象,放进三级缓存,然后填充属性,发现需要ServiceA。
  5. 此时去三级缓存查找ServiceA,找到ObjectFactory,调用工厂拿到早期引用instanceA(AOP 场景下这里拿到的是代理对象),注入给ServiceB。
  6. ServiceB创建完成,放进一级缓存singletonObjects。
  7. 回到步骤 3,把ServiceB注入给ServiceA的serviceB属性。
  8. ServiceA创建完成,放进一级缓存。

这里的关键点在于第 5 步:Spring 拿到的是早期裸对象(或者代理对象),这个对象的依赖还没有填充完成,但已经可以注入给别的对象了。这就像把决定西装样式的设计图先发给别人看,等成衣做完再送过去替换——而这在 Java 里是完全可行的,因为引用传递的本质是“指针”,只要指针指向正确,早晚能访问到最终对象。

为什么要三级缓存,两级不够吗?这是个非常经典的追问。关键在于 AOP 代理。如果 Spring 在实例化后立即进行 AOP 代理,那问题就简单了,早期对象直接就是代理对象,二级缓存就够了。但现实是 Spring 要在 Bean 初始化完成后才创建 AOP 代理,而其他 Bean 在依赖注入时需要的又必须是有能力生成代理的早期对象。三级缓存就是把“是否生成代理”这个决定延迟到了真正需要早期引用的那一刻,由ObjectFactory来动态决定。正是这个延迟,让“Bean 还没完全初始化但已经可以被引用”这件事成为了可能。

如果你是小白,这样理解三级缓存:一级缓存是“成品货架”,二级缓存是“半成品货架”,三级缓存是“制作工单”。别人要买东西(依赖这个 Bean)时,先看成品货架有没有,没有就看半成品货架,还没有就去制作工单里查这个订单是否已经在做了——如果正在做,就先临时拿一个半成品(或它的代理)顶上,避免重新再做一个,最终成品完成后再补货上架。

6. @Autowired 的查找策略:先类型、再限定、后名称

@Autowired是我们最常用的注入注解,但它内部查找 Bean 的规则,很多人并没有完全搞懂。我遇见过一个真实的生产事故:一个接口有两个实现类,Spring 启动时直接爆了NoUniqueBeanDefinitionException,同事一脸懵:“我明明把想要的实现类作为字段名了,怎么还报错?”

这就需要了解@Autowired的查找顺序了。

@Autowired默认的查找策略是byType(按类型)。具体流程是:

  1. 先按类型UserService在容器中查找。
  2. 如果找到且只有一个,直接注入。
  3. 如果有多个,尝试按@Qualifier指定的名称过滤。
  4. 如果没有@Qualifier,尝试按字段名/参数名过滤(byName兜底)。

刚才那个事故的根源是:接口有两个实现类,按类型查找已经找到了两个候选,此时 Spring 会尝试用字段名去匹配,字段名是userService,但两个实现类的 Bean 名称分别是userServiceImplA和userServiceImplB,都没有匹配上,于是报错。

解决方案有两个:

方案一:给某个实现类标注@Primary,表示它是主要候选人,多选一时优先选它:

@Service @Primary public class UserServiceImplA implements UserService { // ... }

方案二:在注入点明确指定@Qualifier:

@Autowired @Qualifier("userServiceImplB") private UserService userService;

这里要注意,@Qualifier的值默认是类名首字母小写,也可以显式指定 Bean 名称。

还有个隐性细节:如果用了构造器注入,且构造函数有多个参数,@Qualifier要放在参数上:

public OrderService(@Qualifier("userServiceImplB") UserService userService) { this.userService = userService; }

7. Bean 的作用域和延迟加载:单例之外的几种玩法

Spring 中的 Bean 默认是**单例(Singleton)**作用域,这意味着整个容器共享一个实例。但有些场景下你需要不同的作用域。

常见的几种作用域:

  • singleton:默认,每个容器一个实例,整个应用共享。
  • prototype:每次获取都创建一个新的实例。
  • request:每个 HTTP 请求一个实例(仅 Web 应用)。
  • session:每个 HTTP Session 一个实例(仅 Web 应用)。
  • application:每个 ServletContext 一个实例(仅 Web 应用)。

request和session作用域在 Spring MVC 里比较常用。比如用户的购物车,往往需要存在 session 里,你会这么写:

@Component @Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS) public class ShoppingCart { // ... }

注意那个proxyMode = ScopedProxyMode.TARGET_CLASS,它的作用是生成一个代理对象注入到依赖方。为什么需要代理?因为单例 BeanOrderController在创建时需要注入ShoppingCart,但ShoppingCart是 session 作用域——在容器启动时 session 还不存在呢!代理对象作为替身注入,真正调用方法时才去 session 里取真实实例。这个设计非常巧妙,类似懒加载的思想。

再说延迟加载。Spring 默认在启动时立即创建所有非懒加载的单例 Bean(通过 preInstantiateSingletons 阶段),所以一个大型 Spring Boot 项目启动慢,很大一部分时间花在创建各种 Bean 上。如果你希望某些 Bean 在使用时才初始化,可以加@Lazy:

@Component @Lazy public class HeavyComponent { // 这个 Bean 的创建会延迟到第一次被使用时 }

@Lazy也可以加到注入点上,表示注入一个代理对象,真正调用时才去解析真实 Bean:

@Autowired @Lazy private HeavyComponent heavyComponent;

但我要给个实操建议:除非确实有性能痛点,否则不要滥用@Lazy。它会让你在启动时发现不了潜在问题,比如 Bean 依赖错误、初始化失败等,都是使用时才报,排查成本更高。

8. 手写一个微型 IoC 容器:把核心原理落成代码

很多时候觉得自己懂了一个概念,但要把它写出来才发现一知半解。建议你尝试手写一个微型 IoC 容器,不用复杂,几十行代码即可。我在这里提供一份核心代码,你照着试一遍,对原理的理解会比盯一天源码更深刻。

先说思路:需要一个容器(Map 存 Bean 名称到实例)、一个扫描机制(找到带特定注解的类)、一个创建机制(实例化并完成依赖注入)、一个依赖解析机制(根据类型或名称找到已存在的 Bean)。

首先是待注入的注解:

@Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) public @interface MyInject { }

容器核心:

public class MyIocContainer { private Map<String, Object> beans = new HashMap<>(); // 注册 Bean public void register(Class<?> clazz) throws Exception { String beanName = toLowerFirst(clazz.getSimpleName()); Object instance = clazz.getDeclaredConstructor().newInstance(); beans.put(beanName, instance); // 完成属性注入 injectFields(instance); } private void injectFields(Object instance) throws Exception { for (Field field : instance.getClass().getDeclaredFields()) { if (field.isAnnotationPresent(MyInject.class)) { String fieldName = field.getName(); if (beans.containsKey(fieldName)) { field.setAccessible(true); field.set(instance, beans.get(fieldName)); } } } } public <T> T getBean(String name) { return (T) beans.get(name); } private String toLowerFirst(String name) { return Character.toLowerCase(name.charAt(0)) + name.substring(1); } }

创建容器时先注册没有依赖的 Bean,再注册有依赖的 Bean,注入逻辑就能正常工作:

public class Main { public static void main(String[] args) throws Exception { MyIocContainer container = new MyIocContainer(); container.register(UserService.class); container.register(OrderService.class); OrderService orderService = container.getBean("orderService"); orderService.doWork(); } }

你这个微缩版容器虽然简陋,但已经把 IoC 的精髓表达出来了:对象不再自己 new 依赖,而是容器创建好后按字段注入。把它和 Spring 源码对比,你会发现 Spring 不过是把 Map 换成了更复杂的缓存体系、把反射创建换成了支持构造器选择的过程、把注解扫描换成了类路径扫描,骨架是相通的。

9. 常见问题与排查技巧:NoSuchBeanDefinitionException 等高频报错排查

这里汇总一些实际项目中高频出现的 Spring IoC/DI 相关报错和排查思路,做成速查表,方便你遇到问题时快速定位。

报错信息出现原因排查思路
NoSuchBeanDefinitionException容器里没有找到对应的 Bean检查类是否加了@Component/@Service等注解;检查启动类包路径是否覆盖到了该类所在包;检查是否被@ConditionalOnXxx条件排除
NoUniqueBeanDefinitionException按类型找到多个候选 Bean给目标类加@Primary,或在注入点加@Qualifier指定具体名称
BeanCurrentlyInCreationException构造器注入导致循环依赖调整依赖结构,或用 Setter/字段注入替代构造器注入(但这治标不治本,应从代码设计层面消除循环依赖)
UnsatisfiedDependencyException依赖无法满足查看 Cause 定位具体是哪个依赖注入失败,通常伴随其他异常信息
BeanDefinitionOverrideException同名 Bean 被重复定义Spring Boot 2.1+ 默认禁止同名 Bean 覆盖,检查是否有多个配置类定义了同一个同名 Bean
Autowired field xxx is null对象不是由 Spring 管理的,或者字段注入失败了检查对象是否通过new创建出来的,如果是那样 Spring 管不到它;检查类是否被代理后注入了别的字段

排查这类问题的通用套路是三步走:

  1. 看启动日志里有没有异常上下文,Spring 的报错机制很友好,通常会指出“在上面的错误信息中,由xxx的yyy属性导致”。
  2. 打开断言配置,在application.yml中设置spring.main.lazy-initialization=false(默认就已关闭懒加载),确保启动时就能暴露问题。
  3. 借助 IDE 的可视化工具,IDEA 的 Spring 窗口能看到所有 Bean 的依赖关系图,用它对定位复杂问题了如指掌。

10. 与 Spring Boot 自动配置的衔接:为什么你引入依赖就有 Bean

在用 Spring 的时候,你只需要在 pom 里加一个依赖,项目启动后容器里就自动有了很多 Bean,比如引入spring-boot-starter-web就有DispatcherServlet、RestControllerAdvice等。这些 Bean 是谁创建的?答案就是 Spring Boot 的自动配置机制,它的底层依然是我们上面讲的那些概念——@Configuration+@Bean+ 条件装配。

Spring Boot 的@SpringBootApplication注解由三个注解组合而成:

  • @SpringBootConfiguration:本质是@Configuration,表明这是配置类。
  • @EnableAutoConfiguration:开启自动配置,这是核心。
  • @ComponentScan:扫描当前包及子包下的所有 Bean。

@EnableAutoConfiguration背后通过@Import(AutoConfigurationImportSelector.class)加载一个全局配置文件,里面列了所有自动配置类的全限定名。Spring Boot 启动时挨个尝试加载这些配置类,但每个配置类里都有一堆@ConditionalOnXxx条件注解,比如@ConditionalOnClass(当前 classpath 下有某个类才生效)、@ConditionalOnMissingBean(容器里还没有某个 Bean 才生效)、@ConditionalOnProperty(配置了某个属性才生效)。

所以你在 pom 里加spring-boot-starter-data-redis后,classpath 下出现了RedisTemplate相关的类,RedisAutoConfiguration里的@ConditionalOnClass条件就满足了,RedisTemplate这个 Bean 就被自动创建。这就是为什么“引入依赖就在容器里有了 Bean”的根本原因。

这个机制和手写 IoC 原理一脉相承:容器负责 Bean 的创建和注入,而“创建哪些 Bean”的决策由条件和配置共同决定。如果你能理解这一点,就不会对 Spring Boot 的“魔法”感到神秘,反而能看到它背后是建立在 IoC/DI 这套基础能力之上的。

11. 实战经验分享

用 Spring 这些年,踩过不少 IoC/DI 相关的坑,分享几条最实在的经验。

第一,能不用@Autowired字段注入就不用。不是说它会出错,而是它让类缺少“可见的依赖入口”。我接手过一个老项目,一个 Service 里十几个@Autowired字段,还互相耦合,想测试根本没门,后来花了大力气重构才把依赖关系理清。新写的代码一律用构造器注入,配合 Lombok 的@RequiredArgsConstructor,代码又少又清晰。

第二,不要为了“消除循环依赖”而消除循环依赖。循环依赖通常是代码设计出了问题——模块边界没切好、职责划分不清楚。与其用@Lazy或者改成 Setter 注入硬解,不如花时间调整结构。就我个人经验而言,90% 的循环依赖都能通过提取公共模块、下沉数据处理层来从根源解决。

第三,有效利用ApplicationContext获取 Bean 时要克制。毕竟是绕过依赖注入直接向容器伸手要对象,偶尔在极少数工具类中可用,但一旦在业务代码里大量出现context.getBean(),就要警惕了——说明你没有把依赖关系表达出来,而是在运行时动态拼装。依赖注入的本质追求是让依赖关系显式化,而不是隐藏起来。

第四,理解“容器启动就是一次完整的体检”。设置spring.main.allow-circular-references=false(Spring Boot 2.6+ 默认禁止循环依赖),让容器在启动时把依赖校验做彻底。别为了临时跑起来而放开限制,那样只是把问题藏在了运行期。

把 IoC 和 DI 吃透,不只是为了写代码顺畅,更是一种设计思维的转变。真正的高手写代码时,眼里看到的不是一个一个的类,而是类与类之间的依赖关系网。这张网如何构建、如何解耦、如何测试,才是 Spring 这套框架想教给你的核心心法。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询