Spring框架里的IOC,很多人一开始接触的时候,教材上是这么说的:控制反转,把对象的创建和依赖管理交给容器。听懂了,但总觉得隔了一层。实际写代码的时候,好像也没觉得有多神奇,new一个对象和注入一个对象,区别能有多大?直到你真正独立去维护一个中等规模的项目,或者从零搭建一个工程的时候,才会意识到IOC这玩意儿到底帮你扛了多少活。这篇文章不聊虚的,从源码和实际项目经验出发,把IOC的底层原理、Bean的生命周期、循环依赖的处理逻辑一次说透,最后再手把手写一个Mini版IOC核心,保证你看完能直接给别人讲明白。1. 内容整体设计与思路拆解1.1 为什么Spring要把控制权收走思考一个问题:为什么不用new?原因在真实项目里几乎是被逼出来的。比如一个订单服务要调用库存服务,如果你在自己的代码里直接new一个库存服务对象,那两个模块就死死绑在一起了。库存服务的构造函数一旦变化,订单服务就要跟着改;如果库存服务需要连接数据库,那订单服务在测试环境里还得先把数据库准备好。这不是代码问题,是架构问题。IOC把“谁依赖谁”这件事从代码里分离出来,放到一个统一的容器里管理。对象之间不再直接找对方,而是等容器把依赖送上门。控制权从对象自己手里转移到了容器手里,所以叫“反转”。这个思路放到生活里,就像你出门吃饭。自己做饭,你需要买菜、洗菜、切菜、炒菜、洗碗,全程自己控制。IOC就是你直接去餐馆,告诉服务员你要吃什么,后厨做好了端上来。你不用关心食材是哪来的,也不用管厨师是怎么炒的。你只管吃。Spring容器就是这个餐馆。1.2 从一个具体场景反推IOC的价值假设你在写一个支付对接模块,有支付宝支付和微信支付两种实现。不用IOC的时候,你的业务代码里大概率会长这样:public class OrderService { private AlipayService alipayService = new AlipayService(); private WechatPayService wechatPayService = new WechatPayService(); public void pay(String channel, double amount) { if ("alipay".equals(channel)) { alipayService.pay(amount); } else if ("wechat".equals(channel)) { wechatPayService.pay(amount); } } }这段代码初看起来没什么问题,但后续要加一个新的支付渠道呢?比如银联,你就得改OrderService的代码,加一个成员变量,加一个if分支。每来一个新渠道,就改动一次核心业务代码,这是典型的违反开闭原则。用IOC加上接口设计,代码长这样:public interface PayService { void pay(double amount); } @Service public class AlipayServiceImpl implements PayService { @Override public void pay(double amount) { // 支付宝逻辑 } } @Service public class WechatPayServiceImpl implements PayService { @Override public void pay(double amount) { // 微信支付逻辑 } }业务层通过接口注入,配合@Qualifier或者@Primary指定用哪个实现。以后加新渠道,只需要新增一个实现类,业务代码一行不用改。IOC的价值就在这里:它把“系统里有哪些组件”和“组件之间怎么配合”这两件事,从编译器硬编码里解放了出来。2. 核心细节解析与底层原理2.1 IOC容器的本质是一张巨大的Map很多人觉得IOC容器高深,说白了就是一个注册表,一个Map。Map的key是Bean的name,value是Bean对象或者Bean的实例化策略。Spring启动的时候做三件事:扫描所有带有@Component、@Service、@Repository、@Controller注解的类,把它们的类信息读取出来,根据依赖关系决定实例化顺序,把最终创建好的对象放进容器这个Map里,等谁需要的时候就给谁。这里有个关键点:Spring容器里面存的默认是单例对象。不是每次注入都new一个新的,而是同一个对象在容器生命周期内只有一份。这也是为什么Spring Bean默认是线程不安全的,因为你拿到的就是那个共享对象。如果你在Service里写了一个有状态的成员变量,比如一个计数器,多个请求同时操作它,数据就乱了。容器存储的这个Map结构,实际源码里要比想象中复杂。AbstractBeanFactory里维护了一个singletonObjects的ConcurrentHashMap,这就是一级缓存,专门存放已经完全创建好的单例Bean。理解了这个Map,再去理解Spring的三级缓存就顺理成章了。2.2 Bean的生命周期到底是怎么走的面试聊生命周期,真的把它当成一个流程去记,很容易背串。换一个角度,把它当成一个人从出生到死亡的过程,就好记了。Bean出生于:类被扫描到,容器根据它的构造方法实例化出一个对象。这时候对象还不是一个完整的Bean,因为它的属性还没赋值。接着是属性填充,Spring会看这个Bean的字段上有没有@Autowired等注解,如果有,就去容器里找对应的依赖对象,找到之后通过反射塞进去。然后是初始化阶段。如果Bean实现了InitializingBean接口,Spring会调用它的afterPropertiesSet方法;如果在配置里指定了init-method,Spring会调用指定的初始化方法。还可以通过@PostConstruct注解指定初始化逻辑。这三个的执行顺序是:@PostConstruct先执行,然后是afterPropertiesSet,最后是init-method。初始化完成之后,Bean就躺进了容器里,随时待命供其他对象使用。直到容器关闭,Spring会调用实现了DisposableBean接口的destroy方法,或者执行配置的destroy-method,最后Bean生命结束。这个生命周期里的每一步,Spring都留了扩展点。实现BeanPostProcessor接口,就可以在Bean初始化前后插入自定义逻辑。AOP的代理对象,本质上就是通过BeanPostProcessor搞出来的,在Bean初始化完成之后,返回一个包装过的代理对象代替原来的Bean。2.3 循环依赖的巧妙解法:三级缓存循环依赖是个经典问题,场景很简单。A依赖B,B依赖A,两边互相引用。如果按照普通思路,你要创建A,就得先创建B;要创建B,就得先创建A,这样就死循环了。Spring用了三级缓存解决这个问题。一级缓存存成品Bean,二级缓存存半成品Bean,三级缓存存的是工厂。说白了就是:先把A的早期引用暴露出去,哪怕A的属性还没有完全填充好。B在创建的时候发现自己需要A,拿到A的早期引用,先把B构建完。然后B把A需要的依赖给填上,A继续完成自己剩下的初始化工作。这个设计最巧妙的部分在第三级缓存。三级缓存存的不是一个对象,而是一个ObjectFactory。为什么要这么设计?因为A可能是一个被AOP代理的对象。如果代理发生在属性填充之前,那B拿到的早期引用就应该直接是代理对象。通过工厂,Spring把这个判断延迟到了真正需要暴露引用的那一刻,这种设计思路很值得借鉴。顺便说一句,构造器注入的循环依赖是解决不了的。因为构造器注入在对象还没创建出来的时候就要找依赖,这时候连早期引用都拿不到。遇到这种情况,把其中一个依赖改成setter注入或者字段注入就好了。3. 实操过程与核心环节实现3.1 动手写一个Mini版IOC容器概念说了一堆,不如直接上手写一个迷你版IOC,把核心逻辑跑通。我自己在本地搭了一个Demo工程,大概150行Java代码,把扫描、实例化、依赖注入、单例存储都实现了。这里分享一下核心的设计思路和关键代码。几个核心类:一个Autowired注解、一个Component注解、一个BeanDefinition类、一个IOC容器类。流程分三步:扫描指定包路径下的class文件,找出带@Component注解的类,解析之后放进BeanDefinitionMap,遍历所有BeanDefinition,反射实例化对象并完成依赖注入,把成品放进单例池。扫描的部分用ClassPathScanner,思路是把包路径转成文件路径,递归遍历目录,找到所有.class文件,用反射的Class.forName加载进来。实例化部分比较直接,调用构造方法:public Object createBeanInstance(Class<?> clazz) { try { // 获取无参构造器,通过反射创建实例 return clazz.getDeclaredConstructor().newInstance(); } catch (Exception e) { throw new RuntimeException("Bean实例化失败: " + clazz.getName(), e); } }依赖注入靠遍历字段。拿到Bean的所有Field,检查有没有加上@Autowired注解,如果加上了,就根据字段类型从容器里找对应的Bean,找到之后设置进去:public void populateBean(Object beanInstance, Class<?> clazz) { Field[] fields = clazz.getDeclaredFields(); for (Field field : fields) { Autowired autowired = field.getAnnotation(Autowired.class); if (autowired != null) { Object dependBean = getBean(field.getName()); if (dependBean != null) { field.setAccessible(true); field.set(beanInstance, dependBean); } } } }容器核心的getBean方法,先从单例池里查,查不到就通过createBean方法创建。createBean方法里先实例化,再填充属性,然后放到单例池里。这就是一个简化版的Spring一次完整创建流程。把这个跑通之后,你会发现IOC的核心其实就三个动作:收集类的信息、创建对象、管理依赖。Spring只是在这个基础上加了极其丰富的扩展机制和容错处理。3.2 如何验证自定义IOC容器正确运转写完框架,写两个测试类验证。一个UserService,里面有一个UserRepository类型的字段,加上@Autowired。一个UserRepository类,加上@Component。然后写启动类:public class Application { public static void main(String[] args) { // 扫描指定包路径 List<Class<?>> classes = ClassScanner.scan("com.example.demo"); // 初始化IOC容器 MiniIocContainer container = new MiniIocContainer(classes); container.refresh(); // 从容器中获取Bean并调用方法 UserService userService = (UserService) container.getBean("userService"); userService.sayHello(); } }如果输出UserRepository created.和Hello, userService with repository.就说明整个过程闭环了:UserRepository先被实例化,UserService实例化时通过依赖注入拿到了UserRepository对象。跑通这个流程之后再回去看Spring源码,会有一种豁然开朗的感觉,因为你已经自己走过了一遍从零构建容器的路。4. 常见问题与排查技巧实录4.1 Bean不是你想的那么简单:启动报错的几种情况最常见的启动报错是NoSuchBeanDefinitionException,翻译过来就是找不到Bean。出现这个问题的原因多半是以下几种:扫描的包路径不对,组件不在扫描范围;类上没有加Spring的注解;或者类被加了注解,但@Autowired注入的接口有多个实现类,Spring不知道该选哪个。接口多实现这个问题,我踩过坑。写了一个消息发送接口,有短信发送和邮件发送两个实现类,都不加@Primary。我在Service里直接@Autowired接口类型,启动直接报错。后来在其中一个实现类上加了@Primary,表明这是首选,问题解决。如果希望更精确地在不同场景选择不同实现,可以用@Qualifier("smsSender")指定Bean的名称。还有一类问题是循环依赖导致的启动失败。Spring Boot 2.6之后默认禁止了循环依赖,启动的时候直接报错让你改代码。这实际上是好事,逼着你把代码结构理清楚。如果确实需要保留,可以在application.yml里设置spring.main.allow-circular-references: true,但我个人不建议到处加这个配置,那等于把保护机制关了。4.2 作用域选错引发的Bug案例@Scope注解决定Bean的生命周期范围。单例singleton是默认的,容器里面只有一个实例。原型prototype是每次获取都新建一个对象。还有request和session这两个作用域,分别是在一次HTTP请求和一次会话中有效。我之前见过一个真实案例。某模块要做一个基于ThreadLocal的上下文工具,按照设计,每次请求进来都要创建全新的上下文内容,这个类被定义成了单例Bean。结果现场测试的时候发现,用户A的数据被用户B看到了。排查下来就是单例Bean配合ThreadLocal产生了脏数据。单例Bean在容器里只有一份,ThreadLocal的数据依赖线程隔离,但Tomcat的线程池是会复用线程的,线程复用的时候ThreadLocal里的旧数据没有清理,就串了。这个问题的正确解法有两种:一是把Bean作用域改成request或prototype,保证每次请求都是新对象;二是不用成员变量存状态数据,改成方法参数传值。我后来给的建议是第二种,因为改作用域会影响依赖注入方式,改成方法传值最干净,也最容易测试。4.3 方法注入 vs 字段注入的选型关于Spring推荐的注入方式,业内一直有争论。字段注入写起来最简洁,用@Autowired往字段上一加就完事了。但它是通过反射直接修改字段值的,在单元测试里你需要用@MockBean或者反射工具把依赖塞进去,否则没法构造一个完整的对象。更关键的是,字段注入的方式让类对外暴露的依赖完全不可见,如果这个类有一天要在没有Spring的环境里用,你根本不知道它需要哪些依赖。构造器注入是我在项目里比较推荐的方式。依赖在创建对象的时候就必须提供,保证了对象一旦创建出来,它的依赖一定是齐全的。测试也方便,直接在构造函数里传Mock对象就可以了。Spring官方文档也推荐构造器注入,尤其对于强制依赖。Setter注入适合可选依赖的场景,就是某个依赖没有也能跑,只是部分功能不可用。这种情况用Setter注入,可以在运行时动态替换实现。4.4 配置类里自己new出来对象不走IOC有这样一个反模式:在带有@Configuration的配置类里,通过new关键字创建Bean,然后直接用这个对象,而不是通过容器注入。比如:@Configuration public class BadConfig { @Bean public UserService userService() { return new UserService(new UserRepository()); } }这段代码把UserService放到了容器里,但new UserRepository()这个对象并没有交给容器管理。如果UserService里面还注入了其他Bean,那这些Bean不受容器管理,生命周期和代理都会出问题。比如事务注解失效,AOP代理失效,因为这些功能都是Spring通过容器里的代理对象实现的,你自己new出来的那个对象根本没有代理。正确做法是:UserRepository也定义为一个Bean,然后在@Bean方法里通过参数传入,或者干脆直接注入:@Configuration public class GoodConfig { @Bean public UserService userService(UserRepository userRepository) { return new UserService(userRepository); } }这样UserRepository也是受容器管理的。项目里如果遇到“明明加了事务注解但事务不生效”这类问题,先检查是不是存在手写new绕过容器的情况。4.5 常见问题速查表问题现象常见原因排查方向启动报NoSuchBeanDefinitionException包扫描路径不对、类缺少Spring注解、接口多实现无指定检查扫描路径,确认组件注解,检查是否配置了@Primary或@Qualifier构造器注入的循环依赖启动失败A依赖B,B依赖A改成setter注入或字段注入事务注解不生效对象通过手写new创建,未走代理逻辑确保所有对象都从容器获取请求A的数据串给了请求B单例Bean持有有状态变量,线程池复用去掉成员变量状态,改方法参数传递,或调整作用域同一接口注入多个实现报错未指定注入哪个Bean用@Primary标首选实现,用@Qualifier按名称选中修改配置不生效Bean是单例,初始化时已经加载重启应用或使用@RefreshScope动态刷新5. 工具选型与配置策略5.1 注解配置 vs XML配置的取舍Spring早期是纯XML配置,每一个Bean写一段<bean>标签,一个大项目可能有上千行XML,维护起来相当痛苦。后来引入了注解配置,Java类里面直接标注@Autowired、@Component,把类型的注册和依赖关系都放在代码里,IDE可以自动跳转,比在一堆XML里找配置体验强太多。我的建议是,新项目一律用注解配置,XML只在极少数场景下保留。比如你在给别人提供SDK,不希望对方的组件扫描扫描到你的内部Bean,用XML和Import配合可以限制暴露范围。再比如某些老系统架构里,有动态配置对象需求的场景,XML用<bean>标签定义Meta信息会更直观。除此之外,就不要再用XML了。5.2 依赖注入的几种风格对比注入方式写法优点缺点字段注入@Autowired直接标注字段最简洁对测试不友好,外部不可见依赖,容易违背单一职责Setter注入在Setter方法上加注解支持可选依赖,可动态换实现注入时机不确定,依赖可能缺失构造器注入在构造函数上加注解依赖必须在创建时给全,适合强依赖和不可变对象类构造参数多时略显冗长,IDE用的Lombok@RequiredArgsConstructor可以解决如果一个类的构造器参数超过五个,你就要停下来想一想,这个类是不是承担了太多职责。构造器参数多有两个信号:一个是职责过重,另一个是依赖设计太细碎。优先把相近的依赖聚合成一个对象再传入,比如多个和数据库相关的参数,可以合并成一个RepositoryContext,这样设计更优雅。5.3 关于懒加载与Bean的作用域配合Spring默认是容器启动的时候就把所有单例Bean都创建好,这叫饿汉式加载。有些Bean启动时创建比较耗时,又不一定被用到,可以考虑加@Lazy注解,让它在第一次被用到的时候再创建。但@Lazy不是随随便便就能用的。它有个副作用:如果Bean A标记了@Lazy且依赖了Bean B,那么Spring会给A生成一个代理对象,在真正调用A的方法时才去初始化A,并且获取B的引用。这个代理对象在跨类调用的时候会有性能开销,对于高频调用的核心Bean不建议加@Lazy。实测下来,把@Lazy用在外围的、备用的、连接池等初始化成本较高的Bean上面比较合适。用在核心链路里的Bean,反而会因为代理逻辑影响性能。没必要的就不用加。6. 关于IOC的几点心得体会IOC这个东西,真正理解它的标志不是你记住了它的定义,而是你遇到问题的时候能想到它是怎么参与工作的。我印象最深的一次,是排查一个系统启动时反复卡在某个Bean初始化的问题。当时那个Bean在初始化方法里连了一个外部系统,外部系统响应慢,启动就卡住了。当时的思路是把外部系统的连接改为异步,同时把那个Bean标记为懒加载,系统启动速度一下子恢复了正常。这时候我才真正意识到IOC容器不只是“管理对象”这么简单,它管理的是整个应用的生命周期节奏。另一个经验是关于ApplicationContextAware,通过实现这个接口可以在任何地方获取容器,拿到任意Bean。这个能力很强大,但也容易被滥用。有些人习惯在工具类里用ApplicationContext.getBean()直接拿Bean,写起来方便,但这样就把依赖关系隐藏了,对代码的可测试性和可维护性都有伤害。我现在的原则是:工具类尽量通过构造器传入依赖,实在需要穿透Container的场景才用getBean获取。对于刚接触Spring的开发者,建议先自己动手写一个MiniIOC,不用多复杂,能把扫描、实例化、注入这些流程串起来就行。自己写过一遍之后再看源码,才不会迷失在层层抽象里。最后分享一个小技巧:调试Bean创建顺序的时候常用BeanPostProcessor配合日志输出。在postProcessBeforeInitialization和postProcessAfterInitialization里打印Bean的名称,这样能直接看到每个Bean创建和完成的先后顺序,比对着文档靠脑补高效太多。
Spring框架里的IOC,很多人一开始接触的时候,教材上是这么说的:控制反转,把对象的创建和依赖管理交给容器。听懂了,但总觉得隔了一层。实际写代码的时候,好像也没觉得有多神奇,new一个对象和注入一个对象,区别能有多大?直到你真正独立去维护一个中等规模的项目,或者从零搭建一个工程的时候,才会意识到IOC这玩意儿到底帮你扛了多少活。这篇文章不聊虚的,从源码和实际项目经验出发,把IOC的底层原理、Bean的生命周期、循环依赖的处理逻辑一次说透,最后再手把手写一个Mini版IOC核心,保证你看完能直接给别人讲明白。1. 内容整体设计与思路拆解1.1 为什么Spring要把控制权收走思考一个问题:为什么不用new?原因在真实项目里几乎是被逼出来的。比如一个订单服务要调用库存服务,如果你在自己的代码里直接new一个库存服务对象,那两个模块就死死绑在一起了。库存服务的构造函数一旦变化,订单服务就要跟着改;如果库存服务需要连接数据库,那订单服务在测试环境里还得先把数据库准备好。这不是代码问题,是架构问题。IOC把“谁依赖谁”这件事从代码里分离出来,放到一个统一的容器里管理。对象之间不再直接找对方,而是等容器把依赖送上门。控制权从对象自己手里转移到了容器手里,所以叫“反转”。这个思路放到生活里,就像你出门吃饭。自己做饭,你需要买菜、洗菜、切菜、炒菜、洗碗,全程自己控制。IOC就是你直接去餐馆,告诉服务员你要吃什么,后厨做好了端上来。你不用关心食材是哪来的,也不用管厨师是怎么炒的。你只管吃。Spring容器就是这个餐馆。1.2 从一个具体场景反推IOC的价值假设你在写一个支付对接模块,有支付宝支付和微信支付两种实现。不用IOC的时候,你的业务代码里大概率会长这样:public class OrderService { private AlipayService alipayService = new AlipayService(); private WechatPayService wechatPayService = new WechatPayService(); public void pay(String channel, double amount) { if ("alipay".equals(channel)) { alipayService.pay(amount); } else if ("wechat".equals(channel)) { wechatPayService.pay(amount); } } }这段代码初看起来没什么问题,但后续要加一个新的支付渠道呢?比如银联,你就得改OrderService的代码,加一个成员变量,加一个if分支。每来一个新渠道,就改动一次核心业务代码,这是典型的违反开闭原则。用IOC加上接口设计,代码长这样:public interface PayService { void pay(double amount); } @Service public class AlipayServiceImpl implements PayService { @Override public void pay(double amount) { // 支付宝逻辑 } } @Service public class WechatPayServiceImpl implements PayService { @Override public void pay(double amount) { // 微信支付逻辑 } }业务层通过接口注入,配合@Qualifier或者@Primary指定用哪个实现。以后加新渠道,只需要新增一个实现类,业务代码一行不用改。IOC的价值就在这里:它把“系统里有哪些组件”和“组件之间怎么配合”这两件事,从编译器硬编码里解放了出来。2. 核心细节解析与底层原理2.1 IOC容器的本质是一张巨大的Map很多人觉得IOC容器高深,说白了就是一个注册表,一个Map。Map的key是Bean的name,value是Bean对象或者Bean的实例化策略。Spring启动的时候做三件事:扫描所有带有@Component、@Service、@Repository、@Controller注解的类,把它们的类信息读取出来,根据依赖关系决定实例化顺序,把最终创建好的对象放进容器这个Map里,等谁需要的时候就给谁。这里有个关键点:Spring容器里面存的默认是单例对象。不是每次注入都new一个新的,而是同一个对象在容器生命周期内只有一份。这也是为什么Spring Bean默认是线程不安全的,因为你拿到的就是那个共享对象。如果你在Service里写了一个有状态的成员变量,比如一个计数器,多个请求同时操作它,数据就乱了。容器存储的这个Map结构,实际源码里要比想象中复杂。AbstractBeanFactory里维护了一个singletonObjects的ConcurrentHashMap,这就是一级缓存,专门存放已经完全创建好的单例Bean。理解了这个Map,再去理解Spring的三级缓存就顺理成章了。2.2 Bean的生命周期到底是怎么走的面试聊生命周期,真的把它当成一个流程去记,很容易背串。换一个角度,把它当成一个人从出生到死亡的过程,就好记了。Bean出生于:类被扫描到,容器根据它的构造方法实例化出一个对象。这时候对象还不是一个完整的Bean,因为它的属性还没赋值。接着是属性填充,Spring会看这个Bean的字段上有没有@Autowired等注解,如果有,就去容器里找对应的依赖对象,找到之后通过反射塞进去。然后是初始化阶段。如果Bean实现了InitializingBean接口,Spring会调用它的afterPropertiesSet方法;如果在配置里指定了init-method,Spring会调用指定的初始化方法。还可以通过@PostConstruct注解指定初始化逻辑。这三个的执行顺序是:@PostConstruct先执行,然后是afterPropertiesSet,最后是init-method。初始化完成之后,Bean就躺进了容器里,随时待命供其他对象使用。直到容器关闭,Spring会调用实现了DisposableBean接口的destroy方法,或者执行配置的destroy-method,最后Bean生命结束。这个生命周期里的每一步,Spring都留了扩展点。实现BeanPostProcessor接口,就可以在Bean初始化前后插入自定义逻辑。AOP的代理对象,本质上就是通过BeanPostProcessor搞出来的,在Bean初始化完成之后,返回一个包装过的代理对象代替原来的Bean。2.3 循环依赖的巧妙解法:三级缓存循环依赖是个经典问题,场景很简单。A依赖B,B依赖A,两边互相引用。如果按照普通思路,你要创建A,就得先创建B;要创建B,就得先创建A,这样就死循环了。Spring用了三级缓存解决这个问题。一级缓存存成品Bean,二级缓存存半成品Bean,三级缓存存的是工厂。说白了就是:先把A的早期引用暴露出去,哪怕A的属性还没有完全填充好。B在创建的时候发现自己需要A,拿到A的早期引用,先把B构建完。然后B把A需要的依赖给填上,A继续完成自己剩下的初始化工作。这个设计最巧妙的部分在第三级缓存。三级缓存存的不是一个对象,而是一个ObjectFactory。为什么要这么设计?因为A可能是一个被AOP代理的对象。如果代理发生在属性填充之前,那B拿到的早期引用就应该直接是代理对象。通过工厂,Spring把这个判断延迟到了真正需要暴露引用的那一刻,这种设计思路很值得借鉴。顺便说一句,构造器注入的循环依赖是解决不了的。因为构造器注入在对象还没创建出来的时候就要找依赖,这时候连早期引用都拿不到。遇到这种情况,把其中一个依赖改成setter注入或者字段注入就好了。3. 实操过程与核心环节实现3.1 动手写一个Mini版IOC容器概念说了一堆,不如直接上手写一个迷你版IOC,把核心逻辑跑通。我自己在本地搭了一个Demo工程,大概150行Java代码,把扫描、实例化、依赖注入、单例存储都实现了。这里分享一下核心的设计思路和关键代码。几个核心类:一个Autowired注解、一个Component注解、一个BeanDefinition类、一个IOC容器类。流程分三步:扫描指定包路径下的class文件,找出带@Component注解的类,解析之后放进BeanDefinitionMap,遍历所有BeanDefinition,反射实例化对象并完成依赖注入,把成品放进单例池。扫描的部分用ClassPathScanner,思路是把包路径转成文件路径,递归遍历目录,找到所有.class文件,用反射的Class.forName加载进来。实例化部分比较直接,调用构造方法:public Object createBeanInstance(Class<?> clazz) { try { // 获取无参构造器,通过反射创建实例 return clazz.getDeclaredConstructor().newInstance(); } catch (Exception e) { throw new RuntimeException("Bean实例化失败: " + clazz.getName(), e); } }依赖注入靠遍历字段。拿到Bean的所有Field,检查有没有加上@Autowired注解,如果加上了,就根据字段类型从容器里找对应的Bean,找到之后设置进去:public void populateBean(Object beanInstance, Class<?> clazz) { Field[] fields = clazz.getDeclaredFields(); for (Field field : fields) { Autowired autowired = field.getAnnotation(Autowired.class); if (autowired != null) { Object dependBean = getBean(field.getName()); if (dependBean != null) { field.setAccessible(true); field.set(beanInstance, dependBean); } } } }容器核心的getBean方法,先从单例池里查,查不到就通过createBean方法创建。createBean方法里先实例化,再填充属性,然后放到单例池里。这就是一个简化版的Spring一次完整创建流程。把这个跑通之后,你会发现IOC的核心其实就三个动作:收集类的信息、创建对象、管理依赖。Spring只是在这个基础上加了极其丰富的扩展机制和容错处理。3.2 如何验证自定义IOC容器正确运转写完框架,写两个测试类验证。一个UserService,里面有一个UserRepository类型的字段,加上@Autowired。一个UserRepository类,加上@Component。然后写启动类:public class Application { public static void main(String[] args) { // 扫描指定包路径 List<Class<?>> classes = ClassScanner.scan("com.example.demo"); // 初始化IOC容器 MiniIocContainer container = new MiniIocContainer(classes); container.refresh(); // 从容器中获取Bean并调用方法 UserService userService = (UserService) container.getBean("userService"); userService.sayHello(); } }如果输出UserRepository created.和Hello, userService with repository.就说明整个过程闭环了:UserRepository先被实例化,UserService实例化时通过依赖注入拿到了UserRepository对象。跑通这个流程之后再回去看Spring源码,会有一种豁然开朗的感觉,因为你已经自己走过了一遍从零构建容器的路。4. 常见问题与排查技巧实录4.1 Bean不是你想的那么简单:启动报错的几种情况最常见的启动报错是NoSuchBeanDefinitionException,翻译过来就是找不到Bean。出现这个问题的原因多半是以下几种:扫描的包路径不对,组件不在扫描范围;类上没有加Spring的注解;或者类被加了注解,但@Autowired注入的接口有多个实现类,Spring不知道该选哪个。接口多实现这个问题,我踩过坑。写了一个消息发送接口,有短信发送和邮件发送两个实现类,都不加@Primary。我在Service里直接@Autowired接口类型,启动直接报错。后来在其中一个实现类上加了@Primary,表明这是首选,问题解决。如果希望更精确地在不同场景选择不同实现,可以用@Qualifier("smsSender")指定Bean的名称。还有一类问题是循环依赖导致的启动失败。Spring Boot 2.6之后默认禁止了循环依赖,启动的时候直接报错让你改代码。这实际上是好事,逼着你把代码结构理清楚。如果确实需要保留,可以在application.yml里设置spring.main.allow-circular-references: true,但我个人不建议到处加这个配置,那等于把保护机制关了。4.2 作用域选错引发的Bug案例@Scope注解决定Bean的生命周期范围。单例singleton是默认的,容器里面只有一个实例。原型prototype是每次获取都新建一个对象。还有request和session这两个作用域,分别是在一次HTTP请求和一次会话中有效。我之前见过一个真实案例。某模块要做一个基于ThreadLocal的上下文工具,按照设计,每次请求进来都要创建全新的上下文内容,这个类被定义成了单例Bean。结果现场测试的时候发现,用户A的数据被用户B看到了。排查下来就是单例Bean配合ThreadLocal产生了脏数据。单例Bean在容器里只有一份,ThreadLocal的数据依赖线程隔离,但Tomcat的线程池是会复用线程的,线程复用的时候ThreadLocal里的旧数据没有清理,就串了。这个问题的正确解法有两种:一是把Bean作用域改成request或prototype,保证每次请求都是新对象;二是不用成员变量存状态数据,改成方法参数传值。我后来给的建议是第二种,因为改作用域会影响依赖注入方式,改成方法传值最干净,也最容易测试。4.3 方法注入 vs 字段注入的选型关于Spring推荐的注入方式,业内一直有争论。字段注入写起来最简洁,用@Autowired往字段上一加就完事了。但它是通过反射直接修改字段值的,在单元测试里你需要用@MockBean或者反射工具把依赖塞进去,否则没法构造一个完整的对象。更关键的是,字段注入的方式让类对外暴露的依赖完全不可见,如果这个类有一天要在没有Spring的环境里用,你根本不知道它需要哪些依赖。构造器注入是我在项目里比较推荐的方式。依赖在创建对象的时候就必须提供,保证了对象一旦创建出来,它的依赖一定是齐全的。测试也方便,直接在构造函数里传Mock对象就可以了。Spring官方文档也推荐构造器注入,尤其对于强制依赖。Setter注入适合可选依赖的场景,就是某个依赖没有也能跑,只是部分功能不可用。这种情况用Setter注入,可以在运行时动态替换实现。4.4 配置类里自己new出来对象不走IOC有这样一个反模式:在带有@Configuration的配置类里,通过new关键字创建Bean,然后直接用这个对象,而不是通过容器注入。比如:@Configuration public class BadConfig { @Bean public UserService userService() { return new UserService(new UserRepository()); } }这段代码把UserService放到了容器里,但new UserRepository()这个对象并没有交给容器管理。如果UserService里面还注入了其他Bean,那这些Bean不受容器管理,生命周期和代理都会出问题。比如事务注解失效,AOP代理失效,因为这些功能都是Spring通过容器里的代理对象实现的,你自己new出来的那个对象根本没有代理。正确做法是:UserRepository也定义为一个Bean,然后在@Bean方法里通过参数传入,或者干脆直接注入:@Configuration public class GoodConfig { @Bean public UserService userService(UserRepository userRepository) { return new UserService(userRepository); } }这样UserRepository也是受容器管理的。项目里如果遇到“明明加了事务注解但事务不生效”这类问题,先检查是不是存在手写new绕过容器的情况。4.5 常见问题速查表问题现象常见原因排查方向启动报NoSuchBeanDefinitionException包扫描路径不对、类缺少Spring注解、接口多实现无指定检查扫描路径,确认组件注解,检查是否配置了@Primary或@Qualifier构造器注入的循环依赖启动失败A依赖B,B依赖A改成setter注入或字段注入事务注解不生效对象通过手写new创建,未走代理逻辑确保所有对象都从容器获取请求A的数据串给了请求B单例Bean持有有状态变量,线程池复用去掉成员变量状态,改方法参数传递,或调整作用域同一接口注入多个实现报错未指定注入哪个Bean用@Primary标首选实现,用@Qualifier按名称选中修改配置不生效Bean是单例,初始化时已经加载重启应用或使用@RefreshScope动态刷新5. 工具选型与配置策略5.1 注解配置 vs XML配置的取舍Spring早期是纯XML配置,每一个Bean写一段<bean>标签,一个大项目可能有上千行XML,维护起来相当痛苦。后来引入了注解配置,Java类里面直接标注@Autowired、@Component,把类型的注册和依赖关系都放在代码里,IDE可以自动跳转,比在一堆XML里找配置体验强太多。我的建议是,新项目一律用注解配置,XML只在极少数场景下保留。比如你在给别人提供SDK,不希望对方的组件扫描扫描到你的内部Bean,用XML和Import配合可以限制暴露范围。再比如某些老系统架构里,有动态配置对象需求的场景,XML用<bean>标签定义Meta信息会更直观。除此之外,就不要再用XML了。5.2 依赖注入的几种风格对比注入方式写法优点缺点字段注入@Autowired直接标注字段最简洁对测试不友好,外部不可见依赖,容易违背单一职责Setter注入在Setter方法上加注解支持可选依赖,可动态换实现注入时机不确定,依赖可能缺失构造器注入在构造函数上加注解依赖必须在创建时给全,适合强依赖和不可变对象类构造参数多时略显冗长,IDE用的Lombok@RequiredArgsConstructor可以解决如果一个类的构造器参数超过五个,你就要停下来想一想,这个类是不是承担了太多职责。构造器参数多有两个信号:一个是职责过重,另一个是依赖设计太细碎。优先把相近的依赖聚合成一个对象再传入,比如多个和数据库相关的参数,可以合并成一个RepositoryContext,这样设计更优雅。5.3 关于懒加载与Bean的作用域配合Spring默认是容器启动的时候就把所有单例Bean都创建好,这叫饿汉式加载。有些Bean启动时创建比较耗时,又不一定被用到,可以考虑加@Lazy注解,让它在第一次被用到的时候再创建。但@Lazy不是随随便便就能用的。它有个副作用:如果Bean A标记了@Lazy且依赖了Bean B,那么Spring会给A生成一个代理对象,在真正调用A的方法时才去初始化A,并且获取B的引用。这个代理对象在跨类调用的时候会有性能开销,对于高频调用的核心Bean不建议加@Lazy。实测下来,把@Lazy用在外围的、备用的、连接池等初始化成本较高的Bean上面比较合适。用在核心链路里的Bean,反而会因为代理逻辑影响性能。没必要的就不用加。6. 关于IOC的几点心得体会IOC这个东西,真正理解它的标志不是你记住了它的定义,而是你遇到问题的时候能想到它是怎么参与工作的。我印象最深的一次,是排查一个系统启动时反复卡在某个Bean初始化的问题。当时那个Bean在初始化方法里连了一个外部系统,外部系统响应慢,启动就卡住了。当时的思路是把外部系统的连接改为异步,同时把那个Bean标记为懒加载,系统启动速度一下子恢复了正常。这时候我才真正意识到IOC容器不只是“管理对象”这么简单,它管理的是整个应用的生命周期节奏。另一个经验是关于ApplicationContextAware,通过实现这个接口可以在任何地方获取容器,拿到任意Bean。这个能力很强大,但也容易被滥用。有些人习惯在工具类里用ApplicationContext.getBean()直接拿Bean,写起来方便,但这样就把依赖关系隐藏了,对代码的可测试性和可维护性都有伤害。我现在的原则是:工具类尽量通过构造器传入依赖,实在需要穿透Container的场景才用getBean获取。对于刚接触Spring的开发者,建议先自己动手写一个MiniIOC,不用多复杂,能把扫描、实例化、注入这些流程串起来就行。自己写过一遍之后再看源码,才不会迷失在层层抽象里。最后分享一个小技巧:调试Bean创建顺序的时候常用BeanPostProcessor配合日志输出。在postProcessBeforeInitialization和postProcessAfterInitialization里打印Bean的名称,这样能直接看到每个Bean创建和完成的先后顺序,比对着文档靠脑补高效太多。