☰
Spring Bean注入方式全解析:构造器、字段注入与循环依赖三级缓存原理
2026/10/11 7:07:21 网站建设 项目流程

"你平时是怎么注入Bean的?@Autowired一句就完事了吧。"入职培训的时候带我的老哥这么问我,当时我理直气壮答了个"字段上贴注解啊,大家都这么写"。他没反驳,只是甩给我一句话:"那要是循环依赖呢?要是多个实现类呢?你知道三级缓存跟注入有什么关系吗?"那顿饭之后我老实把Spring源码翻了一个星期。

Bean的注入方式,表面看是道入门题,实际上藏着Spring整套容器设计的核心逻辑——为什么有这么多注入姿势、官方为什么反复改推荐、三级缓存又是为谁兜底。这篇文章不打算只列几种写法,我会把"怎么写、为什么这么写、坑在哪、原理是啥"一次性讲透,搞懂之后不管你是写业务还是啃源码,都能通。

1. 先搞清楚为什么会有这么多注入方式

1.1 依赖注入的本质:从"找依赖"变成"被给依赖"

拿搬砖做个类比。正常情况下你想用一台切割机,得自己去器材室找、登记、扛回来。但工地如果有个管理员,他提前按你的工位把机器配好,你到点直接开工就行。Spring IoC容器就是这个管理员,它维护一个大仓库,里面注册满了形形色色的bean(也就是对象)。你哪个类需要依赖别人,告诉容器一声,容器直接把目标对象"送"到你手里。

所以"注入"的本质是控制权反转(IoC)。传统写法里,类要使用另一个类,通常是自己在构造方法里new出来。而依赖注入的意思变成:我不负责创建依赖对象,由容器在合适的时机把依赖传递给我。带来的直接收益是解耦——业务类不需要关心依赖怎么建出来的,切换实现类也不用动业务代码,测试时塞一个mock实现就行。

这也是为什么Spring把注入方式设计成了多种而不是一种:依赖的属性不同——有的必填、有的可选、有的需要初始化顺序可控——只有提供多种入口,才能适配不同场景。

1.2 注入方式全景图:从XML到注解的三代变迁

Spring 2004年诞生至今,注入方式的演变可以分三个阶段。

第一代是纯XML配置时代。你需要在applicationContext.xml里声明bean,再通过property标签或constructor-arg标签把bean之间的关系"缝"起来。比如:

<bean id="userService" class="com.example.service.UserService"> <property name="userDao" ref="userDao"/> </bean> <bean id="userDao" class="com.example.dao.UserDao"/>

这种写法所有依赖关系都集中在XML里,结构很清晰,但缺点也致命:工程一大,XML文件就会膨胀到你不想打开它;改一个依赖关系还得去翻配置文件。今天我基本不建议新项目再走这条路,但老项目维护时你得会读。

第二代是注解扫描时代。Spring 2.5引入@Component和@Autowired,从此不用再手动维护bean与bean之间的关系,框架自己通过类路径扫描完成装配。这是当前绝大多数项目采用的方式。

第三代是基于Java Config的显式装配,Spring 3.0引入@Configuration和@Bean。它的优点是把"集中可见"(XML的优点)和"类型安全"(注解的优点)揉到一起。

这三个世代不是互斥的。同一个工程里完全可以XML、注解、Java Config并存。作为开发者,你至少三种写法都得看得懂,才能在各种风格的历史代码里生存。

2. 注解注入的三种主流姿势

2.1 属性注入(字段注入):最常用但也最被诟病

字段注入是几乎所有框架示例代码里的默认写法,也是很多新手唯一认识的注法:

@Service public class UserService { @Autowired private UserDao userDao; public void doSomething() { userDao.save(); } }

一眼看过去,代码量最少、最直观,一行注解解决所有问题。底层原理是AutowiredAnnotationBeanPostProcessor在bean实例化之后扫描所有标了@Autowired的字段,通过反射赋值。注意这里有个隐藏点:被注入的字段连public都不用,private照样能通过反射设置进去——这个特性既是便利也是坑。

字段注入的方便是显性的,但代价在后面:

  • 依赖关系被藏着。外部调用者只看构造方法根本看不出这个类依赖了什么,必须翻类内部才能摸清全部依赖,可读性和可测试性都受影响。
  • final字段没法用它。因为字段注入发生在实例化之后的反射赋值阶段,final字段在构造时就必须定死。
  • 与Spring容器耦合太深。如果类是自己new出来的(比如单测场景),字段永远是null,Spring管不到它。
  • 依赖的可变性失控。字段没有final约束,运行期随时可能被篡改。

我的实际建议是:字段注入只适合Controller这类边缘Web组件——薄、短生命周期、不太需要写单测。核心Service层和通用组件,别用。

2.2 构造器注入:官方推荐的硬约束方案

构造器注入的写法是这样的:

@Service public class OrderService { private final OrderMapper orderMapper; private final ProductClient productClient; public OrderService(OrderMapper orderMapper, ProductClient productClient) { this.orderMapper = orderMapper; this.productClient = productClient; } }

从Spring 4.3开始有个很重要的简化:如果类只有一个构造方法,可以不用写@Autowired,Spring自动选它。所以依赖只有一两个的时候,上面这个类甚至可以做到零注解。但如果类里存在多个构造方法,就必须用@Autowired显式标出容器该用哪个。

构造器注入的好处非常硬核:

  • 依赖清单集中在构造签名,一眼可读。
  • 配合final,依赖永远不可变,不会出现运行时被替换的幺蛾子。
  • 构造完成时依赖就已就绪,后续代码不会踩NPE。
  • 单测极其舒服,直接new出目标对象再传进依赖即可,完全不需要容器的参与。
  • 强制依赖场景下,类不可能处于"装配一半"的危险状态。

它的代价也比较明显:如果一个类需要十几个构造参数,那说明这个类已经违背了单一职责原则,该拆分了。

在循环依赖这个问题上构造器注入有个特殊结论——它救不了循环依赖,因为构造器注入发生在实例化阶段,A构造时就需要B,但A此刻连自己的实例都还没完成,没法把自己的"早期引用"暴露给容器。所以如果项目里出现了循环依赖,要么重构,要么把其中一条边改成setter或字段注入。

2.3 Setter注入:可选的、可变的、不是必须的

Setter注入的写法如下:

@Service public class EmailService { private MailSender mailSender; @Autowired public void setMailSender(MailSender mailSender) { this.mailSender = mailSender; } }

它的核心特点是把"实例化"和"装配"拆成了两个阶段,依赖是在bean创建之后再被组装进来的。这带来几个能力:一是可以在运行时替换依赖实例;二是很适合可选依赖——Spring允许依赖不存在,你只需要在方法里判空就行;三是它可以配合默认值初始化。

但setter注入的劣势同样明显:方法调用顺序不可控,漏调用setter就会得到空依赖;final依然没办法用;对象长期处于"没完全初始化"的状态,比构造器注入多了不确定性。

Spring官方文档从5.x开始态度很明确:构造器注入用于强制依赖,setter注入用于可选依赖。当两者同时存在时,容器优先用构造器注入。现在的现代项目中,setter注入更多是给特殊场景做补充,而不是主方案。

坊间流行的说法是"依赖注入有三种方式:接口注入、构造器注入、setter注入"。接口注入(让类实现一个特定接口,容器通过接口方法传依赖)在早期Spring里存在,但早就不推荐也不使用了。实际开发中,你记住字段注入、构造器注入、setter注入这三种就足够覆盖几乎所有场景。

3. 注入过程中的细节与进阶场景

3.1 歧义处理:@Qualifier还是@Primary还是两者皆用

当项目规模变大,接口多实现的场景会频繁出现。比如支付这个业务,微信支付、支付宝支付、银行卡支付都实现了PaymentService接口:

@Service("wechatPaymentService") public class WechatPaymentService implements PaymentService { // ... } @Service("alipayPaymentService") public class AlipayPaymentService implements PaymentService { // ... }

这时如果你直接:

@Autowired private PaymentService paymentService;

启动时会抛NoUniqueBeanDefinitionException——容器里有多个类型匹配的bean,它不知道该选哪个。

解决的方案有三种,我按实际使用频率排列:

第一种,@Qualifier指定bean名称。这是最显式、最不容易出错的:

@Autowired @Qualifier("wechatPaymentService") private PaymentService paymentService;

这里bean的名称默认是类名首字母小写,也可以通过@Service注解自定义。@Qualifier的本质是:把匹配范围从"按类型"收紧到"按名称"。

第二种,@Primary标出主实现:

@Service @Primary public class WechatPaymentService implements PaymentService { // ... }

当不指定任何限定时,Spring优先选择标了@Primary的实现。要注意一个优先级关系——当@Qualifier和@Primary同时出现时,@Qualifier的优先级更高:显式点名了就走点名,没点名才轮到@Primary兜底。

第三种,用@Configuration配合@Bean手动注册多个实现,并在方法级标注限定:

@Configuration public class PaymentConfig { @Bean @Qualifier("mobile") public PaymentService wechatPaymentService() { return new WechatPaymentService(); } @Bean @Qualifier("pc") public PaymentService alipayPaymentService() { return new AlipayPaymentService(); } }

这种方式把"哪个实现叫什么名字"从实现类解耦出来,适合你不能改源码实现类、又需要精准控制bean名字的场景。

3.2 配置注入:@Value与@ConfigurationProperties

注入的对象不只有bean,配置项也是常见注入目标。最基础的是@Value:

@Service public class FileStorageService { @Value("${storage.path:/tmp/files}") private String storagePath; @Value("${storage.max-size:104857600}") private long maxSize; }

注意值后面跟上冒号和默认值的写法——配置文件中没读到storage.path时就用/tmp/files兜底,这种写法在多个profile环境下非常有用。

@Value适合单个简单配置项的注入,但配置一多就会显得零散。更推荐的做法是@ConfigurationProperties:

@Component @ConfigurationProperties(prefix = "storage") public class StorageProperties { private String path; private long maxSize; // getter 和 setter 省略 }

然后使用的时候正常注入这个Properties对象:

@Service public class FileStorageService { private final StorageProperties storageProperties; public FileStorageService(StorageProperties storageProperties) { this.storageProperties = storageProperties; } }

@ConfigurationProperties不仅能自动完成类型转换,还支持列表、嵌套对象,适合大规模配置项的映射。踩坑提醒:这个类如果既没被@Component扫描到,也没被@EnableConfigurationProperties注册,配置注入就是不生效的。很多团队排查半天"配置读不进",最后都是组件扫描路径的问题。

3.3 集合注入与泛型注入的特殊用法

接口多实现时,如果你不是要某一个实现而是要全部实现——比如支付调度器需要对每种支付方式都做一次处理,Spring支持直接把所有实现注入为集合:

@Service public class PaymentDispatcher { private final List<PaymentService> paymentServices; public PaymentDispatcher(List<PaymentService> paymentServices) { this.paymentServices = paymentServices; } }

Spring会自动收集容器里所有PaymentService类型bean,按某个内部顺序塞进List。这个用法对策略模式极友好:新增一个实现类不用改任何Dispatcher代码,只要它注册成Spring bean就会被自动"纳入分发名单"。

类似的还有注入Map,key是bean名称,value是bean实例:

@Autowired private Map<String, PaymentService> paymentServiceMap;

之后就能用paymentServiceMap.get("wechatPaymentService")按渠道路由,在"按业务类型分发"的场景很顺手。

另一个容易被忽视的是泛型注入。Spring在解析泛型信息上做了很多工作,利用ResolvableType,目标字段的泛型会被拿去匹配:

@Service public class UserService { @Autowired private Handler<Order> orderHandler; }

只要Handler 是接口,容器里注册了Handler 和Handler 两个实现,Spring就能按字段声明的泛型精确匹配对的那个。这个特性在实现模板方法模式时非常有用。

4. 注入方式背后的Spring原理

4.1 三级缓存是如何解决循环依赖的

前面讲到构造器注入不能解决循环依赖,那Spring到底是用什么机制处理这个问题的?答案就是"三级缓存"——准确说是三个Map:

  • 一级缓存singletonObjects:存放完整的、初始化完毕的singleton bean。
  • 二级缓存earlySingletonObjects:存放早期引用。此时的bean已经实例化,但属性填充和初始化还没完成。
  • 三级缓存singletonFactories:存放bean的ObjectFactory,用来生成早期引用,并允许在这时介入AOP代理逻辑。

把A和B互相依赖的场景拆开来看:

  1. 容器开始创建A,A实例化完成。
  2. A需要注入B,于是容器检查B——B还没创建。
  3. 容器先把A的ObjectFactory放进三级缓存,提前暴露"半成品A"。
  4. 容器转而创建B。B实例化完成,B需要注入A。
  5. B在三级缓存里找到A的ObjectFactory,调用getEarlyBeanReference生成一个早期引用,注入给B。
  6. B初始化完成后,A继续自己的流程,从容器里拿到B,注入到自己。
  7. A完成初始化,放入一级缓存。

整个流程的精髓在"三级"而不是"两级"。有人会问:二级缓存够用吗?答案是单靠二级不行。原因在于AOP:如果A需要被@Transactional这类切面代理,代理对象不是在实例化阶段生成的,而是在bean初始化之后由BeanPostProcessor生成的。如果B在A初始化完成前就拿到了A引用,那拿到的是原始A,不是代理A,后续A的事务逻辑就全部失效了。

三级缓存的作用就是:平时所有bean走正常模式,不额外生成早期代理;只有真发生循环依赖时,才通过getEarlyBeanReference提前触发代理逻辑,把早期引用升级为代理对象存入二级缓存,再交给B使用。整个过程精心控制在"确实需要的时候才付出代价"。

这就是为什么Spring能支持字段注入和setter注入的循环依赖,却搞不定构造器注入的循环依赖:后者的依赖发生在实例化阶段,A在构造时就要B,可A自身实例都还没建立,没有任何东西能被提前暴露出来。

4.2 Bean生命周期和后置处理器如何参与注入

每个bean的完整生命周期大致是:实例化 → 属性填充/依赖注入 → 初始化(InitializingBean、init-method)→ 使用 → 销毁(DisposableBean、destroy-method)。

注入动作发生在"属性填充"阶段,准确说它由BeanPostProcessor接口介入。Spring在实例化出一个bean后,会遍历所有注册的BeanPostProcessor,逐个调用它们的postProcessProperties方法(老版本叫postProcessPropertyValues),允许每个处理器修改这个新实例的属性。

所以我们常用的@Autowired、@Resource、@Value,底层本质上是不同的BeanPostProcessor在做解析:

  • AutowiredAnnotationBeanPostProcessor:负责@Autowired和@Value的解析。
  • CommonAnnotationBeanPostProcessor:负责JSR-250规范的@Resource、@PostConstruct、@PreDestroy。

这就是为什么有的公司会在启动日志里看到这些类被实例化的原因——它们是Spring"装配流水线"上的关键工位。搞懂这些,遇到"注解没生效"的问题时,你才有排查方向:要么组件没进扫描路径,要么后置处理器没被注册。

4.3 从Spring Framework 5.0开始的变化

Spring 5.0开始全面拥抱JDK 8,大量使用default方法、@Repeatable等新特性,这让注解在组合场景上的表达能力更强了。Spring 6.x、Spring Boot 3.x之后,JDK 17成为基线,命名空间从javax全面切换为jakarta。

这个变化导致了一个非常经典的升级伤:Spring Boot 2.x项目里你import的是javax.annotation.Resource,迁移到Spring Boot 3之后直接编译报错,得改成jakarta.annotation.Resource。Spring Cloud相关组件同样面临这个调整。做老项目升级时,第一个编译报错基本都会落在这种"历史悠久"的注解import上。

时代在变,注入方式的整体分类没变,但注解的归属和底层规范一直在迭代。保持跟进是有必要的,不过也不用焦虑——核心的注入思路十年内不会变。

5. 实操对比与选型建议

5.1 一种务实组合:字段注入还是构造器注入

我见过几种团队风格。老一点的项目几乎全字段注入;阿里Java开发手册明确推荐强制构造器注入;而Spring官方不少示例代码还在用字段注入。

先把对比收成一张表,方便照搬:

维度构造器注入字段注入setter注入
依赖可见性构造签名一目了然隐藏散落在字段上setter可见但清单长
依赖不可变性可配final不可配final不可配final
可测试性直接new即可需要容器或反射new后手动调setter
循环依赖不支持支持支持
强制依赖vs可选依赖强制依赖最佳不分类型都模糊可选依赖较好
Spring官方推荐高低中
代码量中(单构造零注解最简)最低中
团队典型规范强制推荐不推荐慎重使用

在实际项目里,我建议的组合是这样:

  • 核心业务Service、DAO、通用组件:全部构造器注入,配final,能写单构造就写单构造(省掉@Autowired)。
  • Controller层:可以接受字段注入。Controller通常很薄,很少有复杂单测需求。
  • 可选依赖或运行期替换:用setter注入,同时做好判空防御。
  • 静态工具类需要注入Spring组件:用setter注入,把值转存到静态字段。

5.2 常见面试问题速查:40秒抓住考官出题意图

这部分整理了面试中高频出现的注入考点。背答案没意义,关键是理解每条背后的原理,让你能顺着话头延展。

  • "@Autowired和@Resource有什么区别?"——@Autowired是Spring框架自己的,默认按类型装配;@Resource是JSR-250标准,默认按名称装配,先按名称找不到再回退按类型。工程上,单Spring生态内@Autowired更顺手;追求标准兼容性,@Resource更通用。实战里按类型装配足以应对大多数场景。
  • "接口有多个实现类,你怎么注入?"——单一实现用@Qualifier按名称指定,或@Primary标主实现;要注入全部就用集合形式List 或Map<String, Interface>。
  • "Spring为什么推荐构造器注入?"——不可变、依赖明确、拒绝循环依赖并把问题提前暴露、可测试性好。顺带可以谈三级缓存。
  • "循环依赖一定不能有吗?"——字段注入与setter注入在单例bean场景下可以被三级缓存绕过;构造器不行。但循环依赖本质是设计缺陷,能重构就要重构。
  • "@Autowired的required属性是什么?"——默认true,找不到bean直接抛异常;设false后依赖缺失也能继续启动,但对应字段可能是null,用的时候得判空。

6. 常见问题排查与避坑实录

6.1 我抓狂过的几个注入失败案例

第一个是NoSuchBeanDefinitionException。它表示Spring在容器里找不到匹配的bean。排查顺序是:先看实现类有没有@Component/@Service注解;再确认包扫描范围——Spring Boot主类默认扫描所在包及其子包,实现类在另一个包就直接扫描不到;最后看是否存在多类型注册导致匹配混乱的情况。

第二个是NoUniqueBeanDefinitionException,多实现在没区分时最容易遇到。我曾经在一个老系统里排查过一个场景:接口叫PaymentService,实现类里有个类也叫PaymentService,命名冲突直接导致Spring扫描行为异常,早上线上启动反复失败,群里一片哀嚎。解决办法很简单——想清楚自己到底需要哪个实现,然后用@Qualifier或@Primary明确指定。

第三个是集合注入的顺序问题。Spring注入List和Map时,元素顺序默认是不保证固定的。如果你对顺序有要求,就得让实现类实现Ordered接口或使用@Order注解。我见过订单状态机里因为List顺序错乱导致状态流转走到了错误的handler,排查时花了小半天才定位到居然是顺序问题。

还有几个比较细节的场景。如果你自定义了一个BeanPostProcessor,而它的执行时机和@Autowired的解析撞车,可能导致某些字段注入失效。这种情况下,先看日志里后置处理器的初始化顺序,然后按需调整Order。

6.2 静态工具类注入和定时器注入的特殊场景

静态工具类不是Spring bean,你没法直接把Spring组件注入到静态字段里。比如你写了个DateUtil,里面需要使用ObjectMapper做序列化,但它的方法全是静态的,没有实例,Spring的注入是面向实例的,常规路径走不通。

常见的解决思路是,让这个工具类自己成为一个Spring管理的bean,然后通过setter方法把依赖转存到静态字段:

@Component public class SerializeUtil { private static ObjectMapper objectMapper; @Autowired public void setObjectMapper(ObjectMapper mapper) { SerializeUtil.objectMapper = mapper; } }

注意前提是SerializeUtil本身需要在容器里,Spring创建它时才会调用这个方法。这种方式引入了静态全局状态,用时要判断是否已经初始化完成,也别在并发场景过于依赖它。

定时任务的注入问题也很典型。如果你在Quartz里手动new了一个Job类,那这个Job就不是Spring bean,注解注入无用。正确做法要么把Job类也注册成Spring的bean,要么通过Spring的AutowireCapableBeanFactory手动完成注入,要么在Job的构造里用工具类获取BeanFactory。遇到这种情况,先停下来问自己一句:这段逻辑真的需要被Spring管吗?如果想清楚了要管,就让Spring参与它的生命周期。

6.3 从一个线上事故聊到团队规范

最后聊一个我经历过的线上事故。某次流量高峰期,一段老代码突然出现了NPE,定位过去发现是一个核心Service里的字段注入没生效——因为那段逻辑被一个切面包裹后,对象变得不是Spring直接创建的实例,而是通过代理链创建的,字段注入在某个环节没有正确完成。当时为了排查,把代码翻了一遍又一遍,最终才意识到团队里普遍使用字段注入的坏处:依赖隐藏、无法用final、对象创建路径一旦被代理干扰,问题就变得极其难以追踪。

那次之后,团队规范里白纸黑字写了一条:业务类一律构造器注入。为了确保规范落地,我用ArchUnit写了个架构测试,扫描所有被@Component、@Service标注的类,发现类字段上有@Autowired就直接让CI失败。这种"机器检查"比嘴上强调一百遍都管用。

还有一点来自生态观察——Spring AI、Spring Cloud Alibaba这些新兴框架的示例代码,大多还在用字段注入。这本身没有错,官方为了简洁地展示业务逻辑,挑最短的写法很正常。但你要清楚,示例的简洁是用来降低理解成本的,不是用来给生产代码立规矩的。看示例的时候把知识带走,把代码风格留给上下文。

如果这篇文章看完还想再往深走一步,我最推荐的方法是自己手写一个极简版Spring容器,把类扫描、反射实例化、属性填充、单例池这四个流程亲手实现一遍。写完之后你对"注入方式"这四个字的理解,会比背十篇博客都牢。这是我折腾过所有Spring相关代码之后,沉淀下来最有价值的一条学习路径。有问题欢迎在评论区继续聊,注入这事,聊着聊着就通了。

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

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

立即咨询