ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Bean生命周期与循环依赖:从源码调用链到设计取舍

Spring Bean生命周期与循环依赖:从源码调用链到设计取舍

简介:这份PDF面向准备Java后端面试的求职者与初中级开发者,聚焦Spring体系与数据库调优两大高频考点,帮助在有限时间内系统梳理核心知识。内容围绕Spring Bean生命周期、Spring MVC执行流程、Spring Boot自动装配原理展开,并延伸至SQL执行计划解读、优化技巧、MySQL常见索引类型及B+树底层结构,另附线程池种类与核心参数说明,知识点按模块分层组织,便于对照复习与查漏补缺。资源包共1个PDF文件,大小约396KB,轻量易读,适合打印或移动端随时翻阅。目前已有180人学习下载,可作为面试前的速查清单与知识框架参考。

1. 从一道“背了也不会用”的题说起

Spring Bean 的生命周期,几乎是 Java 面试里出现频率最高、也最容易被背成口诀的一道题:实例化、属性注入、初始化、销毁,四步走完,面试官点头,你出门就忘。可真正让这道题有价值的,不是那四个词,而是它背后那条被 Spring 精心编排的调用链——谁在什么时候被调用、为什么必须是这个顺序、少一步会怎样。搞懂这条链,你再看@PostConstruct为什么比构造器里写初始化逻辑更靠谱、看循环依赖为什么能靠三级缓存兜住、看@Transactional为什么会因为一个final方法直接失效,全都能串起来。这篇不教你背答案,而是把 Spring 容器启动一个 Bean 时到底发生了什么,从源码调用顺序一路拆到设计取舍,顺带把 MVC 流程、自动装配、线程池、B+ 树这些高频点背后的机制讲透,让你下次被追问“为什么”时,答的是原理而不是记忆。

2. Bean 生命周期:一条被精心排序的调用链

2.1 实例化与属性注入:对象先“生出来”,再“喂饱”

很多人把“实例化”和“初始化”混为一谈,这是理解生命周期的第一个坎。实例化(instantiate)只做一件事:在堆上分配对象、调用构造器,此时对象里的字段全是默认值,@Autowired标注的依赖还是null。Spring 拿到这个“空壳”之后,才进入属性注入(populate)阶段,把依赖和配置值填进去。

实例化的方式有两种,面试常问但很少人讲清区别:

// 方式一:反射调用无参构造器(默认) // Spring 通过 BeanDefinition 拿到类名,反射 newInstance Class<?> clazz = Class.forName("com.example.UserService"); Object bean = clazz.getDeclaredConstructor().newInstance(); // 方式二:工厂方法,BeanDefinition 里记录 factoryMethodName // 适用于构造逻辑复杂、需要静态工厂封装的场景 public class BeanFactory { public static UserService createUserService() { // 可以在这里做参数校验、依赖外部配置等构造器做不了的事 return new UserService(); } }

反射方式要求类有无参构造器,这也是为什么一个只写了带参构造器的 Bean 如果不加@Autowired会启动报错——Spring 找不到可用的实例化入口。工厂方法则把“怎么造”这件事从类本身剥离出去,适合需要读配置、做条件判断才能决定实例形态的场景。两种方式产出的都是“半成品”,真正的差别在后面的注入和初始化。

属性注入阶段,Spring 按BeanDefinition里记录的依赖关系逐个set。这里有个容易被忽略的细节:注入顺序并不保证与你代码里的字段声明顺序一致,Spring 内部按依赖解析的拓扑顺序处理,所以不要在某个 setter 里假设另一个依赖已经注入完毕。

2.2 初始化回调:afterPropertiesSet 与 init-method 的先后

属性注入完成后,对象“饱”了,但还没“醒”——业务上需要的初始化逻辑(比如加载缓存、建立连接池、校验配置)还没跑。Spring 提供了三个层次的初始化入口,执行顺序是固定的:

public class OrderService implements InitializingBean { @PostConstruct public void postConstruct() { // 第一顺位:JSR-250 注解,由 CommonAnnotationBeanPostProcessor 触发 } @Override public void afterPropertiesSet() { // 第二顺位:InitializingBean 接口回调 } public void customInit() { // 第三顺位:XML 的 init-method 或 @Bean(initMethod = "customInit") } }

为什么是这个顺序?因为@PostConstruct由BeanPostProcessor的前置处理阶段驱动,afterPropertiesSet是invokeInitMethods里显式判断接口后调用的,而init-method排在接口回调之后。这个顺序不是随意定的:接口回调属于“框架契约”,注解属于“声明式扩展”,自定义方法属于“用户兜底”,越靠后越贴近业务、越靠前越贴近框架。理解这一点,你就明白为什么官方推荐优先用@PostConstruct——它不污染类对 Spring 接口的依赖,同时保证在依赖注入完成后执行。

提示:afterPropertiesSet方法名本身就是契约——它承诺“属性都设置好了”,所以任何依赖注入结果才能做的逻辑放这里才安全,放构造器里必然拿到 null。

2.3 销毁阶段:DisposableBean 与 destroy-method 的收尾

容器关闭时,单例 Bean 走销毁流程,顺序与初始化对称:先@PreDestroy,再DisposableBean.destroy(),最后destroy-method。这里有个血泪经验:原型(prototype)作用域的 Bean,Spring 不负责销毁。容器只跟踪单例的完整生命周期,原型 Bean 交出去之后就不再持有引用,销毁回调永远不会触发,资源泄漏往往就出在这。如果你的原型 Bean 持有连接、文件句柄,必须自己在业务代码里显式关闭。

另一个坑是销毁方法的异常处理:destroy()抛异常不会阻止后续 Bean 的销毁,但会被记进日志,排查资源泄漏时这条日志经常被淹没。我一般会在自定义销毁方法里包一层 try-catch,把关键资源释放的失败单独打点,而不是指望框架帮你兜底。

3. 循环依赖与三级缓存:为什么构造器注入救不了场

3.1 三级缓存的结构与查找顺序

循环依赖是 Bean 生命周期里最反直觉的一块。A 依赖 B、B 又依赖 A,按“先实例化再注入”的顺序,A 注入时 B 还没造出来,B 注入时又要 A,看似死结。Spring 用三级缓存把这个结解开了:

// 一级:完整的单例 Bean,可直接使用 Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256); // 二级:实例化完成但未完成属性注入的“半成品” Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16); // 三级:Bean 的创建工厂,用于生成早期引用(可能是代理对象) Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);

查找逻辑是严格按 1→2→3 的顺序:先查一级,命中直接返回;否则查二级,命中返回半成品;再否则查三级,从工厂拿到早期引用后放入二级并移除三级。这个“升级”动作很关键——同一个 Bean 的早期引用只会被生成一次,后续再要就直接从二级拿,避免重复创建代理。

3.2 为什么构造器注入无法被自动解决

三级缓存能 work 的前提是:对象已经实例化出来了,只是属性没填。字段注入和 setter 注入都满足这个前提——先new出空壳放进三级缓存,再慢慢填依赖。但构造器注入不一样:依赖是构造器的入参,没有依赖就造不出对象,连“半成品”都放不进缓存,三级缓存无从下手。

@Service public class AService { private final BService bService; // 构造器注入:要造 A 必须先有 B,要造 B 必须先有 A,死锁 public AService(BService bService) { this.bService = bService; } }

这就是为什么 Spring 官方推荐构造器注入(保证不可变、依赖明确),却又在循环依赖场景下不得不退让。解决路径有三条:改成 setter/字段注入让三级缓存生效、在其中一个依赖上加@Lazy生成延迟代理、或者最彻底地重构消除循环。@Lazy的本质是注入一个代理对象,真正调用时才去容器里取真实 Bean,把“创建时机”推迟到循环之外,代价是首次调用多一次代理转发,且启动期发现不了依赖问题。

注意:Spring Boot 2.6 之后默认禁止循环依赖,启动直接报错。这不是退步,而是逼你把循环依赖当设计问题处理,而不是靠缓存机制掩盖。

4. Spring MVC 与自动装配:请求怎么进来,配置怎么生效

4.1 DispatcherServlet 的九步调度链

Spring MVC 的执行流程常被画成一张箭头图,但真正值得记的是“哪些环节必须回到前端控制器”。请求进来先到DispatcherServlet,它查HandlerMapping拿到“URL→处理器方法”的映射,再交给HandlerAdapter去真正调用 Controller——适配器这层的存在是为了兼容注解式、函数式等多种控制器写法,把“调用方式”的差异挡在核心流程之外。Controller 返回ModelAndView后,ViewResolver解析出具体视图,渲染成 HTML,最后由DispatcherServlet写回响应。

关键点在于:除了 Controller 执行业务和视图渲染本身,其余每一步的结果都要回到 DispatcherServlet 汇总。拦截器的preHandle在 HandlerAdapter 调用前触发,postHandle在 Controller 返回后、视图渲染前触发,afterCompletion在渲染完成后触发——三个钩子卡在调度链的不同位置,这也是拦截器比过滤器更“懂业务”的原因:过滤器在 Servlet 容器层,拿不到方法参数和目标对象;拦截器在框架层,能访问 HandlerMethod。

4.2 @EnableAutoConfiguration 的条件装配机制

自动装配的核心不是“自动”,而是“条件”。@SpringBootApplication拆开是三个注解,其中@EnableAutoConfiguration通过@Import(AutoConfigurationImportSelector.class)把配置类的加载权交给选择器:

// 选择器读取 META-INF/spring.factories(Boot 2.7 前) // 或 META-INF/spring/...AutoConfiguration.imports(Boot 2.7+) // 拿到候选配置类全限定名列表 protected List<String> getCandidateConfigurations(...) { // 加载所有 jar 包里声明的自动配置类 } // 每个候选配置类上挂着 @Conditional 系列注解 @Configuration @ConditionalOnClass(DataSource.class) // 类路径有 DataSource 才生效 @ConditionalOnMissingBean(DataSource.class) // 用户没自己定义才生效 public class DataSourceAutoConfiguration { ... }

getAutoConfigurationEntry()会先做一轮排除(exclude属性)、再去重,最后交给条件评估。@ConditionalOnClass决定“这个技术栈在不在”,@ConditionalOnMissingBean决定“用户有没有覆盖”,两者组合实现了“约定优于配置、但用户优先”的装配哲学。理解这套机制,你就能解释为什么引入某个 starter 依赖后配置自动生效、为什么自己定义一个同类型 Bean 就能顶掉默认实现——不是魔法,是条件注解在启动期逐个求值。

5. 避坑实录:那些让机制“看起来失效”的现场

5.1 事务注解加在 private 方法上

现象:方法上明明写了@Transactional,抛异常后数据照样写进去了。原因:Spring 事务基于 AOP 代理,JDK 动态代理只能拦截接口方法,CGLIB 也只能拦截可重写的方法,private、final、static方法代理不到,注解形同虚设。解决:改成public,且确保调用方拿到的是代理对象而非this。

5.2 同类内部调用绕过代理

现象:A 方法调 B 方法,B 上的事务注解不生效。原因:this.b()走的是原始对象,不经过代理,AOP 增强根本没机会织入。解决:把 B 拆到另一个 Bean 里注入调用,或用AopContext.currentProxy()拿代理再调。

5.3 异常被 catch 吞掉导致不回滚

现象:业务抛了异常,事务却没回滚。原因:默认只对RuntimeException和Error回滚,且异常被try-catch消化后不再向上抛,事务拦截器感知不到失败。解决:捕获后重新抛出,或显式TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。

5.4 线程池用了无界队列导致 OOM

现象:任务提交越来越慢,最后内存溢出。原因:newFixedThreadPool底层用LinkedBlockingQueue无界队列,任务堆积不触发拒绝策略,队列无限增长。解决:手写ThreadPoolExecutor,显式指定有界队列和拒绝策略,核心线程数按 CPU 密集/IO 密集分别估算。

5.5 索引列上做运算或前置模糊

现象:明明建了索引,EXPLAIN里type却是ALL。原因:where id + 1 = 10、like '%abc'、对索引列用函数,都会让 B+ 树无法按序定位,优化器放弃索引。解决:把运算移到常量侧、模糊查询改后缀匹配或走全文索引,用EXPLAIN的key列验证是否真正命中。

6. 从 B+ 树到线程池:机制背后的取舍逻辑

把上面这些点串起来看,会发现一个共同的设计哲学:框架在“自动化”和“可控性”之间反复权衡。三级缓存用内存换启动期的容错,代价是循环依赖被掩盖成“能跑就行”;自动装配用条件注解换开箱即用,代价是启动链路变长、排查 Bean 来源要靠--debug;线程池用队列缓冲换吞吐,代价是过载时延迟飙升而非快速失败。没有哪个选择是绝对正确的,关键是知道代价在哪。

拿 B+ 树来说,非叶子节点只存索引不存数据,一个 16KB 的页能塞约 1000 个键,三层就能覆盖千万级数据——这个“矮胖”结构是为磁盘 IO 优化的:层数越少,一次查询的随机读越少。这也是为什么范围查询要用叶子节点的链表、为什么前缀模糊会让索引失效:前者顺着链表扫,后者破坏了键的有序性,B+ 树没法二分定位。再看线程池,corePoolSize到maximumPoolSize的扩容不是“线程不够就加”,而是“核心满了进队列、队列满了才加救急线程”,这个顺序决定了突发流量下是先排队还是先扩容,选错了就是延迟和拒绝策略的连锁反应。

我一般验证一个机制是否真懂,会做一件事:把它放到极端场景下推演。比如问自己“如果三级缓存只有两级会怎样”——答案是代理对象的早期引用无法复用,AOP 场景下可能拿到原始对象而非代理,事务注解在循环依赖里失效。这种反推比背结论有用得多。从那以后我每次遇到“注解不生效”“配置没覆盖”这类问题,都强制先走一遍“代理有没有织入、条件有没有满足、调用有没有绕过容器”的排查链,而不是上来就改代码。希望这套推演习惯也能帮到你——这份面试题资料里把上述机制都做了条目化梳理,配合源码对照着看,比单背答案扎实得多,需要的话可以拿去逐条验证。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进