Spring IoC与DI深度解析:从容器原理到实战踩坑
在学习Spring这件事上我见过太多人被Bean、容器、Component、Autowired这些名词绊住。拿我自己来说最早照着教程用XML配了一个DataSource配了一个SqlSessionFactory控制台打出了“Hello Spring”看起来跑通了。但过了一个礼拜让我自己搭一个Service调用Mapper的项目我还是卡在“为什么Service里可以直接Autowired一个Mapper接口它到底什么时候被new出来的”这个问题上。后来我才意识到自己缺的不是Spring的API知识而是对Spring最底层两个思想的理解IoC控制反转和DI依赖注入。这两个概念看着玄乎一旦想通了Spring的配置、注解、生命周期、循环依赖很多东西都能串起来。这篇就从0开始把IoC和DI彻底聊透。1. 理解IoC之前先拆掉对象自己管自己的思维定势1.1 传统代码的问题汽车和引擎的耦合先看一段最熟悉的代码public class Car { private Engine engine; public Car() { this.engine new GasEngine(); } public void start() { engine.run(); } }这段代码从功能上讲完全没问题但它隐含了一个设计问题Car在构造时自己决定了自己需要什么引擎。如果有一天要换ElectricEngine或者客户要求装一台氢燃料引擎你只能打开Car的构造方法去改代码。更麻烦的是如果引擎本身也要依赖很多东西Car就需要知道引擎怎么造这会让类的职责越来越重。我之前带实习生做需求遇到过类似的情况一个报表服务在内部直接new了一个Excel导出工具类后来要加PDF导出代码就得改好几处。问题不在于用没用Spring而在于对象之间的依赖关系被写死了。写死的依赖意味着不能替换不方便测试后期扩展也要动老代码。这就是IoC要解决的第一个痛点对象创建和依赖管理不该由对象自己负责而应该交给一个更高层的东西来统一处理。1.2 控制权反转把怎么造依赖的决定权交给外面“控制反转”这四个字重点在“反转”上。传统的做法是车自己造引擎A类自己创建B类主动权在内部的代码手里。IoC的做法是车只声明“我需要一个Engine”但Engine从哪来、用哪种实现由外部决定。打个比方你要给车换轮毂。最原始的做法是你自己买设备、买轮毂、自己拆装。控制权在你手里。但现实中你大概率会把车开去修车店告诉师傅“我要换19寸的轮毂”剩下的事由修车店解决。修车店背后有渠道、有工具、有标准流程它才是帮你完成这件事的“容器”。在Spring的世界里这个“修车店”就是IoC容器。它统一负责创建对象、维护对象之间的依赖关系、管理对象的生命周期。你只需要告诉容器两个信息类是哪些它们之间的关系是什么。至于什么时候new、先new谁再注入谁全由容器说了算。所以IoC的意义不是“少写一行new”而是把对象之间的耦合关系从代码中解耦出去。依赖关系由外部构造对象本身只关心自己的业务逻辑。1.3 Spring是怎么完成IoC的Bean工厂与容器管理Spring里最底层的IoC容器是BeanFactory日常开发中更多用的是它的升级版ApplicationContext。容器的核心工作可以概括成三件事加载配置解析XML、注解或JavaConfig知道要管理哪些类实例化并装配按依赖关系创建对象把依赖注入到目标对象里统一管理生命周期单例对象放在容器里销毁时统一回调。比如同样写一个CarService public class Car { private final Engine engine; public Car(Engine engine) { this.engine engine; } public void start() { engine.run(); } }Spring扫描到这个Car需要Engine于是先创建Engine再通过构造器把Engine传给Car。整个过程中Car不关心Engine在哪、怎么创建它只知道自己需要一个Engine。这个视角变化就是“控制权反转”落到代码上的具体体现。2. DI是IoC在Spring里的落地三种注入方式怎么选DI全称Dependency Injection依赖注入它是实现IoC的主要手段。简单说IoC是思想DI是具体做法。Spring里做DI有构造器注入、Setter注入、字段注入三条路下面把它们的优缺点和适用场景讲透。2.1 构造器注入依赖就是必经之路构造器注入是Spring官方一直推荐的写法也是我在生产环境里最常用的一种Service public class OrderService { private final OrderRepository orderRepository; public OrderService(OrderRepository orderRepository) { this.orderRepository orderRepository; } }用构造器注入有四个明显好处依赖不可变字段用final修饰一旦创建就不能再被替换依赖完整性对象创建时依赖必须齐全不存在“new出来之后发现某个依赖还没设置”的情况测试友好写单元测试时只需要构造器把mock对象传进去不需要启动Spring容器便于发现循环依赖构造器循环依赖会在启动阶段直接报错快速暴露问题。很多项目在代码审查阶段会强制要求“必需依赖必须用构造器注入”原因就在这。它不是更啰嗦而是把依赖关系显式化让接口契约更清晰。2.2 Setter注入留给可选依赖Setter注入看起来是这样Service public class ReportService { private DataSource dataSource; Autowired public void setDataSource(DataSource dataSource) { this.dataSource dataSource; } }适用场景通常有两个一是依赖是可选的类里有默认值或者可以容忍为空二是依赖的内容可能需要后期重新设置。不过实际开发中Setter注入的使用频率明显低于构造器注入。原因很现实可选依赖大部分时候用默认配置或ObjectProvider更优雅而需要运行时更换依赖的场景很少会写成sping Bean。所以我的建议是除非你能说出明确理由否则优先用构造器注入不要为了“少写构造方法”去选Setter。2.3 字段注入省事但容易被忽略的问题字段注入是最“好看”的写法Service public class ProductService { Autowired private ProductRepository productRepository; }一行注解字段直接注入完事。但它有几个很容易被忽略的问题依赖不透明private字段外部看不到查看这个类需要哪些依赖必须翻代码或者写测试测试不友好构造器里没有依赖入口写单元测试要么启动Spring要么用ReflectionTestUtils反射塞字段容易隐藏循环依赖构造器循环依赖启动就报错字段注入的循环依赖要到运行时访问某个Bean才暴露定位成本高可能破坏不可变性字段随时可以被替换难以保证稳定。我见过不少线上项目Controller、Service、Repository全部字段注入代码看起来清爽但阅读和调试体验很差。IDEA等工具默认会给出注入警告阿里Java开发规范也明确不推荐字段注入。日常Demo可以用生产代码要慎重。2.4 团队规范里我怎么定注入规则在我们团队里我强制执行的规则是三行的非常简单依赖类型注入方式原因必填、确定依赖构造器注入保证完整性、可测性可选、可替换依赖Setter注入方便重新设置快速验证/演示代码字段注入简化临时代码但不进生产同时还有个不成文的约定一个类的构造器依赖超过4个就要考虑是不是职责太杂了。依赖数量本身也是设计坏味道的信号IoC只是帮你把依赖关系管理起来它不能替你把过度复杂的类拆好。3. 容器执行过程拆解从Bean定义到Bean生命周期有很多人用Spring用了很久但问“一个Bean从被扫描到能使用中间到底发生了什么”就答不上来。这一节把整个过程拆开看。3.1 一个Bean从定义到可用全链路发生了什么Spring容器启动后一个Bean的完整路径大致如下扫描或读取配置找到所有被Component/Service/Repository标注的类或者XML里的bean标签封装成BeanDefinition把类的信息、依赖信息、作用域、初始化方式装进一个BeanDefinition对象实例化通过反射调用构造器创建对象属性填充/依赖注入按BeanDefinition里记录的依赖注入其他Bean、基本类型或集合执行Aware回调如果Bean实现了BeanNameAware、ApplicationContextAware等接口容器会在这里把对应的信息传进去BeanPostProcessor前置处理每个Bean初始化之前都会经过这一层AOP代理的创建就在这里介入执行初始化方法PostConstruct、InitializingBean、自定义init-method都在这个阶段调用放入单例池如果Bean是单例会放进singletonObjects这个缓存Map里后续直接从池中取。关键点是第4步和第6步。第4步决定了依赖什么时候进来第6步决定了各种扩展工具比如日志切面、权限校验、事务代理是怎么“插队”的。3.2 Bean生命周期里的关键节点Spring面试题里必有“Bean生命周期”真实工作中这个模型也非常有用。拿最常见的初始化阶段来说Component public class CacheChecker { PostConstruct public void init() { System.out.println(缓存预热开始加载热点数据); } }PostConstruct是最常用的初始化回调。相比在构造器里做初始化它能保证容器已经完成了依赖注入所有字段都可用。比如依赖了一个DataSource你想在启动时预热一批查询就只能在PostConstruct里做因为构造器执行时DataSource还没被注入进来。再往后是BeanPostProcessor这是Spring扩展能力最强的接口Component public class MyBeanPostProcessor implements BeanPostProcessor { Override public Object postProcessBeforeInitialization(Object bean, String beanName) { return bean; } Override public Object postProcessAfterInitialization(Object bean, String beanName) { return bean; } }AOP、事务、异步注解这些底层机制几乎都是靠BeanPostProcessor在Bean初始化之后套上代理对象实现的。理解了这一点再去看EnableAsync、EnableTransactionManagement这些注解就会明白它们本质上是通过扩展点让容器在Bean初始化时“插手”加工对象。3.3 三级缓存为什么能兜住单例循环依赖网上关于Spring三级缓存的讨论很多我尽量用不绕弯的方式讲清楚。先看一个经典循环依赖场景A依赖BB依赖A两个Bean都通过字段或Setter方式注入。容器创建A时发现A需要B于是先去创建B创建B时发现B需要A于是又回头找A。如果没有额外机制这就死循环了。Spring的做法是用三个Map缓存有层次地兜住一级缓存singletonObjects存放创建完成的完整单例Bean二级缓存earlySingletonObjects存放已经实例化但尚未属性填充完成的早期对象三级缓存singletonFactories存放ObjectFactory工厂工厂能产出对象的早期引用。流程是这样的A开始创建实例化完成但还没注入依赖Spring把能产出A的ObjectFactory放进三级缓存A填充属性时发现需要B触发B创建B实例化完成填充属性时发现需要A从三级缓存中拿到A的ObjectFactory调用getObject得到A的早期引用B拿A的早期引用完成注入B自己也就创建完成放入一级缓存A从先前容器取B完成注入最终也创建完成放入一级缓存。为什么二级缓存不够非要三级缓存核心原因是代理对象。如果A的方法上加了Transactional或Aspect切面那么A最终放进一级缓存的其实是个代理对象而不是原始对象。Spring希望这个代理对象在A“真正需要被提前引用”的时候才创建而不是A一实例化就创建。三级缓存存放ObjectFactory的好处是只有在B真的来拿A的早期引用时工厂才会执行getObject并在此时判断是否需要生成代理。如果全程没人循环依赖工厂就永远不会被调用也就不会白白多创建代理。这就是设计上的延迟创建思想。同样要记住三级缓存的边界构造器注入的循环依赖解决不了因为构造器阶段对象还没实例化根本拿不到早期引用原型作用域的循环依赖解决不了因为原型Bean不缓存没有中间缓冲的余地。3.4 观察者模式在容器事件机制中的体现IoC思想不只体现在对象装配上Spring的事件机制也是它的延伸。ApplicationContext本身提供了事件发布能力事件监听器同样由容器管理这其实就是观察者模式的典型落地。// 事件对象 public class UserLoginEvent extends ApplicationEvent { public UserLoginEvent(String username) { super(username); } } // 发布者 Component public class AuthService { Autowired private ApplicationEventPublisher publisher; public void login(String username) { System.out.println(登录成功: username); publisher.publishEvent(new UserLoginEvent(username)); } } // 监听者 Component public class LoginLogListener { EventListener public void onLogin(UserLoginEvent event) { System.out.println(记录登录日志: event.getSource()); } }在这个例子里AuthService并不知道LoginLogListener的存在监听者只是一个被容器管理的独立Bean。发布者只负责发事件监听者只关心自己感兴趣的事件类型依赖关系完全解耦。这种“对象之间通过容器间接协作”的模式和IoC的思想是一脉相承的对象不再互相直接拉依赖而是在容器的协调下各司其职。4. 手写一个迷你IoC容器把黑盒变成白盒“手写Spring”是最近很热的学习方式。我建议不必手写整个Spring但可以自己撸一个几十行的迷你容器用它理解Bean扫描和依赖注入的本质。4.1 手写前先确定需求这个迷你容器只做三件事扫描指定包下带Component注解的类实例化这些类并保存在Map里扫描所有实例的字段给带Autowired的字段注入对应依赖。它不做接口映射、不做生命周期回调、不做代理但核心思路和Spring第一批启动流程是一致的。4.2 完整实现代码下面是完整的演示代码跑一个简单的单层包扫描没问题。先定义注解Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) public interface Component { } Target(ElementType.FIELD) Retention(RetentionPolicy.RUNTIME) public interface Autowired { }然后是核心容器import java.io.File; import java.lang.reflect.Field; import java.net.URL; import java.util.Enumeration; import java.util.HashMap; import java.util.Map; public class MiniSpringContext { private final MapClass?, Object beans new HashMap(); public void scan(String packageName) throws Exception { String path packageName.replace(., /); EnumerationURL resources Thread.currentThread() .getContextClassLoader().getResources(path); while (resources.hasMoreElements()) { URL resource resources.nextElement(); File dir new File(resource.toURI()); for (File file : dir.listFiles()) { if (!file.getName().endsWith(.class)) { continue; } String className packageName . file.getName().replace(.class, ); Class? clazz Class.forName(className); if (clazz.isAnnotationPresent(Component.class)) { Object instance clazz.getDeclaredConstructor().newInstance(); beans.put(clazz, instance); } } } dependencyInject(); } private void dependencyInject() throws Exception { for (Object bean : beans.values()) { for (Field field : bean.getClass().getDeclaredFields()) { if (field.isAnnotationPresent(Autowired.class)) { field.setAccessible(true); Object dependency beans.get(field.getType()); if (dependency null) { throw new RuntimeException(依赖未注册: field.getType().getName()); } field.set(bean, dependency); } } } } public T T getBean(ClassT requiredType) { return requiredType.cast(beans.get(requiredType)); } }这段代码最关键的地方在第46行的dependencyInject()方法。它拿到每个Bean的字段检查是否标注了Autowired然后用field.setAccessible(true)绕过private限制再根据字段类型从Map里取出依赖反射赋值进去。这就是DI最朴素的实现——容器替你把依赖塞进字段里。写个测试类验证下Component public class UserRepository { public String findName() { return zhangsan; } } Component public class UserService { Autowired private UserRepository userRepository; public String getName() { return userRepository.findName(); } } public class Main { public static void main(String[] args) throws Exception { MiniSpringContext context new MiniSpringContext(); context.scan(com.demo); UserService userService context.getBean(UserService.class); System.out.println(userService.getName()); } }把UserRepository和UserService放在com.demo包下运行控制台会打印出zhangsan。也就是说在没有配置任何XML、没有依赖Spring框架的情况下我们仅靠两个注解和几个类就实现了IoC的基本闭环。4.3 手写后你才会发现的Spring取舍自己写完这个迷你容器很多原来“背概念”的东西就有体感了在我的容器里beans.get(field.getType())用的是字段类型作为key所以如果字段声明成接口类型而Map里存的是实现类依赖就找不到。Spring是怎么解决这个问题的它会把接口的所有实现类找出来按Primary、Qualifier或者字段名去匹配。这层逻辑非常繁琐看到Spring源码里一团又一团的匹配规则也就不奇怪了我的容器只用无参构造器Spring要支持构造器注入就必须分析构造器参数、从容器里逐个找依赖再传入参数复杂度直接翻倍我的容器不支持AOP代理因为没有BeanPostProcessor这样的扩展点。Spring把“实例化”和“加工”分两步所有增强逻辑都在“加工”阶段插入这就是它能无限扩展的底座。手写迷你容器的意义不是复刻Spring而是理解“容器到底做了什么”。想通了这几行回看Spring的十万行源码你会发现它只是在同样的主骨架上增加了更多灵活的策略而已。5. 循环依赖、作用域与测试最容易踩的三个实战大坑5.1 启动报错但日志看不明白先检查扫描路径刚开始用Spring Boot时最常见的报错是下面这行No qualifying bean of type com.example.service.UserService available大概率原因不是依赖写错而是容器里根本没有这个Bean。Spring Boot的默认扫描规则是以启动类所在包为根递归向下扫描。如果启动类在com.example.demo而UserService写在com.example.service容器就不会去扫描它。这就引出IoC一个极其重要的认知容器能帮我们管理依赖但它只能管理它“知道”的依赖。扫描路径就是容器的管辖范围。解决方法是把启动类放在所有子包的顶层比如com.example这样com.example下的所有包都能被自动扫描到。如果是非Spring Boot项目也可以通过ComponentScan指定额外扫描路径。排查这类问题时我一般按这条路走先确认类上有没有Component或其派生注解再确认包结构是否在扫描范围内最后确认有没有被Conditional之类的条件注解屏蔽。这三种情况只要录一两次后面再看“No qualifying bean”报错就会很自然。5.2 构造器循环依赖不是配置改一下就能救上一个吐槽过字段注入会隐藏循环依赖构造器注入会直接把循环依赖暴露出来。但暴露不代表解决实际碰到构造器循环依赖重启项目是过不去的。典型场景Component public class AuthService { private final UserService userService; public AuthService(UserService userService) { this.userService userService; } } Component public class UserService { private final AuthService authService; public UserService(AuthService authService) { this.authService authService; } }启动报错不是配置能解决的Spring官方也明确不打算支持构造器循环依赖。我在项目里真正解决这个问题的方式是重构把互相调用的公共逻辑提取到第三个服务里让两个服务都依赖第三个服务双向依赖就变成了单向依赖链。还有一种更快但不彻底的方案是把其中一个依赖从构造器改成ObjectProviderComponent public class AuthService { private final ObjectProviderUserService userServiceProvider; public AuthService(ObjectProviderUserService userServiceProvider) { this.userServiceProvider userServiceProvider; } public void doSomething() { UserService userService userServiceProvider.getObject(); } }ObjectProvider是延迟获取的它允许你在方法执行时再从容器里取依赖绕过创建阶段的双向依赖。但这属于“补救方案”它掩盖了设计问题尽量避免在核心业务里靠它硬解循环依赖。真正该做的是审视类职责把单向依赖理清楚。5.3 单例Bean依赖原型Bean注入一次就失效Spring里单例是默认作用域原型Bean则会每次获取时创建一个新实例。但如果你在一个单例Bean里直接Autowired一个原型Bean原型特性会完全失效Component Scope(prototype) public class PrototypeService { } Component public class SingletonService { Autowired private PrototypeService prototypeService; public void print() { System.out.println(prototypeService); } }原因很简单单例Bean在创建时只做一次依赖注入注入进去的PrototypeService已经固定了后续调用不会再去容器里拿新实例。这也是“依赖注入时机”这个知识点最容易踩的坑注入发生在Bean初始化阶段而不是方法调用阶段。想让每次调用都拿到新实例正规做法是用ObjectProviderComponent public class SingletonService { private final ObjectProviderPrototypeService prototypeProvider; public SingletonService(ObjectProviderPrototypeService prototypeProvider) { this.prototypeProvider prototypeProvider; } public void print() { System.out.println(prototypeProvider.getObject()); } }每次调用getObject()容器才会创建一个PrototypeService返回。这就把“什么时候取依赖”的控制权从构造阶段延后到了方法执行阶段理解了这一点你对IoC容器接管依赖时机的理解会更进一步。5.4 不依赖Spring容器也能单元测试最后分享一个DI带来的隐藏福利对象之间的依赖如果是通过构造器传入的写单元测试根本不需要启动Spring容器。public class UserService { private final UserRepository userRepository; public UserService(UserRepository userRepository) { this.userRepository userRepository; } public String getUserName(String id) { return userRepository.findById(id).getName(); } }测试类可以直接用Mockito手工构造对象class UserServiceTest { Test void shouldGetUserName() { UserRepository repository mock(UserRepository.class); when(repository.findById(1)).thenReturn(new User(1, zhangsan)); UserService service new UserService(repository); assertEquals(zhangsan, service.getUserName(1)); } }这里完全没有Spring参与只是把一个mock对象通过构造器传进去。如果项目里大量使用字段注入测试前就得启动ApplicationContext又慢又重。反过来讲DI强制我们把依赖暴露在构造器签名里也逼着类设计者去面对“这个类到底需要几个外部依赖”这个问题。依赖关系越清晰代码越容易被测试这句话在我带过的项目里反复得到验证。如果你正在从0开始学Spring我的建议是先别看各种花哨的启动器也别急着背注解列表。去找一个最简单的项目把Service、Repository这套关系写出来然后停下来想想——如果没有Spring这些对象谁来创建谁注入给谁想清楚这个问题IoC和DI就算过了第一关。我自己的习惯是每次看到Autowired都会在心里翻译一句话“这里不是我把依赖拉进类里而是容器把依赖递到我手上。”这种视角转换之后Spring其他组件学起来会顺很多。