首页 / 资讯中心 / 文章详情

Spring容器核心机制:Bean生命周期、依赖注入与循环依赖实战解析

Spring容器核心机制:Bean生命周期、依赖注入与循环依赖实战解析 ★ FEATURED ARTICLE
上期我们把 Spring 容器的基本用法过了一遍这次直接聊容器内部。别慌不是让你去背源码而是把 Bean 的生命周期、依赖注入的判定逻辑、循环依赖这类面试高频和线上事故高发区一次性讲透。我自己带项目这两年见过不止一次因为对容器理解不到位把单例 Bean 当成多例用或者因为循环依赖问题改了一晚上代码也没定位到根因的情况所以这篇我会把背后的道理和排查套路一起写出来。项目里最好别只会“能用”得能讲清楚容器为什么这样设计出问题时才知道往哪儿查。1. 容器启动后Bean 的一生到底是怎么走的1.1 BeanDefinition 阶段容器眼中还没有对象只有“配方”很多人以为 Spring 容器启动就是挨个new对象其实第一步根本没创建任何实例。容器在做的是“搜集配方”这个配方就是 BeanDefinition。无论是 XML 里的bean标签、Component注解还是Bean方法最终都会被解析成一个个 BeanDefinition 对象里面记录了这个 Bean 的类名、作用域、是否懒加载、初始化方法名、属性值、依赖关系等信息。我在实际项目里遇到过一个很典型的场景新人把配置类里的Bean方法写成了 private结果启动直接报错。原因就在 BeanDefinition 阶段容器需要拿到方法的反射信息private 方法虽然能调用但 CGLIB 代理有问题而且 Spring 官方也不建议这么写。这说明如果你不理解这个阶段很多报错看着莫名其妙实际上就是“配方采集”出了问题。BeanDefinition 架构还有点像开餐厅前先定菜谱你在注解里写的Scope(prototype)、Lazy、DependsOn就是告诉厨师这道菜是现点现做还是提前备好、做的时候先准备哪个配菜。容器后续所有动作都是按照这份菜谱来执行的。所以排查 Bean 相关问题时第一步永远不是翻代码逻辑而是先确认 BeanDefinition 到底有没有被正确注册进来。1.2 实例化与初始化new 出来只是万里长征第一步BeanDefinition 收集完毕后容器才开始真正创建对象。对单例非懒加载的 Bean容器启动时就会走完整的创建流程。我把这个过程拆成几个关键步骤实例化通过构造器new出对象。这里要注意构造器选哪个、入参怎么来本身就是一套复杂的推断逻辑Autowired写在构造器上会影响选型。属性填充把该注入的依赖注入进来。反射拿字段、调用 setter处理Autowired、Resource、Value。Aware 回调如果 Bean 实现了 BeanNameAware、BeanFactoryAware、ApplicationContextAware 等接口在这里回调。BeanPostProcessor 前置处理这是扩展点密集区Spring 自己的 AOP 代理、Autowired注解解析都靠它。初始化方法执行PostConstruct、InitializingBean.afterPropertiesSet()或自定义 init-method。BeanPostProcessor 后置处理比如返回 AOP 代理对象。容器正式持有单例 Bean 放入一级缓存这里的先后顺序很多人记混面试常考。我举一个实战例子公司里有个老系统用 XML 配置某个 Bean 在PostConstruct里依赖另一个 Bean 的数据结果因为依赖 Bean 还没初始化完成每次启动都在那个方法里空指针。后来我把初始化数据的逻辑挪到了ApplicationRunner里因为PostConstruct只保证当前 Bean 初始化完成不保证依赖的 Bean 数据已经准备好了。这个顺序问题就是对生命周期理解不到位最容易翻车的地方。1.3 三级缓存与循环依赖容器如何解开“你中有我我中有你”循环依赖是面试出镜率最高的名字。A 依赖 BB 依赖 A两个 Bean 各持对方引用这在 Spring 默认的单例场景下是可以解决的。三级缓存指的是三个 Map一级缓存存放完整 Bean二级缓存存放早期暴露出来的半成品 Bean三级缓存存放 ObjectFactory。关键在实例化和属性填充之间插入的一个动作提前暴露。A 实例化后还没填充属性时就把 A 的 ObjectFactory 放入三级缓存。等到填充属性发现需要 B去创建 BB 填充属性发现需要 A此时一级缓存没有 A二级缓存也没有但三级缓存的 ObjectFactory 能生成一个早期的 A 引用交给 B。B 走完流程放入一级缓存A 再继续完成自己的初始化。这里有个非常容易误解的点三级缓存解决循环依赖是有前提的。一是只针对单例 Beanprototype 作用域根本不适用每次创建都是新对象没法缓存也没法提前暴露二是只对 setter 注入或字段注入有效构造器注入会在实例化阶段就卡死没法提前暴露。我自己排查过一个事故A 和 B 全是构造器注入启动就一直报循环依赖错误后来把其中一个改成 setter 注入就通过了。还要注意循环依赖通过三级缓存解决之后代理问题也有讲究。如果 A 被事务或 AOP 代理三级缓存里存的是 ObjectFactory生成的是代理前的原始对象还是代理后的对象Spring 在创建代理时会有特殊处理用到earlySingletonReference的判断避免循环依赖场景下拿到非代理对象。这块光看理论不行建议打断点跟一遍源码会有豁然开朗的感觉。2. 从 Autowired 到注入背后的判断逻辑学会读容器的心跳2.1 byType 与 byName 的取舍当候选 Bean 不止一个时Autowired默认是按类型注入的但容器里同类型 Bean 可能不止一个。比如你有两个实现类都实现了同一个接口Autowired一个接口类型字段容器会首先按类型找一找二就傻了不知道该给谁。此时容器会尝试按字段名作为 Bean 名称再找一次这就是退化为 byName 的逻辑。这段逻辑藏在DefaultListableBeanFactory.determineAutowireCandidate里先拿类型候选列表判断是否标记了Primary、是否指定了Priority再回到字段名匹配。我建议把这个顺序记成口诀类型筛选、Primary 优先、名字兜底。不止一个候选且没有其他标识时会直接抛NoUniqueBeanDefinitionException这类报错十有八九就是接口多实现没指定主选。实际开发里byName 兜底看着简单但有个隐蔽问题字段名一旦重构改名注入的 Bean 就变了代码不会报错行为却悄悄改变。我印象很深的一次是同事把一个userService字段改成了userServiceImpl刚好容器里有个userServiceImpl的 Bean从此他调的方法是另一个实现业务逻辑完全不对排查了大半天。后来我们团队定了个规则多实现场景下明确用Qualifier或Resource(name…)不让名字兜底机制在重构时成为暗雷。2.2 Primary、Qualifier、Resource显式指定比搏运气更可靠如果你有多个同类型 Bean最常见的处理是给其中一个类加Primary表示默认优先选择它。这个注解适合“大部分场景都用这个实现个别场景要换”的情况。但如果逻辑上必须区分场景比如订单短信走阿里云、物流短信走腾讯云那Primary就不够用了需要Qualifier精确到名字。Qualifier既可以写在字段上也可以配合Bean自定义名称。我的建议是使用语义化的名字比如Qualifier(aliyunSmsSender)别用类名的无意义缩写。还要记住一点Qualifier的匹配依据是 Bean 名称所以写Bean(aliyunSmsSender)时名字要对上光看类型不顶用。Resource是 JSR-250 的注解默认按字段名找 Bean找不到再按类型找它不支持Primary语义所以和Autowired的行为有细微差别。这里再讲一个容易踩的坑Resource在 JDK 8 之后从 Java EE 模块中被移除Spring Boot 项目里要想用它需要单独引入jakarta.annotation.Resource。有些老项目升级到 Spring Boot 3.x 后发现Resource大面积标红就是这个原因。团队统一用Autowired可以减少这种迁移成本但Resource按名称注入在某些动态切换场景更直观没有绝对好坏关键是一致性。2.3 构造器注入与字段注入很多人忽略了编译期约束字段注入写起来最省事但它的短板是依赖关系不可见测试时得靠反射往私有字段里塞 mock编译期也不会提醒你漏了什么依赖。构造器注入则强制把所有依赖写进参数列表测试时直接 new 出来传参就行依赖不齐根本编译不过。这也是 Spring 官方推荐的注入方式。可能有人问构造器注入会导致构造器参数很多代码很丑怎么办这其实是信号说明类职责过于集中该拆分了。我参与的支付项目中每个 Service 的构造器保持在三个参数以内超出就考虑聚合服务或者拆功能。另外一个细节是构造器注入天然不支持循环依赖前面说过这是好事。如果项目里循环依赖靠字段注入隐藏着升级重构时改成构造器注入很多隐藏问题立刻暴露出来启动就能看到清晰的报错而不是运行到某个方法才诡异失败。还有一个容易忽略的点构造器注入与RequiredArgsConstructor结合使用Lombok 帮我们生成包含 final 字段的构造器。这样配合不可变依赖Bean 被容器创建后依赖引用不会被篡改。如果你还在用字段注入可以试试这个小改造代码整洁度和可测性都会上一个小台阶。3. 作用域与会话状态从“默认单例”到“每次新建”的适用边界3.1 singleton 与 prototype 的真实差异Spring 默认的 scope 是 singleton容器整个生命周期只有一份实例。这也是为什么容器启动时大部分 Bean 就被创建了。singleton 带来的两个直接好处是节省内存、对象复用坏处是所有调用方共享同一个对象Bean 内部如果持有可变状态就存在线程安全问题。很多线上偶发数据错乱查到最后发现是 singleton Bean 里塞了一个有状态的成员变量。prototype 则每次从容器获取都创建新实例比如ctx.getBean(MyPrototype.class)每次拿到的都是不同对象Autowired注入的地方只会注入一次。所以要注意把 prototype Bean 注入到 singleton Bean 里这个 prototype 就退化成 singleton 了因为容器只在上次注入时创建过一次。这块是理解作用域最容易出偏差的地方我在线上就见过把状态 Bean 定义成 prototype 想拿新对象却因为注入方式不对一直拿到旧对象的情况。什么时候选 prototype典型的有带状态的校验器、每次请求参数都不同的计算器、临时会话模型。但绝大多数 Service、Controller、Repository 无状态用 singleton 就很好别为了“每次创建一个”而盲目上 prototype尤其在高并发场景频繁创建对象会引入 GC 压力反而不划算。3.2 request、session、application 作用域的使用场景Web 应用里还会看到 request 和 session 作用域。request 作用域意味着每个 HTTP 请求一个 Bean 实例同一个请求内共享请求结束就销毁。session 作用域则在整个会话期间复用。这两种多用于保存请求上下文数据、用户会话信息。Spring MVC 处理这类 Bean 时如果在一个 singleton Bean 里注入 request 作用域的 Bean直接注入会报错因为请求还没开始对象创建时机对不上。解决办法有两个一是使用ObjectProviderT延迟获取每次调用时拿到当前请求对应的实例二是使用 scoped proxy也就是在Scope里加proxyMode ScopedProxyMode.TARGET_CLASS让容器注入一个代理对象请求真正走到这里时代理再从当前上下文解析出真实 Bean。我推荐 Web 项目优先用 scoped proxy它对调用方透明可以避免把ObjectProvider渗透到业务代码里但要注意代理对象的序列化和性能开销并不大可以放心用。application 作用域在整个 ServletContext 生命周期内共享有点类似于更大范围的 singleton。实际用的场景不算多比如全局配置缓存、全局计数器。不过这类全局状态要特别注意并发修改一般只读比较安全。我见过有人把累计访问量放在 application 作用域的 Bean 里结果并发刷数据直接不对后来换成了 Redis 原子计数这才是正确姿势。3.3 怎么让单例 Bean 安全地使用原型 BeanObjectProvider 与 Lookup前面提到单例注入原型会“第一次注入即永远固定”如果你确实需要在每次方法调用时拿到新的原型 Bean比较推荐的方式是用ObjectProviderT。构造器注入一个ObjectProviderMyPrototype每次调用getObject()就能拿到新的实例。这种方式编码直观也符合依赖注入的思路。示例如下Service public class OrderService { private final ObjectProviderOrderNoGenerator generatorProvider; public OrderService(ObjectProviderOrderNoGenerator generatorProvider) { this.generatorProvider generatorProvider; } public String createOrder() { OrderNoGenerator generator generatorProvider.getObject(); return generator.next(); } }另一种方案是Lookup方法注入Spring 会生成子类重写这个方法每次调用都返回新的原型 Bean。它看起来简单但有个前提目标类不能是 final方法也不能是 private底层涉及 CGLIB 生成子类。部分团队觉得魔法味道太重反而更喜欢 ObjectProvider。我自己的习惯是项目里明确要“每次新对象”时优先 ObjectProvider偶尔一次性的需求才用 Lookup。这个取舍没有标准答案核心是团队可读性和维护成本。还有第三种偏暴力的方案直接从ApplicationContext中手动getBean()。这种写法把容器当成服务定位器侵入到了业务代码中测试时还得 mock 容器我基本不推荐。不过在一些框架底层代码或者无法避免的遗留系统里它也算一种保底解法。最好还是统一到 ObjectProvider 这种带约束的获取方式上可测性会好很多。4. 实战手写一个最小可用容器读懂 Spring 的核心思想4.1 用 Map 模拟容器的注册表理论讲多了还是抽象我建议你亲手写一个 100 行左右的小容器。Spring 容器本质可以理解为三样东西一个存放 BeanDefinition 的注册表、一个负责创建对象的工厂、一个存放单例对象的缓存 Map。我们可以用ConcurrentHashMapString, Object做单例池用另一个 Map 存 Bean 定义。先定义一个简单的定义类public class BeanDefinition { private Class? beanClass; private boolean singleton true; // getter/setter... }注册表就是MapString, BeanDefinition beanDefinitionMap。容器的核心方法包括register()和getBean()。register()把类信息放进注册表getBean()先查单例缓存没有就反射创建。这个阶段跑通了你已经理解了容器最基础的原型把创建和管理对象的过程从业务代码中收拢成一个统一入口。我在教新人时总爱说Spring 就是一个自动化的工厂加仓库。手工时代你在 Service 里 new DAO换成容器后你告诉容器“我有这个类”容器帮你 new 好放在仓库里你需要时直接拿。手写这个迷你容器时你会直观感受到为什么要定义 BeanDefinition为什么要有缓存为什么容器能帮你省掉那么多重复代码。4.2 手动实现 Ioc 的注入解析与循环依赖解决有了注册表后我们要实现最简单的依赖注入扫描字段上的MyAutowired注解拿到字段类型后从容器里按类型找 Bean 并注入。这可以通过反射完成for (Field field : beanClass.getDeclaredFields()) { if (field.isAnnotationPresent(MyAutowired.class)) { Object dependency container.getBean(field.getType()); field.setAccessible(true); field.set(instance, dependency); } }逻辑不复杂但你会发现如果 A 依赖 B、B 依赖 A这个简单的getBean会陷入递归并最终爆栈。此时就是动手实现三级缓存的最佳时机。你可以先只放两个 Map一级缓存放完整 Bean二级缓存放提前暴露的“半成品”。A 实例化后还没注入依赖时先把自己放入二级缓存再开始填属性B 创建时去二级缓存找 A能找到就注入于是循环就被解开了。我建议你自己写一遍这个过程会非常有收获。等你对比 Spring 源码的三级缓存会发现多出的那一层ObjectFactory是为了解决 AOP 代理问题二级缓存虽然能解决循环依赖但代理对象的生成时机很难控制。手写时不求完全一致重在理解“提前暴露”这个设计是怎么打破死锁的。有了这个认知以后再看 Spring 源码就不晕了。4.3 真实 Spring 容器的对照代码发在哪里手写完后你可以对照真实的 Spring 源码看关键类。去读 DefaultListableBeanFactory、AbstractAutowireCapableBeanFactory、DefaultSingletonBeanRegistry 这三个类点开doCreateBean方法能看到我们刚刚模拟的流程实例化、提前暴露、属性填充、初始化。用断点分别打在getSingleton、addSingletonFactory、populateBean上逐步观察容器的运行轨迹。很多面试题说“看过 Spring 源码”其实背结论和读过源码完全两码事。我建议你结合调试和手写代码去验证而不是只看源码分析文章。比如getSingleton(String beanName, boolean allowEarlyReference)里的三级缓存获取逻辑只有自己模拟过才知道它为什么要把allowEarlyReference作为参数暴露出来。源码离我们并不远本质上就是我们手写那个小容器的高配版多了处理注解、代理、生命周期回调、复杂依赖解析等能力。5. 常见问题与排查技巧实录5.1 NoSuchBeanDefinitionException 排查步骤这种异常最常见的表现是启动或调用时报错提示找不到某个 Bean。第一步先去查 BeanDefinition 是否存在可以加--debug启动参数或者用ApplicationContext.getBeanDefinitionNames()打印所有已注册的 Bean 名。如果没有对应 Bean再去看类是否被组件扫描覆盖包路径写错是高频原因比如启动类在com.example.app被扫描类在com.example.other这就扫不到。另一个隐蔽原因是条件装配没有生效比如ConditionalOnProperty、ConditionalOnMissingBean等条件不满足Spring 会静默跳过不注册这个 Bean。我排查过一个场景加了某个 starter但配置类里的Bean始终不执行最后发现是配置属性少写了一个前缀条件直接不成立。所以看到找不到 Bean不要光盯着类定义还要看配置属性和条件注解。5.2 循环依赖报错的几种经典场景循环依赖相关报错在不同版本提示不一样常见的包括BeanCurrentlyInCreationException提示 “Requested bean is currently in creation”。出现这个优先检查是否有两个 Bean 通过构造器相互注入。解决方案不外乎三个改成 setter 或字段注入、使用Lazy延迟其中一个依赖的解析、重构把依赖关系拆成单向。Lazy可以打破循环是因为它注入的是代理对象真正调用时才会解析依赖值得一试但别滥用。还有一类循环依赖是涉及 AOP 代理时出现的比如 A 被事务增强、B 又依赖 A虽然是 setter 注入却仍报错这往往和代理对象创建时机有关。此时需要分析是代理链提前触发导致的一般调整 AOP 配置或拆分事务方法能解决。线上还有一个低概率但很坑的版本问题Spring Boot 2.6 之后默认禁止循环依赖如果升级后老项目突然报错你可以在配置里临时开启spring.main.allow-circular-referencestrue但长期方案还是建议把循环依赖消掉。5.3 Bean 初始化顺序引发的空指针这种问题启动时有时不报错运行时才出问题特别难查。根源通常是对初始化时机的误判。一个 Bean 的PostConstruct里如果去调另一个 Bean 的数据但对方的数据来自数据库或缓存而对方还没完成初始化就会拿不到。这种情况我建议通过DependsOn显式声明依赖或者把初始化动作挪到事件监听器里比如ApplicationReadyEvent保证所有 Bean 都就绪后再执行。注意DependsOn会强制按声明顺序创建 Bean如果乱用容易造成启动变慢但必要时很管用。另外初始化逻辑里尽量只做“容器内状态准备”不要立刻做远程 IO。真要预热数据用ApplicationRunner或CommandLineRunner更合适。我们项目里的缓存预热放在监听器里启动阶段就没有再出过空指针。5.4 配置文件的典型坑DI 里的Value是配置文件解析的重灾区。我见过几种典型错误属性名拼写错了不报错因为 Spring 默认找不到会使用字符串占位符原样传入使用Value(${user.name})时这个字段容易被系统属性覆盖还有在 static 字段上写Value完全注入不进去因为静态字段不属于实例。解决这些问题的统一思路是常量配置集中封装别在业务代码里大量散落Value。更可靠的方式是用ConfigurationProperties绑定一个配置类类型不安全的问题也会暴露在启动阶段。比如Component ConfigurationProperties(prefix order.push) public class OrderPushProperties { private String webhookUrl; private int retryTimes; // getter/setter... }配错类型会直接启动失败比运行时才发现友好得多。我强烈建议新项目统一走这个方案老项目里那些散落的Value也可以逐步迁移安全性会明显提升。6. 几个让你少走弯路的实操建议第一Bean 命名别偷懒。组件扫描默认的 Bean 名是类名首字母小写如果你在 XML 或Bean里手动指定了名字后续的Qualifier、Resource、DependsOn都要用同一个名字大小写敏感。团队里最好约定统一规则比如Bean方法名就是有意义的语义名别出现foo()、bar()这类无法辨识的注册名。第二不要动不动重写容器行为。Spring 开放了大量扩展点比如BeanPostProcessor、BeanFactoryPostProcessor、ImportSelector但如果业务代码里大量出现这种全局钩子排查问题的成本会急剧上升而且兼容性风险很大。滥用这类扩展点升级 Spring Boot 版本时常常要跟着改最好保持在框架层或基础设施层使用。第三测试时不要依赖容器也能活。我在团队里一直强调Service 层单元测试尽量不启动 Spring 容器用 Mockito 直接构造依赖。你只有在纯 POJO 和构造器注入的前提下才能做到“不启动容器也能逻辑自洽”。反过来说如果测试一个类必须启动容器这类设计的耦合度一般比较高值得重构。第四多看启动日志。Spring Boot 默认启动日志里包含 Bean 的注册过程把日志级别调到 debug 后能看到每个条件装配的判断结果。排查“我怎么没注册上”的问题这是最直接的证据源。我每次接手新项目都会先跑一遍 debug 日志把条件装配的成败过一遍比翻代码猜快得多。最后分享一个我这几年最大的体会Spring IoC 容器本质上解决的是对象管理和协作的问题它把“对象怎么创建、依赖怎么给、什么时候销毁”这些琐碎事收拢起来让我们能更专注于业务逻辑本身。但容器不是银弹它带来的间接层、代理、作用域语义都需要开发者心里有数。纸上得来终觉浅建议你按上面说的用手写迷你容器的方式走一遍再回到项目里看那些日常使用的注解你会发现自己“会用”和“理解”之间的距离真的只差一次亲手实现的过程。
阅读完成 · 觉得有帮助?
咨询建站