1. 先别急着背概念:Spring 框架里的“注入”到底指什么
“Spring 框架注入”这个问题,我在不同场合被问过不下几十次。刚毕业的求职者问的是“面试题里的依赖注入是什么意思”,半路转行学 Spring Boot 的菜鸟想搞懂“@Autowired 究竟是干什么的”,还有不少刚接触安全的同学搜进来,发现满屏都是 SQL 注入、XXE 注入,心里嘀咕“Spring 和黑客有什么关系”。说实话,这个关键词背后藏了完全不同的几层语境,如果一开始方向搞错了,后面花再多时间都是在打转。
先把结论放在前面:在 Spring 框架的官方语境里,“注入”指的是依赖注入(Dependency Injection,简称 DI),它是 Spring IoC 容器最核心的能力。IoC 是 Inversion of Control,控制反转;DI 是控制反转的具体实现方式。Spring 容器负责创建对象、管理对象生命周期、维护对象之间的依赖关系,而“注入”就是那个“把依赖塞给对象”的动作。至于安全领域常说的 SQL 注入、XXE 注入,那是完全另一码事,指的是攻击者把恶意代码注入到程序执行路径里,跟 Spring 的依赖注入除了中文名都叫“注入”之外,没有任何技术关联。这两者后面我会专门花一章来拆,不然太容易混淆。
这篇文章我不会只给你复述“控制反转就是把控制权交给容器”这种教科书式结论。我会从底层讲清楚 Spring 的依赖注入是怎么设计的、为什么非要用注入、三种注入方式各有什么坑,再深入讲讲让无数人头疼的三级缓存和循环依赖问题,最后把“注入”的各种歧义语境一次洗清楚。不管你是准备面试、写业务代码,还是单纯被这个名词绕晕了,这篇都值得你读到最后。
2. 依赖注入的本质:容器如何把对象“装配”起来
2.1 控制反转和依赖注入是一回事吗
先说控制反转。传统的 Java 开发模式里,对象需要什么依赖,自己就 new 什么。比如 Service 里面要用到 Dao,代码里直接写new UserDao()。看起来没什么问题,但一旦项目复杂起来,这种写法会让你痛苦不堪:类之间耦合严重,要换一个 Dao 实现得改源码;想给 Service 写单元测试,发现它硬绑定了数据库逻辑,根本没法 mock;对象创建散落在各个业务代码里,生命周期完全失控。
控制反转的核心思想就是把这件“创建依赖对象”的事情从业务类内部剥离开,交到一个“容器”手里。容器启动时先扫描配置、读取注解,搞清楚每个类需要什么依赖,然后统一创建、统一装配。业务类自己只负责声明“我需要什么”,不负责回答“这个依赖怎么来”。把依赖从一个类“塞”进另一个类的过程,就叫依赖注入。你可以把它类比成组装电脑:以前你自己买显卡、声卡、硬盘,亲手插到主板上;现在你只提交一张配置单,装机师傅(容器)负责把配件配齐、插好、点亮,你拿到的就是一台能用的整机。类的职责从“既要写业务、又要管组装”简化为“只管业务”。
Spring 从诞生起就围绕这个模型设计。你用@Component、@Service、@Repository标记一个类交给容器管理,用@Autowired声明依赖,剩下的活全交给容器。最直观的好处是,Service 层和 Dao 层不再通过new硬耦合,而是面向接口编程,具体实现哪怕换了一套全新的数据库访问方式,Service 本身的代码一行都不用动。
2.2 注入的三种姿势:构造器、setter 和字段
Spring 支持三种依赖注入方式,很多新手觉得“不都是注入吗,随便用就行”,其实差别非常大,面试也很爱考。
构造器注入:在构造函数参数里声明依赖,容器创建对象时把依赖作为参数传进来。
@Service public class UserService { private final UserRepository userRepository; public UserService(UserRepository userRepository) { this.userRepository = userRepository; } }setter 注入:通过 setter 方法接收依赖,对象先被无参构造创建出来,再由容器调用 setter 把依赖放进去。
@Service public class UserService { private UserRepository userRepository; @Autowired public void setUserRepository(UserRepository userRepository) { this.userRepository = userRepository; } }字段注入:直接在成员变量上加@Autowired,这是网上教程里最常见的写法,也是争议最大的写法。
@Service public class UserService { @Autowired private UserRepository userRepository; }从 Spring 团队早期的官方推荐来说,构造器注入优先级最高。为什么?因为字段注入把依赖关系藏得太深,你一眼扫过去根本不知道这个类依赖了谁;更重要的是,依赖不可变、容易测试、能保证对象在创建完成后就处于完整可用状态。字段注入则制造了“先创建后注入”的中间态,一旦容器代理某些对象,字段访问还可能踩到 null 的坑。后面我会专门讲这些实际生产环境里遇到的麻烦。
3. 为什么非要“注入”不可:解耦、测试性与多态
3.1 不用注入会怎样:一段现实的业务代码
假设你要开发一个支付模块,支持支付宝和微信支付。不用依赖注入,代码大概率长这样:
public class OrderService { public void pay(Long orderId) { AlipayPayService alipayPayService = new AlipayPayService(); alipayPayService.pay(orderId); } }现在要接入微信支付,你得改 OrderService 的源码,把 AlipayPayService 替换成 WechatPayService,如果还有银联支付、国际卡支付,每来一个新渠道都要改一次业务类。更致命的是,OrderService 的单元测试没法写:你不可能在测试环境真的去调支付宝的接口,可代码里已经写死了 new AlipayPayService(),你根本没法替换掉它。
如果用 Spring 的思路做,第一步定义接口:
public interface PayService { void pay(Long orderId); }第二步每个渠道是一个实现类,用@Service标记:
@Service public class AlipayPayService implements PayService { @Override public void pay(Long orderId) { /* 支付宝逻辑 */ } } @Service public class WechatPayService implements PayService { @Override public void pay(Long orderId) { /* 微信逻辑 */ } }第三步在业务类里声明依赖:
@Service public class OrderService { private final PayService payService; public OrderService(PayService payService) { this.payService = payService; } public void pay(Long orderId) { payService.pay(orderId); } }OrderService 完全不知道“具体是谁”在干活,它只知道依赖一个 PayService 接口。将来要加银联支付,新增一个实现类即可,OrderService 不碰。写单测的时候直接传一个 PayService 的匿名实现或 mock 对象,干净利落。这就是面向接口编程的意义,而依赖注入是把这种设计落到实处的关键工具。
3.2 容器管理的生命周期和可替换性
Spring 容器不只负责注入,还负责对象的生命周期。默认情况下,Spring 中的 Bean 是单例的,容器启动时创建一次,之后反复复用。如果你不用容器、全靠自己管理,想让一个类全局只有一份,你得自己写静态单例、自己处理线程安全、自己控制销毁逻辑——这些脏活累活,容器全包了。
还有一点被很多人忽略:可替换性。同一个接口在容器里可以注册多个实现,可以通过@Primary指定默认优先项,也可以通过@Qualifier("wechatPayService")精确指定注入哪一个。这种能力不是“省事”那么简单,它让系统具备了运行时切换实现策略的可能。项目里做多租户、做灰度发布、做 A/B 测试,都离不开这种按需取用的注入模型。你再对比一下早期的 Java EE 里的 EJB 那种重量级装配方式,Spring 能把 Java 生态带到一个新高度,靠的绝不是一个“少写几个 new”的噱头。
4. 注入背后的大杀器:Spring 三级缓存与循环依赖
4.1 什么是循环依赖,什么时候会触发
只要用 Spring 写稍大一点的系统,迟早会碰到“循环依赖”这个词。所谓循环依赖,就是 A 类依赖 B,B 类又依赖 A,形成闭环。比如初始化 A 时,容器发现 A 需要 B,于是先去创建 B;创建 B 时发现 B 又需要 A,于是又回头创建 A——这下就死循环了。
Spring 解决这个问题靠的是三级缓存。这三级缓存不是“三张缓存表”这么简单,它是一套为了解决“创建过程中依赖不完整”而精心设计的偏门机制。很多面试题喜欢问“为什么是三级缓存,不是两级?”,这个问题背后其实藏着一个非常微妙的代理问题和对象创建时序问题。网上文章一大把,但能真正讲清楚的不多。
理论上最简单的解决思路是“先创建 A 的早期引用,再补属性”,二级缓存就够了:第一级存最终成品,第二级存半成品(早期引用)。但 Spring 偏偏要用三级缓存,原因在于:默认情况下单例 Bean 不一定是最终的那个对象,AOP 代理可能在 Bean 创建过程中生成。如果 A 最终要被代理,但创建时容器拿到的只是一个普通早期对象,那么当 B 依赖的是“被代理后的 A”时,二级缓存就出问题了。三级缓存里存的是一个“对象工厂”,这个工厂在需要的时候才决定是返回原始对象还是返回代理对象,从而保证了所有依赖方拿到的都是最终正确的版本。
我当年第一次看到这套设计也觉得绕,直到自己动手 Debug 了一遍才真正理解。下面我用一个具体流程给你走一遍。
4.2 三级缓存机制完整流程拆解
Spring 中负责这块的核心类是 DefaultSingletonBeanRegistry,它内部有三个 Map:
- 一级缓存 singletonObjects:存放已完全初始化的成品 Bean。
- 二级缓存 earlySingletonObjects:存放已经开始初始化、但尚未完成属性填充的早期 Bean(半成品)。
- 三级缓存 singletonFactories:存放 Bean 对象工厂,通过工厂可以获取到早期对象。
假设 A 和 B 互相依赖,两者都是单例 Bean,容器创建流程大致是:
- 创建 A,执行构造器,得到 A 的原始对象。
- 把“A 的对象工厂”放入三级缓存 singletonFactories,这个工厂后期可以生成 A 的早期引用,甚至生成 AOP 代理对象。
- 开始填充 A 的属性,发现 A 依赖 B,于是去创建 B。
- 创建 B 的流程同样走一遍:构造对象、放入三级缓存、填充属性。此时 B 发现自己依赖 A。
- 获取 A 时,一级缓存没有,二级缓存没有,三级缓存里能找到 A 的工厂。调用工厂得到 A 的早期引用(必要时这里会做 AOP 代理),放入二级缓存,同时从三级缓存中移除。B 拿到这个“不完整但可用”的 A 引用,完成属性填充,B 初始化完成。
- B 回到容器里被标记为完成,放入一级缓存。A 继续完成自己剩下的属性填充,最终也初始化完成,放入一级缓存。
如果你只是死记这个流程不亲手跑一遍,很容易忽略一个关键细节:三级缓存里存的是 ObjectFactory,而不是对象本身。工厂的存在把“什么时候创建早期对象”和“创建的是原始对象还是代理对象”这两件事延迟到了最后一刻。这个设计看似多此一举,但在 AOP 介入时,它是保证“注入的 A 是代理后对象”的根本手段。
4.3 为什么构造器循环依赖无解,原型 Bean 也解不了
三级缓存能破循环依赖,但有个前提:循环依赖的 Bean 必须支持提前暴露早期引用。Spring 在创建 Bean 时,只有经历了“构造器执行完、属性尚未填充”这个阶段,才能把工厂塞进三级缓存。如果 A 和 B 是通过构造器互相依赖的,比如 A 的构造器参数里有 B,B 的构造器参数里有 A,那么在“构造器执行之前”根本不存在一个可提前暴露的 A,三级缓存机制直接失效,运行时抛 UnsatisfiedDependencyException。
另外,原型 Bean(prototype)不在一二级三级缓存管理的范围内。道理很直接:Spring 创建原型 Bean 时每次都是新对象,不会存放“早期引用”,所以原型 Bean 的循环依赖没有任何缓存手段兜底,容器一贯的态度就是直接报错。我在项目里见过有人把 Bean 改成 prototype 想解决“单例被并发修改”的问题,结果反而引发了一堆循环依赖报错,排查了很久才发现根因。
这里再说句掏心窝的:循环依赖能解开,不代表你应该写循环依赖。Spring 社区一大票资深开发者都明确不推荐循环依赖,因为它让 Bean 的初始化顺序变得隐秘、难以预测。很多时候你重构一下类设计就能彻底避开循环依赖。依赖注入的设计初衷是把依赖关系变得清晰可见,而不是让你借三级缓存去维护一团乱麻。
注意:三级缓存是 Spring 内部实现细节,不是 Spring 框架对外承诺的稳定 API。不同版本的 Spring 可能调整内部的实现方式。你面试时可以侃侃而谈,但写业务代码时不要依赖这些内部机制。
5. 最容易混淆的“注入”们:安全注入与 AOP 增强
5.1 SQL 注入、XXE 注入和 Spring 的依赖注入毫无关系
搜索引擎的世界很奇怪,你把“Spring 框架注入的含义”输进去,返回的结果里大概率混着“SQL 注入万能密码绕过”“xxe 注入”“dvwa sql 注入”这些内容。如果你是冲着 Spring 来的,很容易被带偏。
这里我用最直白的话帮你划清界限:SQL 注入、XXE 注入、命令注入等,都是信息安全领域的安全漏洞类型,它们的“注入”指的是攻击者把恶意数据作为代码或查询语句的一部分,欺骗程序执行。SQL 注入就是攻击者把一段 SQL 语句拼接进参数里,数据库执行了非预期的查询;XXE 注入是攻击者构造恶意 XML 实体,让解析器去读本地文件或发起内部请求。这些和 Spring 的依赖注入(DI)唯一的共同点就是中文翻译都用了“注入”两个字。
做 Java 后端开发,Spring 框架和 SQL 注入防范往往是同一个项目里都要关心的事,但别因为关键词相近就把它们混为一谈。比如你用了 Spring Boot + MyBatis,如果写 SQL 时用了${}拼接参数,那就存在 SQL 注入风险;用#{}参数预编译就能有效规避。这属于“Spring 项目里如何防止安全漏洞”的话题,跟理解“Spring 容器如何注入依赖”完全是两个维度的知识。
5.2 Spring AOP 也算一种“注入”?代理对象的本质
还有一个容易让人迷糊的点:Spring AOP。你在网上搜索“Spring 注入”时,可能会看到“切面织入”“方法增强”这类词。这个“织入”跟“注入”在英文里不是一个词,但在中文技术表达里经常被混用。Spring AOP 通过动态代理拦截目标方法,在方法执行前后插入增强逻辑,比如 @Transactional 就是通过代理来开启事务、提交事务、回滚事务。这种“把横切逻辑嵌入到业务方法里”的行为,有时候被口语化地称为“注入切面”或“注入代理”。
这个理解本身没错,但你要记住,AOP 的“注入”和 DI 的“注入”不是一回事。依赖注入解决的是“对象引用从哪来”,AOP 解决的是“横切逻辑怎么嵌到已有方法上去”。两者在 Spring 容器里经常协同工作——容器创建 Bean 时发现它有事务需求或切面匹配,就会返回一个代理对象,这个代理对象再被注入给其他依赖方。我在讲三级缓存的时候提到“需要时生成代理对象”,就是这个场景的典型应用。
实际开发中,如果你发现注入进来的对象类型和定义的接口对不上、或者拿不到this调用的切面效果,多半就是代理机制在起作用。用 AOP + 注入组合拳时,一定要清楚你拿到的可能是一个$$EnhancerBySpringCGLIB或者是$Proxy类型的代理对象,直接强转成原类会有风险。这也解释了为什么推荐面向接口编程——代理对象通常能兼容接口,但强转成具体类就可能翻车。
6. 实战高频问题:从报错排查到注入选型一把梭
6.1 常见注入报错与排查思路
实操里最常遇到的报错就是UnsatisfiedDependencyException,字面意思是“依赖未被满足”。出现这个异常的常见原因有这么几类:
- 循环依赖且无法被三级缓存兜底:最常见的是构造器循环依赖,或者原型 Bean 循环依赖。排查的时候先顺着报错栈找“Bean named xxx”的链路,画出依赖闭环,然后重构代码拆环。
- 容器里找不到对应 Bean:比如
@Autowired一个接口,但接口实现类没有加@Service,或者实现类根本不在扫描路径下。检查启动类上的@ComponentScan、@SpringBootApplication扫描范围是否覆盖到了。 - 同一类型有多个候选 Bean,但没有指定:容器面临“两个都能注入,你要哪个”的选择困难,报
NoUniqueBeanDefinitionException。用@Primary指定默认,或@Qualifier指定名字。 - 依赖的 Bean 本身初始化失败:比如这个 Bean 在初始化阶段抛异常,那依赖它的 Bean 也会连带报 UnsatisfiedDependency。先去检查被依赖方自己的初始化逻辑。
排查这类问题最直接的手段不是读文档,而是把异常的堆栈信息完整看一遍。Spring 的报错信息虽然长,但信息量极大,它几乎会告诉你哪个 Bean 在创建、哪个依赖没拿到、在哪一行出的问题。我处理过很多“看起来是循环依赖”的问题,实际仔细一读报错,发现只是某个 Bean 的初始化方法抛了空指针。
6.2 注入方式选型:别把 @Autowired 当万能钥匙
前面说过三种注入方式,实际项目里到底怎么选?我按自己的经验给个朴素建议:
构造器注入适合“必须依赖”的场景。一个类离开了某个依赖就完全没法干活,比如 Service 必须有 Repository,那就在构造器里声明。Spring 从 4.3 开始,如果类只有一个构造器,可以省略@Autowired注解,框架自动用那个构造器注入,代码看起来更干净。同时,构造器注入配合 final 字段,还能保证依赖不可变,对多线程安全也有帮助。
setter 注入适合“可选依赖”或“初始化后可修改”的场景。比如一个组件连不上外部服务时,希望能通过 setter 后补设置,或者测试时手动替换依赖。setter 注入的缺点很明显:对象可能处于“还没注全”的中间状态,你没法保证一个对象创建出来后所有依赖都是齐的。
字段注入我一般不推荐,尤其是团队协作的项目里。它太“隐式”了,类看起来只有业务方法,依赖全藏在字段注解里,测试时也只能靠反射或者 Spring 上下文才能把 mock 塞进去。用 IDEA 写代码时,Alibaba 的代码规范插件也会对字段注入给出警告。它不是不能用,而是不该当默认选项。很多人觉得“写少一点代码”就是好设计,这个观念害人不浅。
6.3 装配细节:@Autowired、@Resource 和 @Qualifier 的区别
选定了注入方式之后,具体“由谁去按名字找 Bean”,还要看用的是哪个注解。@Autowired 是 Spring 提供的,默认按类型注入;@Resource 是 JSR-250 标准,默认按字段名或 setter 方法名注入,再退化为按类型注入。实践中,@Resource 在按名字注入的场景下比较方便,而 @Autowired 配合 @Qualifier 可以达到同样效果,还能与@Primary结合使用。
这里还有一个小坑:@Autowired的 required 属性默认是 true,如果容器里没有匹配的 Bean,启动直接报错。如果你确实允许某个依赖为空,可以写@Autowired(required = false),但这样会掩盖配置错误,使用时要谨慎。另一个常见需求是同一个接口有多个实现,想在运行时动态选择。这时不要直接@Autowired List<PayService>把所有实现一次性注入后用 if-else 分派,更好的做法是结合一个工厂类,把策略选择逻辑收敛到一个地方,业务代码只依赖一个注入入口。这个模式在支付、短信、消息推送这类多厂商场景下非常好用。
7. 手写一遍 Spring 注入:断点 Debug 才是理解的最佳路径
说了这么多,如果你让我只给一条学习建议,那就是:别光看文章,打开你的 IDE 动手 Debug 一次。Spring 的 Bean 创建过程横跨AbstractApplicationContext.refresh()、AbstractBeanFactory.doGetBean()、DefaultSingletonBeanRegistry.getSingleton()这几个关键入口,你把断点打在getSingleton上,运行一个最简单的 Spring Boot 应用,就能亲眼看到三级缓存是怎么涨怎么减的。
我当年第一次把断点打在DefaultSingletonBeanRegistry的三级缓存 Map 上,用 Watches 面板挨个观察 key 和 value 的变化,看 A、B 两个 Bean 来回进入工厂方法,那一刻对“三级缓存为什么存在”的理解,比我之前看十篇文章都透彻。Spring 的设计功底很强,但这种功底不靠背诵,靠的是你亲眼看到一个对象从出生到暴露、从半成品到成品的全过程。
写业务代码的时候,我强烈建议你在脑子里随时给“注入”这个词做一个校正:涉及 Spring 的依赖注入,核心是 IoC 容器与对象装配;涉及安全攻击,是恶意输入导致的代码执行;涉及 AOP,是横切逻辑的动态代理。三个方向完全不同,但都叫“注入”,搞清楚你眼前是哪一个,你才能真正理解你在搜索的资料到底在说什么。
最后分享一个我的个人体会:Spring 里让我觉得最值钱的不是它提供了多少注解、多少开箱即用的功能,而是它逼着你把“依赖关系”这件事从潜意识里拎出来显式思考。写代码的时候可以懒一点,让容器多做点事;但理解它的时候不能懒,把注入原理弄通了,你再看 Spring Boot 的自动配置、看 MyBatis 的 Mapper 注入、看各类 starter 的 Bean 注册,都会有豁然开朗的感觉。要是你也被某个注入相关的报错卡过很久,建议把报错原文贴出来,对着上面的排查清单逐条过一遍,基本能找到答案。