news 2026/10/5 8:00:38

Spring IoC与DI完全指南:深入理解容器原理与Bean生命周期

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring IoC与DI完全指南:深入理解容器原理与Bean生命周期

1. 先从设计思想说起:为什么Spring要搞出IoC和DI

1.1 安全感缺失的地方:2004年之前,Java程序员最头痛的问题

如果你是老Java程序员,一定对下面这种代码无比熟悉:

public class OrderService { private UserDao userDao; private PaymentService paymentService; public OrderService() { // 直接在构造函数里new this.userDao = new UserDao(); this.paymentService = new PaymentService(); } public void createOrder(String userId, double amount) { userDao.checkUser(userId); double fee = paymentService.calculateFee(amount); // ... 业务逻辑 } }

这段代码看着没什么问题,对吗?但等你的项目到了第20个Service、第50个依赖对象时,问题就全暴露了。

首先,OrderService和UserDao、PaymentService是强耦合关系。哪天你想把UserDao换成UserDaoRedisImpl,就得改OrderService的构造代码。其次,测试困难——你想给OrderService做单元测试,结果它连带着把UserDao和PaymentService全实例化了,你根本没法轻易地传入Mock对象。最后,你写的每个Service都要自己管new、管生命周期、管销毁,这完全是在重复造轮子,而且每个人都按自己习惯造,代码风格五花八门。

1.2 控制反转到底反转了什么

"控制反转"这四个字,我见过很多人背了五年都没真正想明白。把它翻译成人话就是:对象创建和依赖管理的控制权,从开发者手里,反转给了Spring容器。

传统写法是你写代码new对象,你自己决定在哪儿创建、什么时候创建、创建成什么样。IoC之后,这些事全部交给Spring容器,你在代码里只声明"我需要一个UserDao",容器就给你一个。你没创建任何东西,只是告诉容器"给我吧"——这就是反转:控制权从主动创建变成了被动接收。

有人会问,这不就是把new换个地方吗?不是,它解决的是整个架构层面的问题。举个例子:你的Service层需要访问数据库。如果用的是MySQL,你得手动创建MySQL连接;后来项目切PostgreSQL,你要改所有Service。有了IoC容器后,你只需要在配置或注解里声明数据源的类型,容器在启动时帮你装配,业务代码一行不用动。

1.3 依赖注入:控制反转的具体落地姿势

控制反转是设计思想,依赖注入是实现方式。Spring通过依赖注入来完成IoC:当一个对象需要另一个对象时,不是自己创建,而是由容器"注入"进来。注入方式已经在骨架代码里声明的三种——构造器注入、Setter注入、字段注入,后面我会逐个用代码演示。

用一个楼梯间类比:传统方式相当于你自己掏钥匙开每一扇门,从一楼走到顶楼,每一层的门你都得管。IoC之后呢,你只管按电梯楼层按钮,电梯系统自动把你送到指定楼层,中间的门它替你开。代码里的Service就是按电梯的人,Spring容器就是那部电梯。

1.4 一个工厂也干得了这事,为何要引入容器

可能有人觉得,"对象交给工厂管理不就行了?"是,工厂模式确实能解耦,但工厂模式解决的问题和容器完全不同。工厂通常只解决创建逻辑的集中,而Spring容器是一个完整的对象生命周期管理平台:它管对象的创建、属性填充、初始化方法调用、销毁方法调用,还管作用域、代理、AOP、事务等一整套生态系统。

还有一个关键点:工厂是硬编码的,你写UserDao.getInstance(),工厂就返回一个UserDao实例,它不知道你的依赖图。而Spring容器启动时会扫描所有Bean定义(BeanDefinition),构建一张完整的依赖关系图,然后按图装配。这张依赖图就是容器最大的价值:它能自动分析出"创建OrderService必须先创建UserDao,但UserDao又依赖DataSource,DataSource依赖连接池",于是按序实例化。工厂模式做不到这一点。

2. 把Spring容器底层机制拆开讲:一段代码是如何变成Bean的

2.1 玩命绕场热身:BeanDefinition、BeanFactory与ApplicationContext

在Spring里有两个核心接口,很多人一直分不清,面试也常问:

  • BeanFactory:IoC容器的顶层接口,提供了最基础的能力——getBean()、containsBean()、isSingleton()。它是个基础设施,不面向普通开发者。
  • ApplicationContext:是BeanFactory的子接口,做了大量增强——支持国际化、事件发布、资源加载等。你平时用的AnnotationConfigApplicationContext、ClassPathXmlApplicationContext都属于它。

这里有个容易混淆的概念:ApplicationContext是接口,AnnotaionConfigApplicationContext是实现类,它内部组合了一个DefaultListableBeanFactory——这个才是真正的容器本体。所以你在绝大多数场景下操作的其实是DefaultListableBeanFactory,只是ApplicationContext给你包了一层更方便的壳。

另一个核心概念是BeanDefinition。Spring把"一个Bean应该长什么样"用元数据来描述,比如:类是哪个、是单例还是原型、懒加载还是非懒加载、初始化方法是什么。这些元数据汇总成一个BeanDefinition对象,容器拿到后,才能按图索骥去实例化。

我拿盖房子类比:BeanDefinition是施工图纸,BeanFactory是施工队,ApplicationContext是装修公司(包设计、包工、还包验收),你住进Bean实例时剩下的全是拎包入住。

2.2 Bean的一生:从定义到使用再到销毁

一个Bean在Spring容器里完整走一遍,大概要经历以下阶段:

  1. 解析配置(XML、注解或Java Config),生成BeanDefinition
  2. 调用BeanFactoryPostProcessor,这一步可以修改BeanDefinition(比如PropertySourcesPlaceholderConfigurer就是在这里把${jdbc.url}占位符替换成真实值)
  3. 按BeanDefinition实例化对象(反射,本质上就是new)
  4. 属性填充(也就是执行你写的那些注入)
  5. 调用BeanPostProcessor#postProcessBeforeInitialization
  6. 执行afterPropertiesSet()或init-method
  7. 调用BeanPostProcessor#postProcessAfterInitialization,这一步是AOP代理生成的关键时机
  8. Bean就绪,可以被使用
  9. 容器关闭时,执行destroy-method或DisposableBean#destroy()

特别值得注意第5到第7步:BeanPostProcessor是整个Spring扩展能力最强大的节点。AOP的代理对象、@Autowired的解析、@Async的代理,全都是通过注册各种BeanPostProcessor在背后悄悄完成的。

如果你把Bean生命周期面试题答到这层细节,至少能加一个档次的分——大多数人只会说"实例化、属性赋值、初始化、销毁"四部曲,你能说出BeanPostProcessor分两步、AOP代理在最后一步生成,这已经能拉开差距。

2.3 三级缓存与循环依赖:Spring如何解开死结

这是Spring最出名的一个底层问题,也是面试高频题。既然标题说"完全理解",我就把它彻底讲透。

假设有两个类:

@Service public class AService { @Autowired private BService bService; } @Service public class BService { @Autowired private AService aService; }

A依赖B,B依赖A。这是"鸡生蛋、蛋生鸡"的问题。如果一个菜鸟来设计,直接写new AService(),然后需要B时new BService(),B又需要A,又new AService(),无限递归,栈溢出。

Spring的解法是三级缓存。它是三个Map,定义在DefaultSingletonBeanRegistry里:

// 一级缓存:存放已经完整创建好的单例Bean Map<String, Object> singletonObjects; // 二级缓存:存放早期暴露的Bean(已实例化、但属性还没填充完) Map<String, Object> earlySingletonObjects; // 三级缓存:存放ObjectFactory,用来生成早期Bean Map<String, ObjectFactory<?>> singletonFactories;

处理A和B互相依赖的过程大致是这样:

  1. Spring开始创建AService,实例化出A的原始对象,还没填属性,把这个对象包进一个ObjectFactory,丢进三级缓存singletonFactories。
  2. 开始给A填充属性,发现需要BService,于是去创建BService。
  3. BService实例化出原始对象,也丢进三级缓存。
  4. 开始给B填充属性,发现需要AService。此时一级缓存没有A,二级缓存也没有A,但从三级缓存找到了A的ObjectFactory,调用它的getObject()拿到A的早期引用,放进二级缓存,同时把三级缓存里的A删掉。
  5. B拿到A的引用,属性填充完成,执行初始化,完整创建成功,放进一级缓存。
  6. 回到A的创建流程,此时把BService注入到A里,A的属性也填完了,执行初始化,完整创建成功,放进一级缓存。

为什么需要三级而不是二级?因为三级缓存里有ObjectFactory,它是在创建完原始对象后、属性填充前就生成的。AOP代理的生成往往需要在这时候介入——如果A需要被代理,二级缓存里的引用应该是代理对象,而不是原始对象。如果你只有二级缓存,你没法提前知道到底要不要生成代理,因为代理增强的时机在BeanPostProcessor最后一步。三级缓存的ObjectFactory就是留一个后门,等真正需要暴露引用时才决定暴露原始对象还是代理对象。

这里顺带说一个很重要的点:Spring只解决单例模式下、非构造器注入的循环依赖。构造器注入是没法解决的,因为构造器在实例化阶段就需要参数,连原始对象都造不出来,谈不上"提前暴露"。你如果遇到BeanCurrentlyInCreationException,第一反应就该是"有人用构造器注入了循环依赖"或"作用域不是单例"。

2.4 默认单例也藏着门道:Bean的作用域

Spring的Bean默认是单例的,也就是singleton作用域。这意味着容器里只有一个实例,所有注入的地方拿到的是同一个对象。这个特性省内存、保证性能,但也埋了一个坑:单例Bean如果注入了原型Bean,每次拿到的还是同一个原型Bean。

为什么?因为原型Bean是在注入那一刻被创建的,注入完成后,单例Bean持有的只是一个普通引用。你后面再从容器里拿原型Bean,拿到的是新实例,但单例Bean里的引用不会更新。解决办法是使用ObjectProvider<T>或者@Lookup方法,让每次调用时都从容器重新获取原型实例。

@Component public class SingletonService { @Autowired private ObjectProvider<PrototypeService> prototypeServiceProvider; public PrototypeService getPrototypeService() { return prototypeServiceProvider.getObject(); } }

大多数入门教程不会讲这个坑,但实际项目里,如果你把一个原型Bean注入到单例Bean里,写出来的代码在某些场景下会出现"每次都复用同一个对象"的诡异问题。我在第一家公司就吃过这亏,调了一天,后来打印了Bean的hashCode才定位到原因。

作用域速查表:

作用域说明常用场景
singleton每个容器一个实例,默认值无状态服务、DAO、配置类
prototype每次获取都新建实例有状态业务对象,模型对象
request每个HTTP请求一个实例Web层,一次请求内共享
session每个HTTP会话一个实例会话级数据
application每个ServletContext一个实例全局共享数据
websocket每个WebSocket会话一个实例实时通信场景

3. 手把手实操:启动一个带IoC和DI的Spring项目

3.1 准备工作:不靠IDE模板,徒手搭一个最小工程

很多人用Spring Boot的脚手架一键生成项目,反而对最底层的Spring容器没什么体感。我建议你手动建一个工程,把依赖关系看个明白。

用Maven建一个普通Java项目,pom.xml里引入最基础的东西:

<dependencies> <!-- 核心容器模块:IoC/DI全靠它 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.3.39</version> </dependency> <!-- 注解驱动的依赖,@Component @Autowired等 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-beans</artifactId> <version>5.3.39</version> </dependency> <!-- 常用工具类,不是必需但建议加上 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-core</artifactId> <version>5.3.39</version> </dependency> </dependencies>

然后写一个最简单的启动入口:

public class Main { public static void main(String[] args) { // 创建一个基于注解扫描的容器 AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext("com.example"); // 直接从容器拿Bean,注意:这里没有new OrderService orderService = context.getBean(OrderService.class); orderService.createOrder("U10001", 199.00); context.close(); } }

就这么一小段代码,背后发生的事情是:容器扫描com.example包,找到所有标注了@Component或派生注解的类,解析它们的依赖关系,创建实例,注入依赖,全部完成后,getBean才能直接拿到一个完整可用的OrderService。

3.2 三种注入方式:优缺点和适用场景

先准备一个基础场景。假设我们有OrderService依赖UserService和OrderDao,三个类都用@Service或@Repository标注:

@Repository public class OrderDao { public void saveOrder(String orderId) { System.out.println("订单已保存: " + orderId); } } @Service public class UserService { public boolean isUserValid(String userId) { return userId != null && !userId.isEmpty(); } }

第一种:字段注入(Field Injection)。

@Service public class OrderService { @Autowired private UserService userService; @Autowired private OrderDao orderDao; public void createOrder(String userId, double amount) { if (!userService.isUserValid(userId)) { throw new IllegalArgumentException("非法用户"); } String orderId = "ORD" + System.currentTimeMillis(); orderDao.saveOrder(orderId); } }

这是最简洁、也最容易上手的写法。三个字:短、平、快。Spring官方其实不推荐这种方式,因为字段注入有几个问题:无法声明final,意味着字段可以中途被替换;无法轻易构造一个没有依赖的纯净实例;IDE侧边栏一眼看不到类的依赖有哪些。但实际项目里依然大量存在,因为它省事、代码量小。

第二种:构造器注入(Constructor Injection)。

@Service public class OrderService { private final UserService userService; private final OrderDao orderDao; public OrderService(UserService userService, OrderDao orderDao) { this.userService = userService; this.orderDao = orderDao; } }

Spring 4.3以后,如果类只有一个构造器,可以省略@Autowired注解,Spring会自动用这个构造器注入。构造器注入的好处是:依赖不可变(final)、创建即完整、测试时直接new一个实例传入Mock依赖即可。这个才是Spring官方推荐的姿势。新项目我建议一律用构造器注入。

第三种:Setter注入(Setter Injection)。

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

Setter注入允许对象创建后再替换依赖,适合那些“依赖可选”或“允许运行时替换”的场景。缺点是无法保证依赖一定被注入,可能会出现NPE。老式XML配置里常常见到,现在Java Config项目里用得越来越少。

三张姿势对比:

维度字段注入构造器注入Setter注入
可读性简洁依赖全在构造器,非常清晰依赖分散
final支持不支持支持不支持
测试难注入Mock易注入Mock可用想测试,可后期替换
Spring推荐否是不推荐作为一个强制手段

3.3 配置革命:从XML注解到JavaConfig

Spring IoC的配置方式,是这条框架最直观的演进史。最老的项目里,所有Bean都写在applicationContext.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="orderDao" class="com.example.dao.OrderDao"/> <bean id="userService" class="com.example.service.UserService"/> <bean id="orderService" class="com.example.service.OrderService"> <constructor-arg ref="userService"/> <constructor-arg ref="orderDao"/> </bean> </beans>

XML的最大问题是写起来冗长、无法在编译期校验类型。Spring 2.5开始引入@Component、@Autowired等注解,配置量明显减少。到Spring 3.0的JavaConfig出现后,配置彻底变成了代码:

@Configuration @ComponentScan("com.example") public class AppConfig { }

一个@Configuration加一个@ComponentScan,整个容器的扫描装配逻辑就齐了。为什么JavaConfig能赢?因为它本身是Java代码,可以享受IDE的编译提示、重构支持、debug能力,不需要在XML和Java之间来回切换上下文。

再往后的Spring Boot更是把自动配置做成了艺术——@SpringBootApplication一个注解就包含了@Configuration、@ComponentScan和@EnableAutoConfiguration,容器自动装配那些你常用的模块。

3.4 手动注册BeanDefinition:面试官都很少问的野路子

除了@Component和@Bean,Spring还允许你完全用编程方式注册Bean。这个知识点很少有人提,但对理解IoC的本质很有帮助:

DefaultListableBeanFactory factory = new DefaultListableBeanFactory(); BeanDefinitionBuilder builder = BeanDefinitionBuilder .genericBeanDefinition(OrderService.class) .addConstructorArgValue("U10001") .addPropertyValue("name", "测试订单服务"); factory.registerBeanDefinition("orderService", builder.getBeanDefinition()); OrderService orderService = factory.getBean("orderService", OrderService.class);

看清楚这段代码在做什么:没有任何注解、没有任何XML,你手动构造了一个BeanDefinition,手动注册到工厂,然后从工厂拿Bean。这就是IoC容器最骨感的模样——所谓的注解扫描,其实就是把"找到标注了@Component的类、创建BeanDefinition、注册进容器"这三件事做了自动化而已。

如果你能在一张白纸上写出这段代码,说明你理解的不是"Spring怎么用",而是"Spring是什么"。

4. 高级扩展:IoC容器如何影响你的代码结构

4.1 为什么说IoC是AOP的基础

AOP(面向切面编程)是Spring的另一半江山,而AOP离不开IoC。因为AOP要对Bean生成代理对象,但代理由谁生成?又由谁管理生命周期?答案是容器。

一个标准流程是这样的:Bean通过IoC容器创建,经过BeanPostProcessor这个扩展点,AOP框架在这里检测到Bean有没有匹配的切面,如果有,就生成一个Proxy对象替换掉原来的Bean,然后放入容器。业务代码里注入的其实是代理对象,但你完全没有感知——表面看是IoC容器在管理Bean,背后AOP已经悄悄替换了Bean。

这也是为什么Spring的设计核心是"容器+扩展点"。你不需要在业务代码里写任何代理逻辑,切面通过配置声明式地挂上去,IoC容器负责把所有零件组装好。

4.2 关于Bean的自动装配:你有多少种玩法

除了@Autowired按类型注入,Spring还提供了几种"装配"策略,理解它们能帮你写出更灵活的代码:

  • 按类型(byType):@Autowired的默认行为,容器里只有一个类型时直接注入;有多个同类型Bean时会根据@Primary或字段名来决策。
  • 按名称(byName):@Qualifier("userService")或@Resource(name = "userService")可以指定名字。
  • @Primary:声明当有多个同类型Bean时,优先选谁。
  • 集合注入:比如List<UserService>会把所有UserService类型的Bean全部注入。这个用处很大,比如做策略模式时,把各种策略实现类一次性撸进来。

我实际项目里最常用的组合是"构造器注入+@Qualifier"。一个接口多个实现类是常态,比如支付接口有AlipayService、WechatPayService、UnionPayService,你可以在运行时按条件选择策略,也可以用Map<String, PaymentService>把所有实现类按名字放进去。

@Service public class PaymentRouter { private final Map<String, PaymentService> paymentServiceMap; public PaymentRouter(Map<String, PaymentService> paymentServiceMap) { this.paymentServiceMap = paymentServiceMap; } public void pay(String channel, double amount) { PaymentService service = paymentServiceMap.get(channel); if (service == null) { throw new IllegalArgumentException("不支持的支付渠道: " + channel); } service.pay(amount); } }

这个例子把IoC容器活生生变成一个注册中心——所有支付服务全都注册进Map,渠道路由选择纯粹变成Map取值。如果新增一个支付渠道,只需要加一个实现类,路由代码一分不用改。这就是IoC给你带来的扩展性体验。

4.3 懒加载与依赖工厂:跑起来前要想清楚的两件事

Spring的Bean默认在容器启动阶段就全部创建完毕(非懒加载)。好处是启动时就能发现配置错误、依赖缺失等问题;代价是启动时间变长。如果你有大量重型Bean,可以设置@Lazy:

@Lazy @Service public class HeavyService { }

懒加载的本质是"第一次被使用时才创建"。但注意:懒加载会延迟报错时机,如果Bean有问题,可能服务跑了很多天才暴露。生产环境建议只在确实必要的时候使用懒加载。

ObjectProvider<T>值得在这个阶段一并介绍。它相当于一个"延迟的依赖解析器":

@Service public class NotificationService { private final ObjectProvider<SmsSender> smsSenderProvider; public NotificationService(ObjectProvider<SmsSender> smsSenderProvider) { this.smsSenderProvider = smsSenderProvider; } public void send(String message) { SmsSender sender = smsSenderProvider.getIfAvailable(); if (sender == null) { throw new IllegalStateException("没有可用的短信发送器"); } sender.send(message); } }

getIfAvailable()可以在容器里没有对应Bean时返回null,避免启动时直接报错。这比直接注入SmsSender灵活多了,在编写框架性质代码时非常顺手。

5. 实战记录:一个典型Spring项目的装配全过程

5.1 场景设计:模拟一个订单服务从0到1

我准备了一个更接近真实项目的例子,把前面讲的概念全串起来。

先建一个电商订单模块,结构大概是:

  • OrderController——接收请求(注:纯演示,不依赖Spring MVC,只用容器)
  • OrderService——业务逻辑
  • OrderDao——数据访问
  • PaymentService——支付策略接口,以及两个实现

目录结构:

com.example ├── AppConfig.java ├── Main.java ├── controller │ └── OrderController.java ├── service │ ├── OrderService.java │ ├── PaymentService.java │ ├── AlipayService.java │ └── WechatPayService.java └── dao └── OrderDao.java

核心代码:

@Configuration @ComponentScan("com.example") public class AppConfig { }
public interface PaymentService { void pay(double amount); } @Service public class AlipayService implements PaymentService { @Override public void pay(double amount) { System.out.println("支付宝支付: " + amount); } } @Service public class WechatPayService implements PaymentService { @Override public void pay(double amount) { System.out.println("微信支付: " + amount); } }
@Repository public class OrderDao { public void save(String orderId) { System.out.println("订单落库: " + orderId); } }
@Service public class OrderService { private final OrderDao orderDao; private final Map<String, PaymentService> paymentServiceMap; public OrderService(OrderDao orderDao, Map<String, PaymentService> paymentServiceMap) { this.orderDao = orderDao; this.paymentServiceMap = paymentServiceMap; } public void createOrder(String userId, String channel, double amount) { String orderId = "ORD" + System.currentTimeMillis(); orderDao.save(orderId); PaymentService paymentService = paymentServiceMap.get(channel); if (paymentService == null) { throw new IllegalArgumentException("渠道不存在: " + channel); } paymentService.pay(amount); } }
@Controller public class OrderController { private final OrderService orderService; public OrderController(OrderService orderService) { this.orderService = orderService; } public void createOrder(String userId, String channel, double amount) { orderService.createOrder(userId, channel, amount); } }

最后是启动入口:

public class Main { public static void main(String[] args) { try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class)) { OrderController controller = context.getBean(OrderController.class); controller.createOrder("U10001", "alipay", 99.00); controller.createOrder("U10002", "wechat", 199.00); } } }

运行结果:

订单落库: ORD1718... 支付宝支付: 99.0 订单落库: ORD1719... 微信支付: 199.0

你注意一下这段代码里没有一个new关键字。OrderController、OrderService、OrderDao、两个支付服务,全是被容器创建并注入的。Map<String, PaymentService>的注入尤其值得品味——Spring会自动把两个支付类的名字作为key放进Map。这就是IoC容器的"自动化"带给你最直观的体验。

5.2 装配路径复盘:容器在启动时到底做了哪些决策

拿上面这个例子,容器启动时的决策过程大致是这样的:

  1. 解析AppConfig,发现@ComponentScan("com.example"),开始扫描包。
  2. 扫描结果:OrderController、OrderService、OrderDao、AlipayService、WechatPayService五个类被识别为Bean。
  3. 构建BeanDefinition:几个类都是单例、非懒加载。
  4. 按依赖关系排序:OrderService构造器需要OrderDao和Map<String, PaymentService>,所以容器先创建OrderDao和两个支付服务。
  5. 实例化并注入:先创建OrderDao,再创建两个支付服务,然后为OrderService创建实例,从容器中找OrderDao和两个支付服务,注入进去。
  6. 继续往上:创建OrderController,注入OrderService。
  7. 所有Bean全部就绪,放入一级缓存singletonObjects。

这就是一个容器"启动即装配"的过程。如果你把Spring Boot项目跑起来看日志,那些"Tomcat started on port 8080"之前,就是这整套流程在做预热。

5.3 为什么我推荐你放弃字段注入

在实操部分,我还有一个强烈的个人建议:新代码一律用构造器注入。理由不只是"官方推荐",而是我在生产环境里真真切切踩过的坑。

第一,字段注入会让Bean依赖变的“隐形”。一个类有10个@Autowired字段,你扫一眼代码是看不出它需要谁先创建的,IDE的调用关系也断了。构造器注入把依赖全部放在构造器里,一目了然。

第二,字段注入没法做final。某些业务场景需要不可变的依赖,比如一个在并发环境下要被多个线程共享的Service,它的依赖如果中途换掉了,很容易出诡异问题。

第三,测试的友好度。构造器注入的类,你在单元测试里直接new OrderService(mockOrderDao, mockPaymentMap)就完了。字段注入则必须借助ReflectionTestUtils或者启动Spring容器才能测,太费劲。

当然,字段注入也有它的优势——代码量少。写小型Demo、个人项目时,怎么方便怎么来我完全没意见。但涉及团队协作、生产代码,我建议统一用构造器注入,减少讨论成本也是价值。

6. 常见异常与翻车现场:你一定会遇到的问题

6.1 NoSuchBeanDefinitionException:找不到Bean的常见四类原因

这个异常是IoC新手最常碰到的。报错信息跑不掉这几句话:No qualifying bean of type 'xxx' available或NoSuchBeanDefinitionException。原因通常是:

第一类:类上忘了加注解。最常见,没有@Component/@Service/@Repository/@Controller,容器压根不知道这个类的存在。解决:加上对应注解。

第二类:没扫描到。你类上明明有注解,但扫不到。通常是@ComponentScan的包路径和你的类所在包不一致。比如@ComponentScan("com.example.aaa"),你的类在com.example.bbb里,扫了个寂寞。解决:把包路径改成上一级com.example或直接并列多个路径。

第三类:类型不匹配。接口注入时,容器里有多个实现类但没有@Primary或@Qualifier,Spring不知道怎么选。或者你声明的类型是接口,扫描到的却是某个实现类的子类,类型对不上。解决:添加@Qualifier或@Primary,或者注入List<Interface>/Map<String, Interface>全部接收。

第四类:Bean的scope问题。你在@SessionScope或@RequestScope的Bean里注入了一个singleton范围里才存在的Bean,或者反之,scope生命周期对不上。解决:检查作用域声明。

排查思路:看一眼报错信息中提到的类,先确认类有没有被扫描到(可以在容器启动后打印所有Bean名),再确认有没有多个实现类。搞一个ApplicationRunner,用context.getBeanDefinitionNames()把所有注册的Bean打出来,一眼就能看出来少了谁。

6.2 BeanCurrentlyInCreationException:循环依赖的解药与毒药

如果你遇到BeanCurrentlyInCreationException,基本是循环依赖问题,而且是Spring解决不了的那种。主要有这么几种:

  • 构造器注入的循环依赖:前面讲过,Spring三级缓存救不了,因为构造器在实例化阶段卡死。
  • 原型作用域的循环依赖:三级缓存只解决了单例,原型Bean创建时不会"提前暴露",所以绕不过。
  • @Async / @Transactional等代理类的循环依赖:有些代理模式会把Bean提前代理,破坏三级缓存的流程,导致循环依赖失败。

解决方案优先级:

  1. 重构代码:把互相依赖的逻辑抽到一个新类里,打破环。
  2. 改为Setter注入或字段注入:让Spring能提前暴露原始对象。
  3. 使用@Lazy:在循环依赖的注入点上加@Lazy,让Spring注入一个代理对象,延迟真正的依赖解析。

我见过很多团队为了省事,一遇到循环依赖就加@Lazy。短期能用,长期会隐藏设计问题。建议当成临时方案,最终目标还是消解依赖环。

6.3 单例Bean持有PrototypeBean:为什么你的“新对象”不新了

这个问题很多三年经验以下的开发也没遇到过,但遇到了非常迷惑。

场景:一个SingletonBean注入了PrototypeBean,然后每次调用SingletonBean.someMethod()都期待PrototypeBean是新实例。结果打印的PrototypeBean的hashCode永远一样,这还怎么玩?

原因前面提过:注入发生在容器创建单例Bean的时候,那次创建后,PrototypeBean实例就被固定住了。容器后续再创建新的PrototypeBean,也不会换掉这个引用。

三种解法:

  1. ObjectProvider<T>:每次getObject()重新拿一个。
  2. @Lookup方法注入:在单例Bean里声明一个@Lookup方法,容器会动态生成一个子类,每次调用都走容器获取。
  3. ApplicationContextAware:不推荐,因为它把代码和容器耦合了,能让单元测试变得很难写。

我实测下来,ObjectProvider是最干净的方案。那段代码前面展示过了,拿到手就能用。

6.4 @Autowired与@Resource到底选谁

这个问题面试被问烂了,但很多人说不清楚。最核心的区别:

  • @Autowired是Spring提供的,默认按类型(byType)注入,类型不唯一再按名字(byName)匹配。
  • @Resource是JSR-250标准,默认按名字注入,名字匹配不到再按类型。

还有一个细节几乎没人提:@Autowired支持required = false属性,容器里找不到对应Bean时不会报错;@Resource没有这个设计。另外,同类型有多个Bean时,@Autowired会让你必须指定@Qualifier或者一个都不注入,@Resource则可以直接指定name来精确定位。

我的选择建议:新项目统一用@Autowired(毕竟是Spring原生,配合构造器注入更深得人心);维护老项目时看项目里的既有规范,别混着用。

6.5 抛几个面试必问的变种题

既然标题说"完全理解",我把面试中围绕IoC和DI的变种问题也整理一下,当作自查清单:

问题一:Spring容器在启动时做了什么?

答:扫描配置类或XML,生成BeanDefinition;执行BeanFactoryPostProcessor;实例化单例Bean;填充属性(DI);执行各种BeanPostProcessor初始化前后的逻辑;放入单例缓存。懒加载的Bean在第一次调用时才执行流程。

问题二:为什么默认是单例的?

答:单例省内存,一个类只实例化一次,同时没有了并发创建的性能开销。对象本身无状态时,单例天然线程安全。前提是Bean里不能有可变的共享字段。

问题三:IoC和DI是同一个概念吗?

答:不是。IoC是思想,DI是思想的一种实现方式。依赖注入的目的就是实现控制反转。

问题四:BeanPostProcessor是干什么的?能不能举个例子?

答:它是IoC容器的后置处理器,在Bean初始化前后各有一个回调。AutowiredAnnotationBeanPostProcessor就是通过它来解析@Autowired注入的;AbstractAutoProxyCreator通过它创建AOP代理对象。

问题五:一级缓存和三级缓存存的东西有什么不同?

答:一级缓存放完整Bean,是全局唯一的单例池。三级缓存放的是ObjectFactory,是一个惰性工厂,它的存在是为了支持循环依赖场景下的"提前暴露"。完整之后就会从二级、三级缓存中移除,只保留一级缓存。

7. 写在最后的实际操作体会

我在教了非常多新手之后发现:大部分人学IoC和DI,卡在的不是"代码不会写",而是"为什么非要从new换成容器管理"。这个坎过了,后面全都是水到渠成的事。

分享一个我带新人时的教学方法,你也可以自己在项目里试试:先写一个完全没有Spring的纯Java版本,把对象关系用new硬编码串联出一个小功能,然后小步引入Spring容器,把new逐个替换成依赖注入。你会发现,改动完成后,整个项目的类之间不再有创建依赖,代码的扩展入口全集中到了容器装配层。这个"从硬编码到容器"的过程,就是理解IoC最好的路径。

另外,不要迷信任何一个"最佳实践"。字段注入确实官方不推荐,但在写快速原型时它最顺手;构造器注入虽好,但参数过多的类反而暴露了设计问题——你首先要做的是拆分大类,而不是争论注入方式。工具是人用的,理解背后原理后,你会知道什么时候该坚持,什么时候该妥协。

Spring的IoC和DI是一个很大的话题,但核心就那么点东西:容器通过BeanDefinition知道你有哪些类,通过依赖注入组装它们,通过生命周期管理保证它们在正确的时机以正确的状态出现。把这句口诀记在心里,所有的源码、报错、面试题都可以往这个框架上靠。

最后再补一个小技巧:当你面对一个陌生的Spring项目、搞不清Bean之间的依赖关系时,在启动入口加一行代码,把容器里所有的Bean依赖关系打出来看:

try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class)) { String[] beanNames = context.getBeanDefinitionNames(); for (String beanName : beanNames) { System.out.println("Bean: " + beanName + " -> " + context.getBean(beanName).getClass().getName()); } }

这行输出基本能帮你还原整个项目的装配结构,定位到"谁少了注解、谁类型冲突"这类问题,比对着报错信息瞎猜快得多。我每次接手老项目的第一件事就是干这个。

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

MRAM工业嵌入式实战:MR25H40CDF与STM32F732IE驱动开发与掉电保护

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 7:59:28

插件机制与激活失败排查:从web boot到entry did not activate

大概两年前&#xff0c;我接手过一套插件化设计的前端应用&#xff0c;几乎每隔一两周就会有人截图贴一句“failed to load plugins web boot: 2 entries did not activate linxin666/dsh-p”过来问怎么回事。那时候我就发现&#xff0c;很多人对“插件”这个词的理解其实停留在…

作者头像 李华
网站建设 2026/10/5 7:59:02

插件机制与 failed to load plugins 排查实践

“plugins 到底能做什么&#xff1f;”这是几乎所有刚接触插件机制的人都会问的第一句话。我在嵌入式、前端和日常工具软件三个方向都折腾过插件系统&#xff0c;从 IAR 编译器里的扩展插件&#xff0c;到前端框架里动不动就报 failed to load plugins web boot 的加载器&…

作者头像 李华
网站建设 2026/10/5 7:58:44

基于SSD+VGG16的驾驶员疲劳检测系统实战指南

简介&#xff1a;本资源是一套基于Python与卷积神经网络实现的驾驶员疲劳检测与预警系统完整毕设项目&#xff0c;面向计算机、人工智能及相关专业本科生&#xff0c;适用于毕业设计、课程大作业及项目实战训练。系统融合人脸关键点识别、闭眼/打哈欠行为判别与实时预警功能&am…

作者头像 李华
网站建设 2026/10/5 7:58:34

FaceNet人脸嵌入考勤系统:从128维向量到L2距离决策

简介&#xff1a;本资源是一套面向计算机专业本科生的深度学习实战项目&#xff0c;聚焦人脸识别考勤系统开发&#xff0c;适用于毕业设计、课程设计及期末大作业等场景。项目基于FaceNet深度学习算法实现人脸特征提取与比对&#xff0c;完整覆盖人脸录入、实时识别、考勤统计、…

作者头像 李华
网站建设 2026/10/5 7:58:18

OpenShell免费开源工具:Windows 10/11找回经典开始菜单与个性化配置指南

这篇文章带你聊聊多数人老系统美化时第一个想到的免费开源工具——OpenShell。它在国内软件站有时候叫“Open-Shell”&#xff0c;沿袭自当年那个无人不知道的Classic Shell&#xff0c;作者换了一批人接手后继续维护&#xff0c;功能也从单纯还原经典开始菜单&#xff0c;逐步…

作者头像 李华