一、💡 一句话理解
核心结论
IoC 是把对象交给 Spring 管理,DI 是 Spring 把对象需要的依赖传进来;构造方法注入,就是通过构造参数完成 DI。
二、🧭 核心概念
| 概念 | 大白话解释 |
|---|---|
| IoC 容器 | Spring 管理对象的地方 |
| Bean | 被 Spring 容器管理的对象 |
| DI | Spring 把 Bean 交给需要它的对象 |
| 构造方法注入 | 通过构造参数接收依赖 |
IoC 管对象 → 对象成为 Bean → DI 负责组装 Bean三、⚙️ 构造方法注入
@RestController
public class ChatController {
private final ChatClient chatClient;
// Spring 创建 ChatController 时,会查找 ChatClient 类型的 Bean 并传进来。
public ChatController(ChatClient chatClient) {
this.chatClient = chatClient;
}
}Spring 首先按照构造参数类型寻找 Bean:
| 候选数量 | 结果 |
|---|---|
| 1 个 | 直接注入 |
| 0 个 | 启动失败,提示缺少 Bean |
| 多个 | 使用 @Qualifier、@Primary 等进一步选择 |
| 仍无法确定 | 启动失败,不会随便选一个 |
// @Primary:多个同类型 Bean 中的默认选择。
@Bean
@Primary
ChatClient defaultChatClient(ChatClient.Builder builder) {
return builder.build();
}
// @Qualifier:当前注入点明确指定某个 Bean。
public ChatController(
@Qualifier("businessChatClient") ChatClient chatClient) {
this.chatClient = chatClient;
}为什么推荐构造方法注入
依赖清晰,可以使用
final保证初始化后不被替换,也方便在单元测试中直接传入假对象。只有一个构造方法时,Spring 会自动使用它,因此通常不用写@Autowired。
四、🔍 容易混淆的地方
4.1 build() 创建的对象不一定是 Bean
public ToolCallingController(ChatClient.Builder builder) {
// 普通 Java 方法调用:创建新对象,但没有把它注册进 Spring 容器。
this.chatClient = builder.build();
}如果对象由 @Bean 方法返回,才会被注册为 Bean:
@Bean
ChatClient chatClient(ChatClient.Builder builder) {
return builder.build();
}4.2 构造方法循环依赖
如果 A 的构造方法需要 B,而 B 的构造方法又需要 A,双方都无法先创建,Spring 会抛出异常。
正确思路通常是重新拆分职责,而不是依赖循环引用技巧。
五、🛠️ 按图实现一个简易 IoC 容器
整体流程
读取扫描路径 → 扫描
.class→ 判断组件注解 → 反射创建对象 → 存入 Map → 注入依赖。
下面先按图片的顺序实现字段注入。这种方式可以先创建全部对象,再统一给字段赋值。构造方法注入的区别见 5.7。
5.1 第一步:配置包扫描路径
这里的“配置”指的是包扫描范围配置,也就是告诉容器从哪个 Java 包开始找类。
src/main/resources/application.properties:
# IoC 容器从这个包开始扫描
scan.package=com.example.app读取配置:
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
public final class ConfigLoader {
private ConfigLoader() {
}
public static String getScanPackage() {
Properties properties = new Properties();
// getResourceAsStream:从 src/main/resources 中读取文件。
try (InputStream inputStream = ConfigLoader.class
.getClassLoader()
.getResourceAsStream("application.properties")) {
if (inputStream == null) {
throw new IllegalStateException("找不到 application.properties");
}
properties.load(inputStream);
String scanPackage = properties.getProperty("scan.package");
if (scanPackage == null || scanPackage.isBlank()) {
throw new IllegalStateException("没有配置 scan.package");
}
return scanPackage;
} catch (IOException e) {
throw new IllegalStateException("读取配置文件失败", e);
}
}
}最终得到包名 com.example.app。下一步会把它转换为路径 com/example/app。
Spring Boot 通常不用自己写这个配置:@SpringBootApplication 已包含组件扫描,默认从启动类所在包向下扫描。这里自己配置是为了展示手写容器的原理。
5.2 第二步:递归扫描 .class 文件
Java 源文件编译后会变成 .class 文件。扫描的目标,是把包路径下的 .class 加载成 Class<?> 对象。
import java.io.File;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Stream;
public final class ClassScanner {
private ClassScanner() {
}
public static Set<Class<?>> scan(String basePackage) {
Set<Class<?>> classes = new HashSet<>();
// 将 com.example.app 转换成 com/example/app。
String packagePath = basePackage.replace('.', '/');
ClassLoader classLoader = Thread.currentThread()
.getContextClassLoader();
URL resource = classLoader.getResource(packagePath);
if (resource == null) {
throw new IllegalStateException("找不到扫描目录:" + packagePath);
}
// 最小示例只支持 IDE 直接运行时的普通文件目录。
if (!"file".equals(resource.getProtocol())) {
throw new IllegalStateException("当前示例暂不支持扫描 Jar:" + resource);
}
try {
Path rootPath = Paths.get(resource.toURI());
// Files.walk:递归遍历当前目录及其全部子目录。
try (Stream<Path> paths = Files.walk(rootPath)) {
paths.filter(Files::isRegularFile)
.filter(path -> path.toString().endsWith(".class"))
// 暂时排除内部类,例如 UserService$1.class。
.filter(path -> !path.getFileName().toString().contains("$"))
.map(path -> toClass(basePackage, rootPath, path))
.forEach(classes::add);
}
return classes;
} catch (Exception e) {
throw new IllegalStateException("扫描 Class 失败", e);
}
}
private static Class<?> toClass(
String basePackage,
Path rootPath,
Path classFile) {
// 例如得到 service/UserService.class。
String relativePath = rootPath.relativize(classFile).toString();
// 去掉 .class,再把路径分隔符换成点。
String relativeClassName = relativePath
.substring(0, relativePath.length() - 6)
.replace(File.separatorChar, '.');
String className = basePackage + "." + relativeClassName;
try {
// Class.forName:根据完整类名加载 Class 对象。
return Class.forName(className);
} catch (ClassNotFoundException e) {
throw new IllegalStateException("无法加载类:" + className, e);
}
}
}例如扫描完成后,集合中保存的是:
UserController.class
UserService.class
UserRepository.class它们只是描述类结构的 Class 对象,还不是业务对象。
简化边界
真实 Spring 扫描的是 classpath 资源,还需要处理 Jar、过滤规则和
BeanDefinition;上面的代码只用于理解基本原理。
5.3 第三步:定义并识别组件注解
扫描出来的类很多,只有带组件标记的类才交给容器管理。注解必须保留到运行期,反射才能读取。
MyComponent.java:
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE})
public @interface MyComponent {
}MyService.java:
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@MyComponent
public @interface MyService {
}@MyController 和 @MyRepository 的写法相同:它们也在注解类上标记 @MyComponent。
private boolean isComponent(Class<?> clazz) {
// 类直接使用了 @MyComponent。
if (clazz.isAnnotationPresent(MyComponent.class)) {
return true;
}
// 检查 @MyService 等注解本身是否带有 @MyComponent。
for (Annotation annotation : clazz.getAnnotations()) {
if (annotation.annotationType()
.isAnnotationPresent(MyComponent.class)) {
return true;
}
}
return false;
}判断过程:
UserService 上有 @MyService
→ @MyService 上有 @MyComponent
→ UserService 应由容器管理5.4 第四步:反射创建对象并存入 Map
容器遍历 Class 集合,筛选组件类,通过无参构造方法创建对象,再放入 Map。
import java.lang.annotation.Annotation;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public class SimpleIocContainer {
// Key 是类,Value 是创建出来的对象。
private final Map<Class<?>, Object> beans = new ConcurrentHashMap<>();
public void registerBeans(Set<Class<?>> classes) {
for (Class<?> clazz : classes) {
// 接口和抽象类不能直接创建实例。
if (clazz.isInterface()
|| Modifier.isAbstract(clazz.getModifiers())) {
continue;
}
if (!isComponent(clazz)) {
continue;
}
try {
// 当前图片式实现要求类具有无参构造方法。
Constructor<?> constructor = clazz.getDeclaredConstructor();
constructor.setAccessible(true);
// newInstance:相当于执行 new UserService()。
Object bean = constructor.newInstance();
beans.put(clazz, bean);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException(
"创建 Bean 失败:" + clazz.getName(), e);
}
}
}
}Map 中的数据类似:
UserRepository.class → UserRepository 对象
UserService.class → UserService 对象
UserController.class → UserController 对象使用 Class 作为 Key,后面就可以按照依赖类型找对象。ConcurrentHashMap 是线程安全 Map;如果容器只在单线程启动阶段初始化,普通 HashMap 也能工作。
5.5 第五步:给字段注入依赖
先定义注入标记 MyAutowired.java:
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface MyAutowired {
}业务类:
@MyService
public class UserService {
@MyAutowired
private UserRepository userRepository;
public String getUserName() {
return userRepository.findName();
}
}容器遍历所有 Bean 的字段,找到 @MyAutowired,再从 Map 中获取对应类型的对象并赋值:
public void injectDependencies() {
for (Object bean : beans.values()) {
for (Field field : bean.getClass().getDeclaredFields()) {
if (!field.isAnnotationPresent(MyAutowired.class)) {
continue;
}
Object dependency = findBeanByType(field.getType());
try {
// 允许给 private 字段赋值。
field.setAccessible(true);
// 相当于 userService.userRepository = userRepository。
field.set(bean, dependency);
} catch (IllegalAccessException e) {
throw new IllegalStateException("依赖注入失败:" + field, e);
}
}
}
}
private Object findBeanByType(Class<?> requiredType) {
List<Object> matches = beans.entrySet()
.stream()
// 当前 Bean 类型可以赋值给字段类型,就说明类型匹配。
.filter(entry -> requiredType.isAssignableFrom(entry.getKey()))
.map(Map.Entry::getValue)
.toList();
if (matches.isEmpty()) {
throw new IllegalStateException("找不到依赖:" + requiredType.getName());
}
if (matches.size() > 1) {
throw new IllegalStateException("存在多个同类型 Bean:" + requiredType.getName());
}
return matches.get(0);
}实际过程是:
读取 UserService.userRepository 字段
→ 字段类型是 UserRepository
→ 从 Map 找到 UserRepository 对象
→ field.set(userService, userRepository)
→ 注入完成5.6 把图片中的步骤串起来
public class Application {
public static void main(String[] args) {
// 1. 从配置中获取包扫描路径。
String scanPackage = ConfigLoader.getScanPackage();
// 2. 递归扫描并获得 Class 集合。
Set<Class<?>> classes = ClassScanner.scan(scanPackage);
SimpleIocContainer container = new SimpleIocContainer();
// 3. 判断组件注解、反射创建对象、存入 Map。
container.registerBeans(classes);
// 4. 遍历 Bean,为 @MyAutowired 字段注入依赖。
container.injectDependencies();
// 5. 从容器取出对象并使用。
UserController controller = container.getBean(UserController.class);
controller.printUser();
}
}getBean():
public <T> T getBean(Class<T> type) {
Object bean = findBeanByType(type);
return type.cast(bean);
}完整调用链:
读取 com.example.app
→ 扫描出 Class
→ 识别组件注解
→ 反射创建对象
→ 存入 Map
→ 给字段注入依赖
→ 从容器获取 Bean5.7 换成构造方法注入有什么不同
图片里的顺序更适合字段注入。构造方法注入不能先执行无参构造再给字段赋值,而是必须在创建对象前准备好构造参数。
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}容器必须递归创建依赖:
准备创建 UserService
→ 发现构造参数需要 UserRepository
→ 先获取或创建 UserRepository
→ 执行 new UserService(userRepository)核心代码:
private Object createBean(Class<?> beanClass) {
try {
Constructor<?> constructor = beanClass.getDeclaredConstructors()[0];
Class<?>[] parameterTypes = constructor.getParameterTypes();
// 每个构造参数都递归地从容器获取或创建。
Object[] dependencies = Arrays.stream(parameterTypes)
.map(this::getOrCreateBean)
.toArray();
constructor.setAccessible(true);
Object bean = constructor.newInstance(dependencies);
beans.put(beanClass, bean);
return bean;
} catch (ReflectiveOperationException e) {
throw new IllegalStateException(
"创建 Bean 失败:" + beanClass.getName(), e);
}
}此时还要使用一个 Set<Class<?>> 记录“正在创建”的类。如果创建 A 时需要 B,创建 B 时又需要 A,就说明出现了构造器循环依赖,应立即报错,避免无限递归。
与真正 Spring 的边界
这套代码只解释核心链路。真正的 Spring 还会注册
BeanDefinition,并处理 BeanPostProcessor、作用域、生命周期、代理、条件装配等机制。
六、🎤 面试一般怎么问
6.1 30 秒标准回答
IoC 是控制反转,对象不再由业务代码自己创建,而是交给 Spring 容器管理。DI 是依赖注入,容器在创建 Bean 时把它依赖的其他 Bean 注入进来。常见方式有构造方法、Setter 和字段注入,我一般优先使用构造方法注入,因为依赖更明确,可以配合 final,也更方便测试。只有一个构造方法时,可以省略 @Autowired。
6.2 高频追问与短答
6.2.1 IoC 和 DI 有什么区别
IoC 是设计思想:对象的创建权交给容器。DI 是实现 IoC 的主要方式:容器把依赖传给对象。
6.2.2 为什么不推荐字段注入
依赖被隐藏在字段里,不能使用 final,脱离 Spring 容器时也不方便测试。构造方法注入能让依赖在创建对象时一次性准备完整。
6.2.3 多个同类型 Bean 怎么注入
可以用 @Primary 指定默认 Bean,也可以在注入点用 @Qualifier 精确指定。仍无法唯一确定时,Spring 会报错。
6.2.4 @Bean 和 @Component 有什么区别
两者都能把对象交给 Spring 管理。@Component 标在类上,由组件扫描发现;@Bean 标在配置类的方法上,适合手动创建对象或注册第三方类。
6.2.5 Bean 默认是单例吗
是。Spring 默认每个容器只创建一个该 Bean 实例,但可以通过作用域配置改成 prototype、request 等。
6.2.6 如何实现一个简单的 IoC 容器
首先通过配置获取包扫描路径,递归扫描目录下的 .class 文件;然后通过反射读取类上的注解,筛选出需要交给 IoC 容器管理的类。
接着使用反射创建对象,并按照 Bean 名称或类型存入 Map 中。最后遍历这些 Bean,检查它们依赖的对象,再从容器中找到对应 Bean,通过构造方法或字段完成依赖注入。
如果使用构造方法注入,还需要递归创建依赖,并检测循环依赖。
扫描包 → 筛选类 → 反射创建 → Map 保存 → 依赖注入回答边界
这是简化版 IoC 容器的实现思路。真正的 Spring 容器还包含 BeanDefinition、BeanPostProcessor、作用域、生命周期、代理等机制。
6.2.7 构造方法注入会遇到什么问题
最典型的是构造器循环依赖。A 必须先拿到 B,B 又必须先拿到 A,Spring 无法完成创建;通常说明类的职责需要重新拆分。
七、📌 快速记忆
- IoC:对象交给 Spring 管。
- DI:Spring 把依赖传进来。
- 构造方法注入:Spring 根据构造参数寻找 Bean。
- 单构造方法通常不需要
@Autowired。 - 多个同类型 Bean:用
@Primary或@Qualifier。 new、build()创建的普通对象,不会自动成为 Bean。- 手写容器:定范围 → 扫 Class → 看注解 → 建对象 → 存 Map → 注依赖。
面试回答路线
是什么 → IoC 和 DI 的关系 → 为什么推荐构造方法注入 → 多 Bean 怎么选 → 循环依赖边界。
相关笔记:Spring Bean 生命周期、2.如何实现一个 IoC 容器。