ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Spring核心机制:Bean作用域、生命周期与自动装配完全解析

2026/10/8 9:33:42 拓冰建站 浏览量
Spring核心机制:Bean作用域、生命周期与自动装配完全解析 很多人刚接触Spring的时候最容易被三个名词绕晕Bean作用域、生命周期、自动装配。这三个词单独拎出来看好像都懂一结合到项目里就各种诡异问题——比如接口返回的数据串了、对象被莫名重置、依赖注入偶尔失效、启动报循环依赖错误。其实说白了Spring就是一套帮你管理对象的框架而这套管理的核心规矩恰好就是这三件事。把这三个点彻底吃透你无论是看Spring源码还是排查线上Bug都会轻松不止一个量级。这篇文章我就用实际踩坑和验证过的经验把这三个核心机制掰开揉碎讲清楚。这篇文章的目标读者是那些已经能写出简单Spring Boot接口、但遇到稍微复杂一点的Bean配置就发怵的同学也适合准备Java面试、想在原理层面把Spring讲明白的人。我会从最基础的作用域讲起一路深入到生命周期钩子、循环依赖和三级缓存每部分都会给到能直接跑的代码和实测结果不搞虚的。1. Bean作用域Spring到底给你造了几个对象1.1 singleton与prototype最常用但也最容易搞混的两个Bean作用域说白了就是回答一个问题你从Spring容器里拿同一个Bean拿到的到底是不是同一个对象默认情况下Spring容器里每个Bean只有一个实例不管你调用多少次getBean拿到的都是同一个对象这个就是singleton作用域。项目里99%的Bean其实都适合用singleton因为无状态的Service、Mapper、工具类共享一个实例完全没问题还省内存、省创建开销。原型作用域prototype则相反每次从容器获取时都会new一个新对象出来。什么时候用典型场景是像有状态的对象比如一次请求里携带了临时数据的实体、一个不能共享的游标、一个每次都要复制初始配置的组件。我以前接手过一个项目里面有个类叫TaskRunner内部保存了这次任务的执行进度结果被人用singleton注入了线上并发一高两个用户的任务进度互相覆盖查了半天才定位到是作用域问题。后来改成Scope(prototype)问题立刻消失。代码长这样Component Scope(prototype) public class TaskRunner { private int progress 0; public void run() { progress; System.out.println(当前进度: progress); } }注意这里有个隐藏的比较大的坑如果一个prototype的Bean被注入到一个singleton的Bean里由于singleton只初始化一次那这个prototype Bean实际上只会被创建一次后面无论怎么调用都是同一个对象。很多人明明写了Scope(prototype)却发现根本没生效原因就在这。解决办法有这么几种不使用字段注入而是每次从ApplicationContext里getBean。用ObjectFactory或Provider 来做延迟获取每次调用时再拿新的实例。把注解加在方法上通过Bean方法创建同时在被注入方用Lookup标注方法。第一种方式最简单但会让代码耦合容器第二种比较优雅代码也干净。很多Spring框架内部解决多例注入问题用的就是ObjectFactory这套机制网上说的“不要在一个单例Bean里直接注入多例Bean”本质就是这条。生命周期这块我在后面专门讲这里先把作用域的分类说透。除了singleton和prototypeSpring在Web环境下还扩展了几个作用域这也是面试里经常和“Web容器”绑在一起问的知识点。1.2 request、session、applicationWeb环境下的专属作用域在Spring MVC或者Spring WebFlux项目里如果你用的是Spring Boot的web starter那下面这几个作用域可以直接用request每个HTTP请求对应一个Bean实例请求结束就销毁。session每个HTTP会话对应一个Bean实例会话结束才销毁。application整个ServletContext周期里只有一个实例和singleton有点像但它是从ServletContext维度去存取的。真正用的时候有个细节这些Bean默认是代理模式因为Spring容器初始化的时候可能根本没有活跃的HTTP请求或会话它没法直接创建一个真正的实例。所以Spring会先给你一个代理对象等真正在请求范围内调用方法时才解析到当前请求对应的实例。这也是为什么Scope注解里可以选择proxyMode比如Component Scope(value request, proxyMode ScopedProxyMode.TARGET_CLASS) public class RequestContextHolder { public String getSessionId() { // ... } }如果不配置代理模式直接往单例Bean里注入request/session作用域的Bean启动就会报错或者运行时出现“Cannot create a scoped instance”之类的问题。这个细节很容易被忽略但在电商、后台管理系统这类需要保存当前用户信息的场景里特别常见。有一点要注意很多框架已经帮你封装好了RequestContextHolder和SessionAttributes之类的工具不是非要自己写一个request作用域的Bean。但理解了这层机制你就知道为什么在异步线程里拿不到当前HTTP请求信息——因为request作用域绑定的是请求线程的上下文你在新线程里直接拿肯定拿不到这也是很多老手在异步任务里踩坑的原因。1.3 自定义作用域扩展点的天花板Spring的作用域其实是可以自己扩展的核心接口就一个Scope。现实业务里很少需要自己实现一个作用域但Spring内部像SimpleThreadScope这种类就是干这个用的如果你遇到“想让一个Bean跟随线程存活”的场景可以自己实现线程作用域。public class ThreadScope implements Scope { private final ThreadLocalMapString, Object threadLocal new ThreadLocal(); Override public Object get(String name, ObjectFactory? objectFactory) { MapString, Object map getScopeMap(); Object obj map.get(name); if (obj null) { obj objectFactory.getObject(); map.put(name, obj); } return obj; } private MapString, Object getScopeMap() { MapString, Object map threadLocal.get(); if (map null) { map new HashMap(); threadLocal.set(map); } return map; } // 还需要实现 remove、registerDestructionCallback、resolveContextualObject、getConversationId }注册时用CustomScopeConfigurer或者直接在配置类里加一个BeanPostProcessor做注册也行。说实话这个操作在常规项目里是“炫技”级别的知识点但理解了Scope接口的扩展方式你对Spring容器如何管理自定义生命周期会多一层体感面试聊到“你对Spring扩展点了解多少”的时候也能多一个拿得出手的案例。2. 生命周期Bean从“出生”到“销毁”的完整路线2.1 实例化和初始化之间隔着一道墙很多初学者把“创建Bean”和“初始化Bean”混为一谈其实这是两个不同阶段。实例化只是调用构造器把对象new出来此时对象还是“半成品”属性还没注入初始化则是把属性填充、执行各种增强逻辑之后让Bean真正达到可用状态。Spring的Bean生命周期顺序大致是实例化通过构造器创建对象。属性填充Auto Wiring依赖注入在这里完成。Aware回调如果Bean实现了BeanNameAware、BeanFactoryAware、ApplicationContextAware等接口会在这里回调。BeanPostProcessor前置处理调用postProcessBeforeInitialization。初始化逻辑PostConstruct方法、InitializingBean接口的afterPropertiesSet方法、自定义init-method按顺序执行。BeanPostProcessor后置处理调用postProcessAfterInitialization。Bean进入可用状态被容器管理和使用。容器关闭时销毁PreDestroy、DisposableBean接口的destroy方法、自定义destroy-method等依次执行。这段顺序是面试高频题也是有经验的程序员排查Bean初始化异常时脑子里的“定位地图”。我以前遇到过一个线上问题某个Bean启动时偶尔报空指针折腾好久才发现是它在初始化阶段依赖了另一个还没初始化完成的Bean。搞清楚上面这个顺序后就能推断出问题出在“初始化时机”而不是代码逻辑本身。2.2 Aware接口与BeanPostProcessor藏在中间的钩子Aware接口是Spring给Bean提供的一套“感知容器”的回调机制。比如BeanNameAware可以让你拿到当前Bean在容器中的名字ApplicationContextAware可以让你直接拿ApplicationContext对象。在普通业务代码里很少直接实现这些接口但很多框架、组件、场景里比如Spring Security、Spring Boot的自动配置都会大量使用这些回调来做依赖注入和上下文准备工作。BeanPostProcessor是比Aware更强大的扩展点它作用在所有Bean上可以在Bean初始化前后做统一的“增强”动作AOP的动态代理就发生在这里。稍微展开一下Spring创建出一个普通Bean之后如果这个Bean需要被增强比如加事务、加切面容器会在postProcessAfterInitialization这一步用JDK动态代理或CGLIB生成一个代理对象替换掉原来的Bean。这也是为什么你在断点里看到有些Bean的类型是Proxy、有的类名带着$Proxy或$$EnhancerBySpringCGLIB。如果想自己验证可以实现一个BeanPostProcessor打印每个Bean初始化前后的类名Component public class MyBeanPostProcessor implements BeanPostProcessor { Override public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { System.out.println(初始化前: beanName - bean.getClass().getSimpleName()); return bean; } Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { System.out.println(初始化后: beanName - bean.getClass().getSimpleName()); return bean; } }跑起来之后你会看到各个Bean的名字按顺序打印。这个体验比死记生命周期顺序有用得多建议你自己搭个最小工程试一次。2.3 初始化销毁的三种写法到底应该用哪个Spring里配置初始化和销毁逻辑常见的有三种方式方式适用场景优点缺点PostConstruct / PreDestroy注解驱动的主流项目直观、可读性好、随着组件走只适用于你自己写的类InitializingBean / DisposableBean框架内部或需要统一处理时单一方法即接口契约明确好找侵入性强业务代码里不推荐Bean(initMethod init, destroyMethod destroy)配置第三方组件的Bean时不用修改源码也能指定生命周期如果方法签名对不上运行才报错我个人的建议是业务代码里优先用PostConstruct和PreDestroy因为语义清晰而且适合绝大多数初始化/收尾场景。如果你在封装一个公共组件比如连接池、自研框架可以考虑实现InitializingBean来保证统一契约或者用Bean的方式在配置类里声明避免侵入第三方类源码。这里有个小坑当三种方式同时存在时执行顺序是有讲究的。PostConstruct最先执行接着是InitializingBean.afterPropertiesSet最后才是init-method。销毁顺序也差不多也是分层来的。如果你在同一个Bean里混用了多种方式记得按这个顺序去预判日志输出别看到初始化日志顺序不对就以为出Bug了。另外补充一个容易被忽略的点PreDestroy只在正常的容器关闭流程里才会执行。如果你直接调System.exit()或者进程被杀掉Spring的销毁逻辑不一定能跑完。所以在做需要释放资源、清理线程池的操作时不要把全部希望寄托在销毁方法上最好还是配合JVM的ShutdownHook来保证。3. 自动装配Spring是怎么一眼认出那个Bean的3.1 Autowired与Resource到底差在哪里自动装配的底层逻辑说白了就是Spring根据你声明的依赖去容器里找匹配的Bean找到后注入给你。但具体怎么找不同注解策略不一样这也是初级程序员最容易分不清的地方。先看Autowired。它的装配规则是“先按类型找再按名称找”。如果容器里只有一个类型匹配的Bean直接就用了如果有多个类型相同但名字不同的Bean它会尝试用字段名或Qualifier指定的名称做二次匹配如果还是匹配不上默认会报错。Spring还提供了required属性默认requiredtrue如果容器里没有对应Bean启动时就会NoSuchBeanDefinitionException。再看Resource。这个注解是JDK自带的Spring也支持它但它的装配顺序是“先按名称再按类型”。如果只写了Resource默认会拿字段名作为Bean名字去容器里找找不到再退化为按类型找。对比项AutowiredResource来源SpringJDK默认按类型优先名称优先解决同类型多BeanQualifiername属性是否支持requiredfalse支持不支持和Spring强绑定程度强弱实际开发里一个接口有多个实现类是很常见的事。比如IOrderService下面有OrderServiceA、OrderServiceB如果直接Autowired注入一个接口就会报“expected single matching bean but found 2”的错误。解决办法就是用Qualifier指定一个名字或者用Resource(name orderServiceA)。千万不要为了省事直接把字段名改成和某个实现类同名这样虽然能过但代码可读性会很差别人读代码时根本看不出你到底想用哪个实现。3.2 循环依赖与三级缓存绕不开的经典问题循环依赖指的是A依赖BB又依赖A。Spring容器在单例模式下是能解决这种构造器之外的循环依赖的靠的就是三级缓存。网上很多文章把三级缓存讲得玄之又玄其实没那么复杂。先看代码两个类互相依赖Component public class A { Autowired private B b; } Component public class B { Autowired private A a; }Spring创建A时发现A需要B于是去创建B创建B时发现B需要A如果此时A还没创建完成就出问题了。单例模式下Spring搞了三级缓存来兜底一级缓存singletonObjects存已经完整初始化的单例Bean。二级缓存earlySingletonObjects存提前暴露的早期Bean也就是还没完成属性填充和初始化的Bean。三级缓存singletonFactories存ObjectFactory工厂能生成早期Bean。大致流程是这样的Spring创建A时先实例化出A的原始对象然后把一个ObjectFactory放进三级缓存里这样A就算“提前暴露”了。接着A去注入BSpring发现B还没创建就开始创建B。B创建时需要A此时从三级缓存里通过ObjectFactory拿到A的早期引用注入给B。B创建完成之后Spring再回过头来把完整的A创建完。为什么要三级缓存而不是二级缓存因为Spring需要在早期引用和最终代理对象之间做转换。AOP动态代理通常发生在BeanPostProcessor后置阶段三级缓存里的ObjectFactory可以做“提前代理”的判断和生成。如果直接用二级缓存存原始对象那么后面生成代理对象时B里注入的还是原始对象就出问题了。这里有几个关键结论面试爱考实际项目里也适用构造器注入的循环依赖无法解决因为构造器执行时对象还没创建出来没有“早期暴露”的机会。prototype作用域的Bean不支持循环依赖。Async、Transactional等AOP增强可能会让循环依赖问题变得更复杂因为代理时机不一样。我在一个项目里遇到过这种情况两个Service互相调用用Autowired注入其实能跑通但后来给其中一个Service加了Transactional注解启动时直接报循环依赖错误。原因就是这个Bean需要被CGLIB代理代理生成时机和早期暴露的引用对不上。所以别把三级缓存当成什么都能解决的万能药核心原则还是编码时尽量别产生循环依赖。3.3 Configuration与Bean方法调用的代理机制自动装配还有一个特别容易忽略的点就是Configuration类本身也是被CGLIB增强过的。假设你写了一个配置类Configuration public class AppConfig { Bean public DataSource dataSource() { return new DataSource(); } Bean public JdbcTemplate jdbcTemplate() { return new JdbcTemplate(dataSource()); } }如果Spring没有对Configuration做代理那么每次调用dataSource()都会重新new一个DataSourcejdbcTemplate里注入的dataSource和容器里管理的dataSource就不是同一个对象了这显然不符合预期。Spring解决这个问题的办法是把配置类设置为CGLIB代理在每次调用Bean方法时先去容器里检查有没有对应名字的Bean有就直接返回容器里的实例没有再执行方法创建。这个机制就是BeansAppConfig类被“替换”成增强子类的根本原因。但你如果用Component来标注一个普通类然后在里面写Bean方法那这个类不会走CGLIB增强每次方法调用都是真实新建的。这就是为什么官方文档强调Bean方法最好放在Configuration类里否则可能产生“同一个Bean被创建两次”的意外。我见过有人写配置类省事用了Component结果系统里莫名其妙出现了两个不同实例的连接池调了很久才找到根因。如果你很好奇想知道当前配置类有没有被代理可以在配置类里加一个构造器打印this.getClass().getName()启动时一看就知道是原始类还是$$EnhancerBySpringCGLIB。4. 实测验证用最小Demo把整个机制跑一遍4.1 一套代码看清作用域和生命周期理论说再多不如直接搭个最小工程跑一遍输出。我建议你们自己建一个Spring Boot项目什么都不用额外引入然后写这些代码Component Scope(singleton) public class SingletonBean { public SingletonBean() { System.out.println(SingletonBean 构造器); } }Component Scope(prototype) public class PrototypeBean { public PrototypeBean() { System.out.println(PrototypeBean 构造器); } }然后写一个CommandLineRunner启动时多拿几次BeanComponent public class PrintRunner implements CommandLineRunner { Autowired private ApplicationContext context; Override public void run(String... args) { SingletonBean s1 context.getBean(SingletonBean.class); SingletonBean s2 context.getBean(SingletonBean.class); System.out.println(singleton 是否同一个: (s1 s2)); PrototypeBean p1 context.getBean(PrototypeBean.class); PrototypeBean p2 context.getBean(PrototypeBean.class); System.out.println(prototype 是否同一个: (p1 p2)); } }跑完你就会看到singleton取两次构造器只打印一次比较结果是trueprototype取两次构造器打印两次比较结果是false。就这一个实验就能让作用域这个概念在脑子里落地生根。再验证生命周期加一个BeanComponent public class LifeCycleBean { public LifeCycleBean() { System.out.println(1. 构造器); } Autowired public void setDependency(DemoService demoService) { System.out.println(2. 属性注入); } PostConstruct public void init() { System.out.println(3. PostConstruct 初始化); } }启动时再看日志顺序一目了然。通过观察日志输出顺序要比死记文档里的流程强得多因为你能亲眼看到每个阶段之间的间隔包括依赖注入到底发生在哪一步之后。4.2 常见问题与排查速查表在实际项目里和Bean作用域、生命周期、自动装配相关的报错翻来覆去就那么几类。我整理了一个速查表格遇到问题可以直接对号入座现象可能原因排查思路启动报NoSuchBeanDefinitionException没扫到、没注册、或包扫描路径不对检查ComponentScan范围检查Bean类是否加了注解明明有两个实现类Autowired报“expected single matching bean”类型相同Bean有多个用Qualifier或Resource(name)指定prototype注入到singleton后只创建一次容器只创建一次字段注入没有动态获取改用ObjectFactory / Provider / Lookuprequest作用域注入单例Bean报错没有配置scope代理加proxyMode或用ObjectFactory延迟获取加了Async后循环依赖报错AOP代理导致循环依赖处理失败重构依赖关系拆开循环Bean方法每次调用都创建新对象配置类没有用Configuration改用Configuration而非Component异步线程里访问request Bean为空request作用域绑定请求线程手动传递上下文或使用TaskDecorator这些坑每一个我都踩过至少一遍。尤其是prototype注入到singleton这一类很多人觉得Spring会自动处理实际上完全不是。它的本质是“容器只会为单例Bean做一次注入”所以多例需求一定要通过延迟获取来打破“一次性注入”的限制。4.3 调试Bean生命周期的一个实用小技巧最后分享一个我实际工作中经常用的小技巧。如果你怀疑Bean初始化顺序有问题或者想看看Spring到底在什么时候创建了哪些Bean不需要去翻源码直接启动时加一下JVM参数-Ddebugtrue这会让Spring Boot打印自动配置报告能看到哪些Bean被装配了哪些被排除了对应的问题也能更快定位。还有Spring Boot的Actuator里有个beans端点可以实时查看容器里所有Bean的名字、类型、依赖关系线上排查Bean相关问题时非常方便只需要在工程里引入spring-boot-starter-actuator即可。如果问题只出现在某一两个Bean上更推荐用BeanPostProcessor临时打印日志就像前面写的那种把所有关键节点的类名和BeanName输出出来。不要觉得打印日志土排查生命周期问题最直接的手段就是打点。说到底Spring这些机制并不复杂陌生感几乎全部来自于“不知道对象在什么时候被谁创建了”。当你自己动手写一个最小Demo亲眼看到构造器、属性注入、初始化方法、代理生成这些节点的前后关系再回头看那些面试题会发现根本不需要背答案。之后遇到作用域串数据、Bean初始化顺序、自动装配失效之类的问题脑子里自然就会浮现出那条完整的生命周期链路定位速度提升不止一个量级。