☰
Spring依赖注入机制详解:构造器注入、@Autowired与循环依赖避坑
2026/10/2 4:28:18 网站建设 项目流程

写这篇文章之前,我先说个现象:团队里做技术分享时,我让大家把Spring中Bean的注入方式写一写。结果有人写了一条@Autowired,有人写了构造函数注入,还有人写了@Resource,但几乎没人能把几种方式的区别、匹配规则、适用场景讲完整。这个问题看着基础,却是整个Spring容器最核心的逻辑,也是后来排查问题、理解循环依赖和三级缓存的起点。这篇我把Spring中Bean的注入方式从头到尾拆一遍——从XML时期的经典注入,到注解驱动的@Autowired和@Resource,再到生产项目里怎么选型、哪些坑最常见,一次讲透。适合刚接触Spring的初学者,也适合想在代码评审或面试里把注入机制讲明白的老手。

1. 先搞清楚一件事:注入解决的是“谁来找谁”的问题

很多教程上来就列各种写法,但没讲清楚为什么需要注入。我习惯用一个场景引入:如果写一个用户下单功能,OrderService要调用UserService查询用户信息,再调用OrderRepository保存订单。不用Spring的典型写法是这样的:

public class OrderService { private UserService userService = new UserService(); private OrderRepository orderRepository = new OrderRepository(); public void createOrder(Order order) { User user = userService.getUser(order.getUserId()); orderRepository.save(order); } }

问题马上来了:OrderService和UserService、OrderRepository被硬编码绑定在一起。哪天UserService的构造函数加了参数,或者换成接口实现类,OrderService的代码就得跟着改。再往后,如果UserService内部还需要OrderRepository,对象的创建顺序也会变成一场噩梦。这就是耦合,而Spring的IoC(控制反转)之所以出现,就是为了解决这个“谁来找谁”的问题。

1.1 Bean和容器先理清

现在项目里到处是@Component、@Service、@Repository,这些注解标记的类就是Spring帮你管理的Bean。所谓Bean,简单理解就是“由Spring容器创建并维护生命周期”的Java对象。容器负责两件事:把一个Bean实例化出来,再把它依赖的其他Bean“喂”给它。这个“喂”的动作,就是依赖注入(DI)。

顺着这个思路你会发现,IoC和DI其实是同一件事的两个视角:IoC说的是控制权反过来了——以前对象自己new自己依赖的东西,现在由容器统一创建和分配;DI说的是容器具体怎么把依赖交给对象——通过构造函数参数、Setter方法或属性字段。两者指的是同一个机制,只是站的角度不同。

1.2 注入这个动作背后有几层工作

容器创建一个Bean并不是“new完就结束”。以注解配置为例,完整流程大致是:

  1. 扫描类路径,找到带@Component这类注解的类,注册成BeanDefinition。
  2. 根据BeanDefinition里的信息实例化对象(对应构造器)。
  3. 对所有被@Autowired、@Resource标记的字段或方法,去容器里找对应的依赖,执行注入。
  4. 调用BeanPostProcessor相关回调,执行@PostConstruct这类初始化逻辑。
  5. 把完整的Bean放入一级缓存(单例池),供后续使用。

在第三步里,如果依赖找不到、找到多个或者依赖本身没准备好,就会抛异常。所以理解和排查注入问题,本质上就是理解容器“按什么规则找依赖”。

1.3 和直接new相比,注入带来的直接收益

用注入替代直接new,最直观的好处是可以随时换实现而不改调用方代码。比如OrderRepository是个接口,只要容器里有对应的实现Bean,注入的地方不用动。还有一点容易被忽略:通过容器管理Bean,默认生成的是单例对象,同一个实例可以被多处注入共享,避免每个类各自new一份带来的内存浪费和状态不一致。这也是后面讲循环依赖时会反复用到的基础——单例缓存和三级缓存正是针对单例Bean设计的。

2. 从XML时代走来的三种经典配置注入:构造器、Setter与工厂方法

Spring 3.0注解流行之前,XML文件是配置Bean的主要手段。现在虽然大多数项目已经是全注解了,但理解XML时代的三种注入方式,对你读懂老项目、理解框架底层都很有帮助。这三种方式分别是构造器注入、Setter注入和工厂方法注入。

2.1 构造器注入:依赖在对象出生时就位

构造器注入的理念很朴素:对象不能没有依赖才能干活,那就把依赖作为构造函数参数传进去。XML里这样配置:

<bean id="userService" class="com.example.UserService"/> <bean id="orderService" class="com.example.OrderService"> <constructor-arg ref="userService"/> </bean>

对应的Java类是:

public class OrderService { private final UserService userService; public OrderService(UserService userService) { this.userService = userService; } }

这里有个关键点:因为依赖是通过构造器传入的,userService字段可以声明为final。final字段意味着对象一旦创建,依赖关系就不可变,不存在“对象已经创建了但依赖还没赋值”的中间状态。这是构造器注入最大的优势,也是我后来在代码评审里坚持推荐它的主要原因。

2.2 Setter注入:允许Bean先出生、再装配

Setter注入则允许容器先用无参构造函数实例化Bean,再通过setter方法把依赖设进去。XML写法:

<bean id="orderService" class="com.example.OrderService"> <property name="userService" ref="userService"/> </bean>

Java类里对应提供一个setter:

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

Setter注入的灵活性在于:依赖可以是可选的,Bean即使有个别依赖没配上也能创建出来,只是功能上会缺东西;同时它允许你在运行中通过调用setter重新设置依赖,这在写测试代码或者做特殊场景替换时比较方便。代价是字段无法声明为final,对象可能长时间处于“不完整”状态。因为Spring的XML和注解都支持这种写法,早期EJB风格的代码里经常能看到它。

2.3 工厂方法注入:让Spring与旧代码握手

工厂方法注入分静态工厂和实例工厂,这种方式的出现场景大多是“某个类不是你自己写的,构造函数不对外开放,只有工厂方法能拿到实例”。比如老项目里常见的:

<bean id="connection" class="com.example.ConnectionFactory" factory-method="getInstance"/>

Spring会调用ConnectionFactory.getInstance()静态方法,把返回的对象作为Bean注册到容器。实例工厂类似,区别是工厂本身也是容器里的一个Bean:

<bean id="myFactory" class="com.example.MyFactory"/> <bean id="service" factory-bean="myFactory" factory-method="createService"/>

这种注入方式现在用得少,但你在研究框架源码时经常会看到类似的模式。框架内部的@Bean方法本质上也可以看作一个实例工厂——Spring通过调用@Configuration类里的@Bean方法,把方法返回值的对象交给容器管理。这么一想,你对@Bean的理解就能提升一个层次。

三种方式的核心价值对比,我整理成一张表:

注入方式依赖赋值时机是否支持final字段灵活性适用场景
构造器注入对象创建时支持较低强制依赖、依赖关系稳定的核心类
Setter注入创建后通过setter不支持较高可选依赖、测试中需要替换实现的类
工厂方法注入工厂方法返回时视情况高非自己编写的类、遗留代码、框架集成

3. @Autowired和@Resource:两种注解注入的匹配逻辑与对冲方式

注解普及之后,写注入配置确实轻松了,但这也带来一个后果:很多人只知其然,不知道注解背后是按什么规则去找Bean的。这个规则不掌握,遇到“明明有两个实现类,@Autowired却报错”或者“字段是null,但又没报错”的情况时,排查思路就容易乱。

3.1 @Autowired默认按类型匹配,再按名称兜底

先说@Autowired,它由Spring提供,核心匹配策略是三步:

@Component public class OrderService { @Autowired private OrderRepository orderRepository; }

第一步,容器先去类型池里找OrderRepository类型的Bean。如果容器里只有一个,直接注入;如果类型找不到,报NoSuchBeanDefinitionException;如果找到多个同类型Bean,进入第二步——@Autowired会尝试根据字段名去找。比如字段名叫orderRepository,容器里正好有一个Bean的name也是orderRepository,那就用这个;如果按名称还是找不到,就会抛出NoUniqueBeanDefinitionException,提示你有多个候选,无法确定。

所以@Autowired不是“无脑按字段声明去取”,它是按类型优先、名称兜底的策略。写代码时让字段名和Bean名保持一致,能省掉大量@Qualifier。

3.2 @Qualifier和@Primary:多实现类场景的两套解法

多实现类时,有两个控制方向。如果你只是想让某个实现成为“默认选择另一个”,在定义Bean的地方加@Primary:

@Component @Primary public class OracleDao implements Dao { ... } @Component public class MysqlDao implements Dao { ... }

这样@Autowired Dao dao默认注入OracleDao。如果某个特殊场景你想明确选择MysqlDao,就在注入点用@Qualifier("mysqlDao"):

@Service public class ReportService { private final Dao dao; @Autowired public ReportService(@Qualifier("mysqlDao") Dao dao) { this.dao = dao; } }

@Primary和@Qualifier可以理解为两套开关:@Primary是“在不特别指定时,默认选我”;@Qualifier是“在某个具体的注入点,我就是指定要我”。两者配合,可以做到既省心又精确。在实际开发中,我建议尽量用@Qualifier把你的意图写明白,@Primary只用于“这个Bean确实是最常用默认实现”的场景,避免隐式规则太多导致后来人看不懂注入关系。

3.3 @Resource按名称优先:JSR-250标准下的另一套规则

@Resource来自JSR-250,是Java EE时代就有的注解。它的匹配逻辑和@Autowired正好相反:先按Bean名称找,找不到再按类型找。看这个例子:

@Component public class OrderService { @Resource private OrderRepository orderRepository; }

容器先找name为orderRepository的Bean,找到就用;如果容器里没有这个名字,再回退到按类型匹配。如果连类型也没有,才报错。你也可以直接指定名称:

@Resource(name = "specialOrderRepository") private OrderRepository orderRepository;

使用@Resource的好处在于它不依赖Spring独有的注解,理论上在别的实现了JSR-250规范的容器里也能用。不过在纯Spring项目中,@Autowired的支持更完整,能配合@Qualifier、构造器注入等多种场景,所以Spring社区的主流建议还是@Autowired。但读懂@Resource非常重要——它在按名称注入的场景下比@Autowired更直接,也更容易排查问题。

3.4 不只是对象:@Value和@ConfigurationProperties把配置也注入进来

注入的不仅是Bean,还可以是配置值。最基础的是@Value:

@Component public class DataSourceConfig { @Value("${spring.datasource.url}") private String url; @Value("${spring.datasource.username}") private String username; }

${...}语法会从application.properties或application.yml中读取对应key的值,这对少量配置字段很方便。如果是一组有结构的配置,更推荐用@ConfigurationProperties,一次性绑定到一个类上:

@Component @ConfigurationProperties(prefix = "spring.datasource") public class DataSourceProperties { private String url; private String username; private String password; // getter/setter... }

这样Spring会按照前缀批量完成字段映射。你知道prefix、user、password和配置项的对应关系,配置文件里多了什么、少什么,一眼就能看出来。这两种方式本质上也是“向Bean里注入外部数据”,和注入其他Bean是同一个思路,只是数据来源变成了配置中心。

4. 生产项目里我为什么偏向构造器注入

前面几代方案各有特点,但落到现在的Spring Boot项目里,我的建议非常明确:能用构造器注入就优先用构造器注入。这不是说其他方式不能用,而是构造器注入解决了我最关心的几个问题——不可变性、可测试性和依赖完整性。

4.1 构造器注入的几个硬优势

先说不可变性。字段可以声明为final,这意味着一旦对象创建完成,依赖就固定了,不可能出现某个地方通过setter把依赖替换掉,然后影响全局。在多线程环境下,一个构造完成后依赖不再变化的对象,状态更安全,也更好推理。

其次是依赖完整性。如果依赖是构造器的必要参数,你想创建一个OrderService却少传一个依赖,编译器就会直接报错。而字段注入或Setter注入,依赖缺失不会被编译器发现,只会在运行时NullPointerException。测试的时候这个差别更明显:用构造函数直接new一个对象,缺什么一目了然;用字段注入还得借助反射或者Spring容器才能把依赖塞进去。

第三是能更早暴露设计问题。当一个类的构造函数参数超过四五个时,你立刻会感觉到“这个类承担了太多职责”。而字段注入把这种坏味道隐藏得很深,代码看起来“清爽”,其实维护起来头大。我一般要求依赖超过5个的类强制拆分,构造器注入就是帮我们及早发现这个信号的工具。

4.2 字段注入为什么在代码评审里经常被挑战

字段注入是迭代最快、写起来最省事的方式:

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

但它的问题恰恰出在这个“省事”上:单元测试时,不启动Spring容器,字段就是null,你只能借助Mockito的@InjectMocks这类工具,还得靠反射找字段;类层面依赖关系不清晰,别人看一眼代码无法马上知道这个类到底需要哪些Bean;也没有办法声明final。这些都降低了代码的可维护性。所以现在很多团队的代码规范里,字段注入是默认禁止的,只允许构造器注入或少数特殊的Setter注入。

4.3 多实现类怎么优雅注入:集合注入与泛型注入

多实现类场景下,如果注入的不是单个对象,而是所有实现,可以用集合注入:

@Service public class ReportService { private final List<ReportGenerator> generators; public ReportService(List<ReportGenerator> generators) { this.generators = generators; } }

Spring会把容器中所有ReportGenerator类型的Bean按顺序注入成一个List。新加一个实现类,不需要改动ReportService,只要符合接口定义就会被自动发现,这比硬编码一个“实现类列表”优雅得多。泛型注入也很有意思,假设有一个通用的仓库接口:

public interface BaseRepository<T> { ... } @Repository public class OrderRepository implements BaseRepository<Order> { ... } @Repository public class UserRepository implements BaseRepository<User> { ... }

你在某个类里写:

@Autowired private BaseRepository<Order> orderRepository;

Spring的ResolvableType机制会根据泛型参数去匹配对应的实现。这算是注入中比较高级的玩法,掌握它对理解复杂框架的装配逻辑很有帮助。

5. 循环依赖与三级缓存:注入机制里的“紧急预案”

聊注入必然绕不开循环依赖,因为它是注入机制中最典型的“异常剧本”。所谓循环依赖,就是A依赖B,B又依赖A。没有容器帮你兜底的话,这几乎是个无解的死锁问题——创建A要B,创建B又要A。Spring在单例Bean场景下用三级缓存解决了大部分循环依赖,但注意,不是所有循环依赖都能解。

5.1 三级缓存到底是哪三级

Spring维护了三个Map,分别对应三种状态:

一级缓存singletonObjects:存放已经完整创建的单例Bean,所有正常注入的Bean最终都在这。

二级缓存earlySingletonObjects:存放已经被提前暴露、但还没完成完整初始化的早期Bean引用,注意这里的引用是不完整的。

三级缓存singletonFactories:存放ObjectFactory工厂对象,用来在需要“提前暴露”时生成早期的Bean引用。

为什么需要三级而不是两级?核心在于三级缓存里存的是ObjectFactory,不只是对象实例。这个工厂可以在生成引用前后执行一些扩展逻辑,比如AOP代理。如果一个Bean最终需要被代理,三级缓存可以在提前暴露引用时就生成正确的代理对象,或者延后处理。如果只有二级缓存,相当于把早期引用提前定死,后面再做AOP增强就不好办了。Spring把这个问题拆成三级的本质,是把“何时生成引用”和“引用长什么样”分开控制。

5.2 哪些注入方式能通过三级缓存走到“山重水复”

默认情况下,Spring创建A的大致流程是这样的:

  1. 创建A的实例(构造函数执行完,但字段还没注入)。
  2. 把A对应的ObjectFactory放入三级缓存。
  3. 开始填充A的属性,发现需要B,于是去创建B。
  4. B的执行流程一样:先实例化,再填充属性,发现需要A。
  5. B去容器里找A,发现一级、二级都没有,但三级里有A的ObjectFactory,于是调用工厂生成一个A的早期引用,放入二级缓存,并把这个早期引用注入给B。
  6. B顺利填充完自身其他属性,走完初始化流程,最终放入一级缓存。
  7. A继续执行自己的属性填充,这次可以从一级缓存拿到完整的B,后续初始化完成,也放入一级缓存。

这个链条成立的关键是:A在填充属性之前,已经被放入三级缓存“提前暴露”了。而这一步依赖的是“实例化A”和“填充A的属性”两个阶段分离。字段注入和Setter注入天然满足这个分离条件。

5.3 构造器注入为什么解不开循环依赖

如果你用构造器注入,循环依赖就解不开了。因为构造器注入是在“实例化阶段”就要把依赖传进去,A还没被放入三级缓存,就需要B参与构造;而B同样在构造时需要A,此时A根本还没有被创建出来,三级缓存里自然也找不到它。死锁发生在“出生”阶段,双方都要求对方先出生,谁都无法让一步。

Spring Boot 2.6之后默认禁止了循环依赖,项目里有循环依赖会直接启动失败。如果确实碰到了老项目中的循环依赖,要么重构,把A和B之间互相调用的部分拆到第三个类去;要么用@Lazy打破循环:

@Service public class A { private final B b; public A(@Lazy B b) { this.b = b; } }

@Lazy在这里的含义不是“B是懒加载”,而是告诉容器:给A注入一个B的代理对象,等到真正调用B的方法时,再去容器里拿真正的B。这样就把“必须现在拥有B”的硬约束,变为了“将来能够拿到B”的软约束。这是我在老项目里最常用的应急手段,但能规避就规避,不要把@Lazy当成常态方案。

实话说,我排查过不少因为循环依赖引发的启动失败,十有八九都指向设计问题:两个类职责边界不清,互相依赖太深。要么抽层出来,要么把公共部分下沉,最终都能通过合理拆分解决,而不是靠开关配置硬撑。

5.4 如果启动了循环依赖开关:兜底不代表解脱

Spring Boot 2.6之前,循环依赖默认是允许的。很多人可能没意识到,项目里能启动成功,可能正依赖着三级缓存这套“紧急预案”。一旦依赖被允许,后面做AOP增强、事务代理时,就可能出现“注入的引用和实际代理不一致”这种极难排查的奇怪现象。所以即使老版本能跑,建议还是逐步清理循环依赖关系,并有意识在配置里开启spring.main.allow-circular-references=false做校验,让启动期直接暴露问题,而不是把隐患埋在运行期。

6. 写代码时最容易踩的注入坑:排查链路与实战对策

注入机制理论讲完,最后落到实际开发。这里我挑几个出现频率极高的坑,它们都是“代码看着没问题,但运行时就是不对”的代表。每个坑我都会给出排查链路和具体对策,这些才是我在日常支持团队时最常写进文档里的部分。

6.1 用new创建出来的对象,里面的@Autowired为什么是null

这个坑新手遇到的最多。场景很典型:某个工具类里写了一个方法,方法里new了一个OrderService,然后调用orderService.createOrder(...),结果userService是null。

原因不复杂:@Autowired之所以能把Bean注入进来,前提是这个对象本身是由Spring创建和维护的。你用new新造出来的对象完全脱离了容器管理,里面的依赖标注自然不会被填充。这个对象是“孤儿Bean”,容器对它一无所知。

排查思路也直接:先确认这个类有没有被Spring扫描到,比如它有没有@Service、@Component这类注解,或者有没有在配置类中@Bean注册;再确认调用方是不是被容器正常注入的Bean;最后才是确认注入字段名、类型有没有歧义。如果只是想在某个工具方法里临时用一下某个Bean,正确姿势是把工具类本身也交给Spring管理,或者通过构造器把依赖传进来,而不是在普通Java类里new一个带注入注解的Bean。

6.2 静态工具类、工具方法怎么注入依赖

静态方法里要使用Spring管理的Bean,是另一个高频场景。比如一个ExcelExportUtil.export(...)是静态方法,里面想调用ExportConfigService。直接写字段注入肯定不行,静态字段不能通过实例Bean注入。

我常用的方案是:把这个工具类本身做成Bean,保留静态方法,但使用一个被注入的实例成员做“桥接”。多说一句代码,这种实现至少要交代清楚“为什么不能用常规注入”:静态方法不属于任何实例,Spring容器只能给实例字段注入,静态字段不在管理范围内。代码可以这样写:

@Component public class ExcelExportUtil { private static ExportConfigService exportConfigService; @Autowired public void setExportConfigService(ExportConfigService service) { ExcelExportUtil.exportConfigService = service; } public static void export(Order order) { ExportConfig config = exportConfigService.getConfig(order.getType()); // 导出逻辑 } }

注意这里用的是构造器注入之外的方法:在@Autowired的Setter方法里把实例字段赋给静态字段,这样静态方法就能拿到容器管理的Bean了。不过这个方案有个副作用:静态字段是全局状态,Spring容器销毁或重新初始化时,这个值可能残留。如果要写单元测试,也得先把这个静态字段重置掉。所以我通常建议,能改造的就不要用静态方法,把ExcelExportUtil做成普通的@Service类,用实例方法,依赖关系干净清爽。

6.3 注入接口还是注入实现类:影响的不只是可替换性

很多关于注入的报错,根子不在注入语法,而在“你注入的是接口还是类”。如果只有一个实现类,注入接口和注入实现类都能跑;一旦出现第二个实现类,差别立刻显现。注入接口时,你写的是@Autowired private Dao dao,容器需要确定用哪个实现;注入实现类时,写的是@Autowired private MysqlDao mysqlDao,没有歧义,但将来想切换到Oracle实现,这里就得改代码。

我的经验是:对外接口明确,依赖侧就注入接口,配合@Qualifier做切换;如果一个类是内部私有的实现细节,并不需要被替换,那直接注入具体类也可以。只是要理解,注入具体类会把一个内部实现细节暴露给依赖方,将来改动范围会变大。回到代码评审,如果看到某个Service里注入了另一个Service的具体实现类,我会多问一句:“这个具体类未来可能被替换或扩展吗?”如果可能,建议改成接口。

6.4 泛型注入、集合注入的排序问题与常见误用

集合注入时,多个实现类的顺序默认不保证,如果有顺序要求,可以给Bean加@Order注解,或者在实现类上实现Ordered接口。Spring会按order值从小到大排序后注入List。这个细节在策略类、过滤器链、消息处理器这类场景中非常关键。

@Component @Order(1) public class FirstHandler implements Handler { ... } @Component @Order(2) public class SecondHandler implements Handler { ... }

Order值小的排在前面,不写默认是最低优先级。踩过的坑是:忘了加@Order,导致依赖链上某个校验逻辑在总流程里位置不对,线上数据格式校验顺序变了,问题还不好定位。另外,泛型注入在多个泛型参数极其接近时也会出现匹配失败,比如BaseRepository<Order>和BaseRepository<User>,如果Order和User存在继承关系,Spring的泛型匹配机制就可能遇到一些边界情况,这时用@Qualifier直接指定更稳妥。

6.5 排查注入问题的固定套路

最后分享一个我自己的排查方法论。注入报错或注入为null时,按这个顺序检查,基本能覆盖90%的问题:

  1. 确认目标Bean是否存在:查启动日志、@Component注解、@Bean方法有没有被执行。
  2. 确认Bean的类型和名字:多个同类型Bean时看@Primary、@Qualifier、@Resource(name=...)是否正确。
  3. 确认注入点是否在容器管理的Bean里:new出来的对象里所有注入都是无效的。
  4. 确认是否涉及循环依赖:如果启动日志里有BeanCurrentlyInCreationException,说明A和B互相依赖,需要拆分或@Lazy。
  5. 确认配置文件是否正确:@Value注入的key是否存在,@ConfigurationProperties的prefix是否正确,配置值类型是否匹配。

按这个顺序做,大部分注入问题都能定位到具体层面。我个人在实际项目里的体会是:注入方式本身没有绝对的对错,但生产项目优先构造器注入、代码评审重点看依赖数量、遇到特殊场景(静态方法、多实现类、泛型)时能明确匹配意图,这三点做到了,工程的注入体系基本不会出大乱子。如果你正准备升级Spring Boot 2.6以上的版本,建议顺便把循环依赖的开关关掉,用启动报错倒逼一次全面的依赖梳理——虽然过程中会有阵痛,但梳理完的项目,结构清楚不只是“能跑”这一个优点。

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

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

立即咨询