依赖注入就是"对象不自己 new 依赖,而是由 Spring 容器把依赖送进来"。你只需要在类里声明"我要什么",Spring 负责创建对象、找到它依赖的对象、按顺序组装好再交给你。这一步也叫控制反转,也就是对象的创建权从业务代码转移到了容器。
很多初学者刚接触 Spring 时被一堆注解绕晕:有的把注解写在字段上,有的写在构造方法上,还有人写在 setter 里,看上去都能跑通,于是随手挑一个用到底。等到自己 new 一个对象报空指针,或者项目启动时报循环依赖,才发现三种写法的差别比想象中大得多。今天这篇文章,编程狮就把字段注入、构造器注入、Setter 注入三种写法连同完整可编译代码一次讲清,说透为什么官方推荐构造器注入,最后给你一张选型对比表。

一、依赖注入是什么,为什么需要它
把这四个字拆开看:"依赖"是 A 类干活需要 B 类,"注入"是 B 不是 A 自己 new 出来的,而是从外面送进来的。 送东西的人就是 Spring 容器。
不用依赖注入时,代码是这么写的,OrderService 既管业务逻辑、又管去哪找依赖:
public class OrderService {
// 自己在内部 new,相当于把实现类焊死在身上
private UserDao userDao = new UserDaoImpl();
public String findUserName(Long id) {
return userDao.getNameById(id);
}
}
这段代码看着没什么问题,需求一变就露馅:想把 UserDaoImpl 换成缓存实现,必须改 OrderService 的源码;想写单元测试,没法把真实的数据库访问换掉。根源就是这个类把"找依赖"的活也揽了。
依赖注入把后一半职责交给容器,业务类只管声明"我需要一个 UserDao"。
1.1 一个最小可运行示例
下面是一份完整可编译的 Spring 示例,展示了容器是怎么把依赖送进来的:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Service;
interface UserDao {
String getNameById(Long id);
}
@Service
class UserDaoImpl implements UserDao {
public String getNameById(Long id) {
return "编程狮用户" + id;
}
}
@Service
class OrderService {
private final UserDao userDao;
// 只声明"我需要一个 UserDao",不关心它是谁、怎么来的
public OrderService(UserDao userDao) {
this.userDao = userDao;
}
public String findUserName(Long id) {
return userDao.getNameById(id);
}
}
@Configuration
@ComponentScan(basePackageClasses = OrderService.class)
class AppConfig {
}
public class DiDemo {
public static void main(String[] args) {
AnnotationConfigApplicationContext ctx =
new AnnotationConfigApplicationContext(AppConfig.class);
OrderService service = ctx.getBean(OrderService.class);
System.out.println(service.findUserName(1L)); // 编程狮用户1
ctx.close();
}
}
注意 OrderService 里没有 new,也没有任何 Spring 的 API,它就是个普通类。容器启动时会做四件事:扫描到 @Service;发现 OrderService 的构造方法需要一个 UserDao;找到唯一实现 UserDaoImpl 并实例化;把它传给构造方法,得到组装完成的 bean。
想按官方文档的节奏把容器的工作细节补齐,可以顺着 Spring 教程 的 IoC 章节系统过一遍。
1.2 常见误解
误解一:依赖注入就等于写个 @Autowired。 不对。注解只是告诉容器"这里要注入",注入方式由标注的位置决定:标在字段上是字段注入,标在构造方法上是构造器注入,标在 setter 上是 Setter 注入。而且从 Spring 4.3 开始,一个类只有一个构造方法时连注解都能省。
误解二:注入的一定得是接口。 注入具体类完全没问题。只是面向接口写,将来换实现、做 mock 都更省事,所以才成了常见约定,而不是语法要求。
误解三:容器会兜底一切,循环依赖也不怕。 容器能兜住一部分,但兜住不等于问题消失——字段注入下两个类互相依赖还能启动成功,这种"能跑"反而把设计问题藏得更深。
二、方式一:字段注入,写法最短但问题最多
字段注入是网上示例里最常见的一种,因为它的代码量最少:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
@Autowired
private UserDao userDao;
public String findUserName(Long id) {
return userDao.getNameById(id);
}
}
看起来干净利落,问题却不少,而且都是那种"平时没事、一出问题就很难查"的类型:
第一,脱离容器就废了。 字段是私有的,又没有构造方法可以传值,容器之外没人能给 userDao 赋值:
public class FieldInjectProblem {
public static void main(String[] args) {
OrderService service = new OrderService();
System.out.println(service.findUserName(1L)); // NullPointerException
}
}
这行代码能编译、能运行,只是在调用那一刻抛出空指针异常。新手常在写单元测试或手动 new 一个工具类时撞上它。
第二,依赖无法声明为 final。 字段注入要求容器在对象构造完成之后再通过反射塞值,所以字段不能是 final,对象的状态也就谈不上不可变。
第三,单元测试只能靠容器或反射。 想替换依赖,要么启动整个 Spring 上下文(慢),要么用反射强行改私有字段(脏)。而构造器注入只需一行 new OrderService(fakeDao)。
第四,依赖关系被藏起来了。 看一个类的公开签名,你完全不知道它依赖了多少东西,于是依赖从两三个悄悄涨到十几个,类的职责早该拆了却没人发现。
第五,循环依赖被掩盖。 字段注入允许 Spring 先把"半成品"对象放进去,因此两个类互相依赖也能启动,设计缺陷就这样被推迟到了运行时。
三、方式二:构造器注入,官方推荐的写法
同样的需求,改成构造器注入是这样:
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final UserDao userDao;
private final SmsClient smsClient;
// Spring 4.3 起,类里只有一个构造方法时,@Autowired 可以省略
public OrderService(UserDao userDao, SmsClient smsClient) {
this.userDao = userDao;
this.smsClient = smsClient;
}
public String findUserName(Long id) {
return userDao.getNameById(id);
}
}
就多了一个构造方法,换来的是四个实打实的好处:
依赖不可变。 字段可以声明为 final,一旦在构造方法里赋值就不能再改,对象从头到尾状态一致,不会出现"构造完了但依赖还是 null"的半成品。
对象构造完成即处于可用状态。 所有依赖在构造时一次性到位,不存在"先 new 出来、稍后再填"的中间态。
单元测试极简。 不需要容器,不需要反射,直接传一个假的进去就行:
public class ConstructorInjectTest {
public static void main(String[] args) {
UserDao fakeDao = new UserDao() {
public String getNameById(Long id) {
return "测试用户";
}
};
OrderService service = new OrderService(fakeDao, null);
System.out.println(service.findUserName(1L)); // 测试用户
}
}
循环依赖在启动期就暴露。 构造器注入要求依赖先就绪,因此 A 依赖 B、B 又依赖 A 时,容器会在启动时直接抛出 BeanCurrentlyInCreationException,而不是等到某个请求打进来才炸。这是优点不是缺点——早失败比晚失败便宜得多。
嫌每个依赖都写一遍赋值语句太啰嗦,可以用 Lombok 的 @RequiredArgsConstructor 自动生成构造方法,字段声明为 private final 即可。
注解、Bean 作用域、生命周期这些细节记不全很正常,随手翻 Spring 速查手册 比翻源码快得多。
四、方式三:Setter 注入与循环依赖的兜底方案
Setter 注入把注解标在 setter 方法上:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class ReportService {
private MailSender mailSender = new ConsoleMailSender(); // 有默认值
@Autowired
public void setMailSender(MailSender mailSender) {
this.mailSender = mailSender;
}
public void send(String to) {
mailSender.send(to, "报表已生成");
}
}
它的定位很清晰:用于"可选依赖",也就是不注入也能正常工作、注入了就覆盖默认行为的场景。 比如发消息的类默认往控制台打印,容器里配了邮件实现就改发邮件。
Setter 注入还能兜住一类棘手场景——循环依赖。构造器注入下 A、B 互相依赖会启动失败,Setter 注入允许"先造对象、后填依赖",死结就解开了。既不想改 Setter 注入、又暂时动不了代码结构,可以用 @Lazy:
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final UserService userService;
// 注入的是一个代理,真正调用时才去容器里取真实对象
public OrderService(@Lazy UserService userService) {
this.userService = userService;
}
public void place(Long userId, Long orderId) {
userService.addPoints(userId, 10);
}
}
@Lazy 注入的不是真实对象而是一个代理,只有第一次真正调用它的方法时才会去容器里取,于是"创建时必须拿到成品"这个要求被绕开了。但要清楚:它是止痛药,不是治疗。 双向依赖通常意味着职责没划清,正经做法是把共用逻辑抽到第三个类里,让依赖变成单向。
五、怎么选:三种注入方式对比与踩坑清单
| 写法 | 依赖可变 | 能否脱离容器 new | 单元测试成本 | 循环依赖 | 适用场景 |
|---|---|---|---|---|---|
| 字段注入 | 是 | 不能,必空指针 | 高,要容器或反射 | 被掩盖,能启动 | 快速验证的演示代码 |
| 构造器注入 | 否,可 final | 能,直接传参 | 低,一行 new | 启动期即报错 | 必选依赖,默认首选 |
| Setter 注入 | 是 | 能,用默认值 | 低 | 允许,可后填 | 可选依赖、需要默认值 |
一句话决策:必选依赖一律用构造器注入,可选依赖用 Setter 注入,字段注入只在写演示代码时图省事。
踩坑清单:
- 字段注入的类千万别自己
new。 拿到的对象依赖全是 null,一调用就空指针。 - 构造器注入启动报循环依赖,别急着改成字段注入。 报错是在提醒你该拆类了,改成字段注入只是把炸弹往后埋。
- 同类型有多个实现要指明。 用
@Qualifier按名字指定,或用@Primary标一个默认实现,否则容器不知道选谁。 - 不要给静态字段加注入注解。 Spring 会直接忽略
static字段,它永远是 null。 - 别在一个类里混用多种注入方式。 构造器注入了三个依赖、又对第四个字段用注解,读代码的人很难判断哪个是必需的。
上面这些写法在 Spring Boot 项目里完全一致,自动配置和 Starter 只是把容器的启动过程藏了起来。想看这一层是怎么封装的,SpringBoot 教程 的起步章节讲得比较细。

总结
依赖注入记住三句话就够:必选依赖用构造器注入,依赖声明为 final,对象构造完成即处于可用状态;可选依赖用 Setter 注入,留好默认值;字段注入图一时省事,代价是空指针、难测试、循环依赖被掩盖。
- 注入方式由注解标注的位置决定,不是由注解本身决定;
- 构造器注入在 Spring 4.3 之后,单构造方法可以省略注解;
- 循环依赖的正确解法是拆分类、理顺依赖方向,
@Lazy只是临时兜底; - 同类型多实现要靠
@Qualifier或@Primary消歧义。
延伸学习
- Spring 框架配套课程 —— 从 IoC 容器讲到 Bean 的完整生命周期。
- Spring IOC 与依赖注入笔记 —— 把容器启动流程与常见配置陷阱一次列清。
- 在线运行 Java 代码工具 —— 不用配环境,直接编译运行本文的示例代码。
常见问题
Q:Spring 4.3 之后,构造方法上的 @Autowired 到底还用不用写?
A:类里只有一个构造方法时不用写,容器会自动拿它来注入依赖。如果类里有多个构造方法,就必须在你希望容器选用的那个上面标注 @Autowired,否则容器不知道该用哪个,会启动失败。
Q:一个类有七八个依赖,构造方法长到没法看,怎么办?
A:先别急着换注入方式,这是类在告诉你"该拆了"。把其中几个内聚的依赖归并成一个新的服务类,往往构造器参数就降到三四个了。如果确实只是参数多、职责没乱,用 Lombok 的 @RequiredArgsConstructor 自动生成构造器即可,代码里不用写那一长串赋值。
Q:老项目全是字段注入,改造成构造器注入工作量会不会很大?
A:逐个类改,一次改一类,别一次性全量替换。改一个类的顺序是:把字段改成 private final;加构造方法接收这些依赖;删掉字段上的注解;跑一遍对应测试。没有测试覆盖的类先补一个冒烟测试再动手。新写的类一律用构造器注入,老类碰到再改,几轮迭代就能收敛。
Q:构造器注入为什么能在启动期就发现循环依赖?
A:因为它要求"依赖先就绪才能造自己"。容器创建 A 时发现 B 还没好,去创建 B 又发现 A 正在创建中,这个环当场就断,于是抛出 BeanCurrentlyInCreationException。字段注入允许先把未填完依赖的对象放进去,环能被打破,问题也就推迟到了运行时。

免费 AI IDE



