Spring Bean 生命周期、作用域与三级缓存:从入门到源码原理
Spring 的 Bean 这一个主题说难不难说简单也真的不简单。很多人用 Spring 用了一两年Autowired和Service用得飞起但一旦面试官问Bean 的生命周期是怎样的三级缓存到底是怎么解决循环依赖的一下子就露馅了。这是因为我们平时只看到了 Bean 的使用却没有理解 Spring 容器在背后到底做了哪些事。这篇文章我想以第二章 Spring 中的 Bean为切入点把 Bean 从概念到源码、从实例化到销毁、从单例到循环依赖整个链路梳理一遍。不管你是刚学 Spring 的初学者还是想补基础准备面试的开发者这篇文章都值得认真读一遍。我会尽量不堆术语用最直白的方式把原理讲清楚同时把实操中容易踩的坑也一并列出来。1. Bean 是什么Spring 框架的核心灵魂1.1 从对象到Bean的认知升级我们平时写 Java 代码创建一个对象用的是new关键字UserService userService new UserService()。这看起来很自然但你有没有想过如果UserService内部依赖了UserDao而UserDao又依赖了DataSource你每次创建UserService的时候都要手动把这些依赖一层一层地 new 出来。对象少还行对象一多代码就成了构造器地狱。Spring 做的事情就是把这套创建对象、组装对象、管理对象的权力收归容器。所谓Bean就是在 Spring 容器管理下的对象。它和你用new创建出来的对象本质上是同一个东西区别在于new出来的对象由你负责它的生老病死而 Bean 由 Spring 容器负责它的实例化、属性赋值、初始化、依赖注入以及最后的销毁。这个过程有个专业说法叫控制反转IoCInversion of Control。通俗点讲以前是你自己控制对象的创建现在你把这个控制权反转给了 Spring 容器。你的代码里只需要声明我需要一个 UserServiceSpring 就会在合适的时机把它创建好、装配好然后交到你手上。1.2 Bean 到底解决了什么问题Bean 机制的核心价值我认为可以浓缩成三个词解耦、集中、复用。先说解耦。假如系统里有 A、B、C、D 四个业务类A 依赖 BB 依赖 CC 依赖 D。如果全部用new来创建那么 A 的代码里必须知道 B 的构造器长什么样B 的代码里必须知道 C 的构造器长什么样类的耦合度极高。一旦 C 的构造器加了一个参数所有依赖 C 的类全要跟着改。用 Spring 管理之后类之间只需要通过接口或者注解声明依赖具体的实现类由容器去匹配改一个实现类不影响其他代码。再说集中。所有 Bean 的定义、作用域、初始化顺序、销毁策略都集中在配置信息里可以写在 XML 里、注解里或者 Java 配置类里。你查看一个系统有哪些 Bean、它们之间是什么关系不需要满项目去搜new关键字直接看配置就能了解全貌。最后说复用。Bean 默认是单例的一个系统里共享同一个实例。这一点对无状态的 Service 类非常合适既节省了内存也避免了反复创建对象的性能损耗。后面我会详细说单例和多例的选择问题这里先记住单例是默认策略但不是唯一策略。2. Bean 的生命周期从诞生到消亡的完整链路2.1 一条时间线看懂 Bean 的一生理解了 Bean 是什么之后下一个问题就是一个 Bean 从创建到销毁中间到底经历了哪些步骤我把整个过程整理成了一条时间线你可以对照着看Spring 容器启动扫描配置读取 Bean 定义BeanDefinition。根据 Bean 定义通过构造器反射实例化对象此时对象的属性都是默认值。进行属性填充也就是依赖注入Autowired、Resource、XML 中的 property 都在这一步生效。如果 Bean 实现了BeanNameAware、BeanFactoryAware、ApplicationContextAware等 Aware 接口容器会回调对应的方法把 Bean 的名字、工厂、上下文等信息告诉 Bean。调用BeanPostProcessor的postProcessBeforeInitialization方法。执行初始化逻辑包括PostConstruct注解方法、InitializingBean接口的afterPropertiesSet方法、XML 中配置的init-method方法三者顺序PostConstruct→afterPropertiesSet→init-method。调用BeanPostProcessor的postProcessAfterInitialization方法此时 Bean 已经可以用了。容器销毁时执行销毁逻辑包括PreDestroy注解方法、DisposableBean接口的destroy方法、XML 中配置的destroy-method方法三者顺序PreDestroy→destroy→destroy-method。这里有一个特别容易被忽略的点BeanPostProcessor 是所有 Bean 都会经过的关卡。Spring 的很多核心功能比如Autowired的注入、Async的代理、AOP 的动态代理都是靠BeanPostProcessor在 Bean 初始化前后做手脚实现的。你甚至可以自己写一个BeanPostProcessor在所有 Bean 初始化完成后打印一句日志这在排查问题时非常有用。2.2 初始化阶段的三个扩展点怎么选很多人分不清PostConstruct、InitializingBean、init-method到底有什么区别我在实际开发里看到过混用的情况。其实它们的触发时机基本一致都是初始化阶段但使用方式不同。扩展点使用方式特点PostConstruct在方法上加注解JSR-250 标准最推荐代码侵入性小InitializingBean实现接口重写afterPropertiesSetSpring 特有接口会让类耦合 Spring 的 APIinit-methodXML 或Bean(initMethod...)指定方法名适合不使用注解的老项目方法名可自定义我的建议是新项目统一用PostConstruct。因为它不依赖 Spring 的接口跑在 JSR-250 规范上就算以后脱离 Spring比如换成别的容器代码几乎不用改。另外要注意如果你的初始化逻辑里需要用Autowired注入的属性那么这些属性在PostConstruct阶段已经完成注入可以放心使用。这一点和构造器里直接访问注入属性不同构造器阶段属性还没填充强行访问会拿到 null。2.3 销毁阶段的那些坑Java 对象本身没有析构函数但 Spring 给了我们销毁的扩展点。PreDestroy和DisposableBean常用于释放资源比如关闭连接池、销毁线程池。但有一个非常经典的坑当 Bean 的作用域是 prototype 时Spring 容器不会管理它的销毁。也就是说你每次从容器里拿到的原型 Bean容器在关闭时不会主动调用它的销毁方法。这个行为不是 bug而是 Spring 的设计——容器认为原型实例由调用方全权负责容器只管创建不管后续。所以如果你在 prototype 作用域的 Bean 里配置了复杂的资源释放逻辑要么自己手动调用销毁方法要么干脆别把这种 Bean 交给容器管理。我自己就踩过一次这个坑一个 prototype 的 Task Bean 里开启了线程池应用重启时线程池没有优雅关闭导致日志里刷了一堆线程中断异常。后来我把线程池的管理挪到了单例的生命周期组件里问题才解决。3. Bean 的作用域不是只有单例3.1 六种作用域逐一解析Spring 内置了六种 Bean 作用域最常用的就两个singleton 和 prototype。剩下的几个主要用在 Web 应用中我放在表格里一起说。作用域说明实例数量适用场景singleton默认作用域每个容器只创建一个实例1无状态 Service、工具类、配置类prototype每次获取都创建新实例N有状态的对象、多线程冲突的场景request每个 HTTP 请求一个实例每请求 1 个Web 应用中的请求级数据容器session每个 HTTP 会话一个实例每会话 1 个用户登录信息、购物车等application每个 ServletContext 一个实例全局 1 个应用级共享配置websocket每个 WebSocket 连接一个实例每连接 1 个WebSocket 会话数据需要特别提醒singleton 和 prototype 的区别不仅仅是实例数量不同而是容器对 Bean 的掌控程度不同。singleton Bean 由容器创建、管理、销毁prototype Bean 创建完就交给调用方了容器不再跟进后续生命周期。3.2 什么时候必须用 prototype我的判断标准很多初学者以为所有 Bean 都应该是单例的这是不对的。判断标准其实很简单这个 Bean 内部有没有可变状态多线程共享它会不会出问题比如一个DateUtils工具类里面全是静态方法没有任何字段那它用单例没问题。再比如一个OrderService它虽然内部有OrderDao依赖但OrderDao是无状态的不持有具体业务数据所以单例安全。但如果一个 Bean 持有类似ThreadLocal之外的可变实例变量比如private int count或者内部维护了一个List用来暂存数据那么单例就意味着所有线程共享这份数据并发环境下就乱了。这种场景就该用 prototype让每个调用方拿到独立实例。不过说实话现代开发里我们更推荐单例 无状态的设计把可变状态要么抽出去放在独立对象里要么用ThreadLocal隔离而不是单纯靠调节 Bean 作用域来规避问题。prototype 有它的价值但它会牺牲容器管理的便利性能不用尽量不用。在 Spring Boot 中你可以用Scope(prototype)或者Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)来指定。如果你是使用Configuration类配置 Bean注意一点用Bean方法返回对象时方法和类都不能是 final 的否则 CGLIB 代理无法工作prototype 作用域可能失效。4. Bean 的实例化方式三种主流方案加一个特殊存在4.1 构造器、静态工厂、实例工厂怎么选Bean 的创建并不局限在外部new然后注册这一种方式Spring 支持多种实例化方式我把实际开发里常用的几种列出来第一种构造器实例化。这是最常用的方式Spring 通过反射调用类的无参构造器或带参构造器创建实例。无论是 XML 里的bean class...还是注解扫描Component走的基本都是这条路径。需要注意如果你写了有参构造器但没写无参构造器Spring 也能通过参数类型匹配自动完成实例化前提是参数本身也是受 Spring 管理的 Bean。第二种静态工厂方法。通过类上的静态方法返回实例。在 XML 中配置factory-method即可。这种方式的典型场景是返回类型是接口但具体实现类不想暴露给调用方。老项目中常见新项目里已经很少直接这么写了。第三种实例工厂方法。工厂类本身是一个 Bean工厂里的某个方法返回目标 Bean。早期用来做第三方库的整合比如旧的SqlSessionFactoryBean现在更多的是被FactoryBean接口取代了所以这一种了解即可。4.2 FactoryBean一个容易被误解的 Bean说到实例化必须提一下FactoryBean。它和BeanFactory名字很像但完全是两回事。BeanFactory是容器本身而FactoryBean是一个特殊的 Bean它生产其他 Bean。实现FactoryBeanT接口的类Spring 在创建它时不会注册它本身而是注册它getObject()方法返回的那个对象。如果你需要获取工厂本身需要在 Bean 名字前加符号比如applicationContext.getBean(myFactoryBean)。FactoryBean的强大之处在于它可以在创建对象时做很多自定义逻辑。比如MyBatis的MapperFactoryBean就是通过工厂方法动态生成 Mapper 接口的代理对象。如果你需要整合没有源码的第三方库或者想动态生成代理对象FactoryBean是最合适的入口。4.3 注解扫描和装配优先级除了 XMLSpring 也支持包扫描加注解。Component、Service、Repository、Controller这组注解的作用是一样的都是告诉容器这个类需要被管理只是语义上区分了业务层、数据层和控制器层。Configuration和Bean则是用 Java 代码显式声明 Bean。如果同一个类型既被Component扫描到了又在Configuration里通过Bean定义了那最终生效的是哪个答案是Bean定义的那个覆盖扫描的。因为Configuration里的Bean方法在容器加载时优先级更高并且在配置类刷新时会覆盖同类型的 Bean 定义。这个机制在排查为什么我改了实现类但注入的还是原来的对象这类问题时很有用。5. 依赖注入的三种方式一篇文章彻底搞懂5.1 字段注入、Setter 注入、构造器注入的对比依赖注入DI是 IoC 的具体实现手段。Spring 支持三种注入方式但在实际项目里它们的地位完全不同。注入方式写法优点缺点字段注入Autowired private UserDao userDao代码最简洁依赖不显式、不便于测试、容易形成循环依赖Setter 注入属性上加Autowired但放在 setter 方法上可选择性后期可修改依赖依赖不是 final可能被改掉构造器注入在构造器上使用Autowired可省依赖不可变、显式、便于测试依赖多了构造器会长从 Spring 官方推荐的角度最推荐的是构造器注入。因为构造器强制要求你在创建对象时就把依赖传进来所以 Bean 不可能出现半初始化的状态而且最终的字段可以用final声明不可变性更好。Spring 4.3 之后如果类只有一个构造器Autowired甚至可以省略Spring 会自动选择该构造器。字段注入虽然舒服但它带来两个痛点一是你无法在构造对象时传入依赖导致单元测试必须借助 Spring 容器或者反射框架比如ReflectionTestUtils测试成本增加二是字段注入在语义上是一种隐式依赖代码审查时不容易看出这个类到底需要什么。我看到不少项目为了图省事全用字段注入结果对象间的依赖关系变成了一张看不见的网排查问题非常痛苦。5.2 Autowired 和 Resource 到底差在哪这个问题几乎每次面试都会碰到我直接给结论。Autowired是 Spring 的注解它的装配策略是先按类型byType再按名称byName。当容器中存在多个同类型的 Bean 时它会先尝试按字段名去找对应名字的 Bean找不到就会报NoUniqueBeanDefinitionException。配合Qualifier(beanName)可以精确指定。Resource是 JDK 自带的注解JSR-250它的装配策略是先按名称byName再按类型byType。如果没有指定name属性它会用字段名作为 Bean 名字去找找不到再按类型找还是找不到就报错。打个比方Autowired像一个按职业找人的猎头先看你这有几个人干这个职位再看有没有恰好叫某个名字的Resource像一个按姓名找人的通讯录先看有没有这个名字找不到再问那你干什么职业的。在只有一个同类型 Bean 时两个注解没有区别一旦出现同类型多个 BeanAutowired默认是报错的Resource则可能根据字段名顺利匹配。我个人在新项目里更喜欢用Autowired Qualifier因为语义更明确而且在 Spring 生态里Autowired对构造函数、方法、参数都支持而Resource一般只用在字段上。如果是第三方组件的注入、需要按名字精确取 Bean 的场景Resource反而更顺手。5.3 依赖注入常见的一个误用静态工具类很多工具类喜欢写成静态方法 静态字段比如public class RedisUtil { Autowired private static RedisTemplate redisTemplate; }这样写等于白写。因为Autowired只能对 Spring 管理的 Bean 的实例字段生效静态字段属于类级别容器在注入时根本没有对象实例的概念所以静态字段永远都是 null。我见过好几个项目因为这个原因在线上出现了空指针排查半天最后发现是静态字段注入。如果你确实需要一个静态工具类里能用 Spring 管理的 Bean有两种方案第一种不要用静态字段改成非静态字段把工具类本身注册为一个 Bean在需要的地方以构造器注入的方式使用它。第二种用ApplicationContextAware写一个静态上下文工具启动时把容器实例保存下来需要的时候通过getBean获取。但这种方式是一种补丁式方案能不用就不用它会破坏依赖注入的显式性。6. 循环依赖与三级缓存Spring 最精妙的设计之一6.1 循环依赖长什么样循环依赖就是两个或多个 Bean 互相依赖形成一个环。Service public class AService { Autowired private BService bService; } Service public class BService { Autowired private AService aService; }在上面代码里创建 A 时需要注入 B创建 B 时又需要注入 A如果不做任何处理这就成了鸡生蛋、蛋生鸡的问题Spring 容器会直接抛BeanCurrentlyInCreationException异常。Spring 解决这个问题的思路并不复杂提前暴露半成品对象。在 A 可以创建的时候就先把 A 的一个实例可能还没有完成属性填充记下来等 B 创建需要注入 A 时直接把这个半成品给 BB 完成创建后A 再完成剩余步骤拿到 B 的引用赋值进来。这样一来A 和 B 就都成功创建了。6.2 三级缓存为什么两级不够Spring 用来管理这个流程的是三个缓存 Map俗称三级缓存级别名称内容一级singletonObjects已经完整创建好的单例 Bean二级earlySingletonObjects提前暴露的半成品Bean三级singletonFactories存放 Bean 创建工厂的注册表创建单例 Bean 时核心逻辑在DefaultSingletonBeanRegistry的getSingleton方法里。我来描述一下整个流程首先实例化 A 对象通过构造器但此时 A 还是早期引用属性都是默认值。Spring 不会把 A 直接放进二级缓存而是先向三级缓存里放一个ObjectFactory对象工厂这个工厂后续可以通过调用它拿到 A 的早期引用。然后尝试对 A 进行属性填充发现需要 B于是去创建 B。创建 B 时B 的实例化完成后同样先放三级缓存接着 B 需要注入 A。此时容器在三级缓存里发现了 A 的工厂调用这个工厂拿到 A 的早期引用把 A 注入到 B 里。B 完成所有初始化后放进一级缓存。回到 AA 拿到 B 的完整引用完成属性填充和初始化放进一级缓存同时清理掉二级和三级缓存里的 A 记录。整个过程完成。那为什么要分三级而不是两级关键就在于只要没有循环依赖Spring 不希望提前暴露半成品。三级缓存里存的是ObjectFactory而不是裸对象好处是可以在这个工厂里对早期引用做包装。比如当 A 涉及 AOP 需要生成代理对象时工厂返回的就是代理对象的早期引用而那些没有循环依赖的 Bean 根本不会被提前实例化。如果只有一级缓存和二级缓存Spring 必须在 A 实例化后立刻暴露半成品这样会带来两个问题一个是性能浪费所有 Bean 都被强制提前创建失去了按需创建的灵活性另一个是对 AOP 代理无法做懒处理可能造成一次创建多个代理对象。三级缓存的工厂机制让 Spring 精确控制什么时候需要半成品、什么时机生成代理。6.3 哪些循环依赖救不了三级缓存确实强大但它不是万能的。以下三种情况循环依赖依然会报错第一种构造器循环依赖。比如 A 的构造器中需要 BB 的构造器中需要 A。因为构造器在实例化阶段就触发了依赖此时对象都还没创建出来三级缓存里自然没有可用的早期引用。解决方案是改用 Setter 注入或者字段注入把创建和依赖填充拆开。第二种非单例作用域的循环依赖。prototype 作用域的 Bean 每次都是新实例本身就是用完就扔的容器不会为其保留缓存所以循环依赖无法解决。出现这种情况优先考虑重新设计依赖关系别再坚持 prototype。第三种使用了Async或者 AOP 且代理未正确提前暴露时。正常逻辑下Spring 的 AOP 代理可以在三级缓存工厂里生成但如果代理模式或者EnableAsync的处理方式不当也会陷入 Bean 正在创建中。这种问题排查比较费劲建议直接用Lazy注解打破循环Autowired Lazy private BService bService。Lazy会注入一个代理对象B 只有在真正使用时才会去创建从源头避免循环。7. 常见问题与排查技巧实录7.1 报错 NoSuchBeanDefinitionExceptionBean 到底在不在容器里这是 Spring 开发中最常见的异常大概占我遇到问题的三成。遇到它首先别慌按下面几步排查先确认类上是否有Component系列注解。如果是Service、Repository、Controller这些确认包路径被 Spring Boot 启动类的SpringBootApplication它隐式包含ComponentScan扫描到了。如果你把类放到了启动类包路径之外Spring 默认是扫不到的需要手动加ComponentScan。再确认该类是否被Configuration类里的Bean方法引用。如果Bean方法返回了一个null容器里自然也没有 Bean。这种情况代码不会立即报错直到你在别处注入时才暴露。还有一个很隐蔽的坑如果类在编译时被混淆了或者改名了但配置里的 Bean 名字没变也可能是容器里没有对应 Bean。排查方法是启动时加debugtrue日志或者在测试类里打印applicationContext.getBeanDefinitionNames()一眼就能看到容器里到底注册了哪些 Bean。7.2 注入成功了但运行时报 null 引用这种情况通常在 Controller 或者工具类里出现。我总结一下主要原因最典型的是我上面讲过的静态字段注入。静态字段根本不参与 Spring 的依赖注入所以永远是 null。还有一个原因是对象是通过new手动创建的而不是容器创建。比如你在某个方法里手动new了一个 Service这个新对象里面的注入依赖自然是空的Spring 不会参与管理。另一个坑发生在构造函数顺序上。比如你在构造器里调用了某个实例方法而这个实例方法里又使用了一个尚未注入的依赖。构造器执行时依赖注入还没有完成所以会空指针。正确的做法是把初始化逻辑放到PostConstruct方法里或者使用构造器参数注入而不是字段注入。7.3 同类型多个 Bean 冲突NoUniqueBeanDefinitionException当一个接口有多个实现类时Autowired默认按类型注入就不知道选谁了。报错信息会列出所有候选 Bean 的名字解决办法有几种用Primary标记一个 Bean 为默认首选。这样不指定名字时容器优先注入它。用Qualifier(beanName)精确指定。比如Autowired Qualifier(userDaoImpl2) private UserDao userDao;第三种是利用字段名匹配。Autowired在类型冲突时会尝试按字段名找 Bean如果你的字段名字恰好和某个实现类的 Bean 名字一致也能注入成功但这种方式比较隐晦容易出错我建议还是明明白白用Qualifier。这里还要强调一下解决冲突和解决循环依赖是两个不同维度的问题。不要为了解决循环依赖而随意加Primary这会掩盖掉类型上的歧义给后续维护埋雷。优先考虑用Qualifier。8. 实操中的三个心得写给正在写代码的你最后分享几个我在实际项目里反复确认过的经验希望能帮你少走一些弯路。第一点写单元测试时尽量走构造函数注入。字段注入在容器里没问题但一旦脱离 Spring 容器做单元测试你就得想方设法给私有字段赋值。构造器注入天然支持手动new对象并传入 mock 依赖测试代码干净不少。第二点如果你要扩展 Spring 容器对 Bean 的处理优先选 BeanPostProcessor其次才是监听器。BeanPostProcessor 在每个 Bean 初始化前后都有回调机会是针对所有 Bean的万能钩子。比如我想统一给所有 RPC 服务加上链路追踪日志就是写了一个 BeanPostProcessor在 Bean 创建后判断类型动态生成代理效果非常稳定。第三点善用 Lazy 处理你不想改架构的循环依赖。虽然我建议从根本上避免循环依赖但实际项目中总有一些历史代码绕不开。这时用Lazy给其中一个依赖打上注解注入一个代理对象就能立竿见影地解决启动报错。不过要记住Lazy是镇痛药不是根治药长期维护下有机会就应该重构依赖关系。Spring 的 Bean 机制是理解整个框架的一把钥匙。你把 Bean 的创建、生命周期、作用域、依赖注入这几个点吃透了再看 AOP、事务、MyBatis 整合这些高阶功能会发现它们全都是在 Bean 机制的骨架之上搭建的。希望这篇文章能帮你把这块基础打牢。