聊到 Spring 原理,很多人的第一反应是“八股文”——面试背一背 IOC、AOP、Bean 生命周期就完了。但真正在团队里排查过问题的人都知道,不懂原理的代价特别贵:循环依赖报错看不懂、事务悄悄失效找不到原因、想给老项目加一个扩展点却无从下手。我写过几年 Spring 相关代码,也翻过源码,今天这篇就把 Spring 里最核心的运行机制拆开讲一遍,结合实际踩过的坑和排查经验,尽量让不熟悉源码的人也能建立起一幅完整的地图。
这篇文章适合这样的人群:刚接触 Spring 的初学者,想知道容器到底做了什么;工作两三年但停留在“会用注解”阶段的开发;以及准备做一些二次封装、公共组件设计的同学。我会从 IOC 容器如何管理对象讲起,延伸到 Bean 的完整生命周期、循环依赖的三级缓存、AOP 与事务的原理,再补充 Spring Boot 自动配置和整个 Spring 生态的演进逻辑,最后给出一份可操作的源码阅读和问题排查路线。
1. 先搞清楚:Spring 真正解决的三个核心问题
1.1 对象从哪来:从 new 对象到 IOC 容器
在没有 Spring 的年代,Java 项目的对象管理是极其原始且痛苦的。比如一个订单服务要调用库存服务,你需要在订单服务里new StockService(),而库存服务又依赖数据库连接,于是你还要在构造方法里传入数据源。一旦依赖层级加深,构造对象的代码会像蜘蛛网一样散落在项目各处,改一个依赖就要全局搜索所有new的地方。这不是代码风格问题,是架构设计上的失控。
Spring 做的事情,本质上就是把“谁负责创建对象、谁负责维护对象之间的依赖关系”这个职责,从业务代码里剥离出来,统一交给一个容器。这个容器就是 ApplicationContext,它像一个超级工厂,根据你提供的配置或者注解,扫描到所有需要管理的类,创建出实例,然后按照依赖关系自动注入。业务代码不再关心对象怎么来的,只关心“我要用什么”,容器会在合适的时间把合适的对象递到你手上。
这里的关键转变是控制权的反转:对象创建和组装的控制权,从开发者手中交到了框架容器手里。你告诉容器“我需要一个 UserService”,容器就去管理好的对象池里找到 UserService,把它依赖的 UserMapper、DataSource、事务管理器等全部组装好,再给你一个完整可用的实例。这种思想就是控制反转(Inversion of Control,简称 IOC)。
1.2 依赖如何组装:DI 的价值与取舍
IOC 是思想,依赖注入(Dependency Injection,简称 DI)是落地手段。Spring 提供了三种注入方式:构造器注入、Setter 注入、字段注入(@Autowired)。很多人刚学时觉得字段注入最方便,一行注解就完事。但深入源码和长期维护的角度看,构造器注入更推荐,原因在于它能让依赖关系在对象创建时就固化下来,不会出现对象已经 new 出来但依赖还没设置的情况,也方便写单元测试时直接传入 mock 对象。
我在维护一个老项目时做过一次重构,那个项目大量使用字段注入,结果想给某个服务类加单元测试,发现new出来的对象总是报空指针——因为字段注入在脱离 Spring 容器时不会自动执行。后来把核心类改成构造器注入,测试瞬间清爽很多。构造器注入的优势在编写公共组件和库时尤其明显,它强制调用方把依赖暴露在外部,而不是藏进类内部。
需要特别注意的是,DI 并不是越多越好。如果一个类依赖了十几个对象,说明它自身职责过重,已经不是“注入”能解决的问题,而是需要拆分类。我在评审代码时经常看到一些 Service 类动辄注入七八个 Mapper 和 Service,这种类虽然能运行,但耦合程度高、测试困难,后续维护成本极大。Spring 提供了容器能力,开发者更应该用它来维护清晰的职责边界,而不是用它掩盖设计问题。
1.3 通用逻辑怎么抽:AOP 的横切思维
业务系统里有大量与主流程无关、但又不得不写的重复逻辑:请求日志、权限校验、性能监控、事务管理。如果把这些逻辑硬编码进每个方法,代码会变得又长又乱,而且一旦需求变化(比如日志格式从 JSON 改成文本),你就得把所有方法改一遍。Spring 的面向切面编程(Aspect Oriented Programming,简称 AOP)就是专门解决这类横切关注点的。
AOP 的核心思路是:在目标方法执行前后,由框架织入额外的逻辑。开发者只需要定义切面、切点、通知,Spring 会在运行时生成一个代理对象,由代理对象来包装目标方法。业务代码本身无感知,但每次调用都会被代理拦截,在合适的时机执行切面逻辑。这样一来,日志、权限、事务这些公共关注点,就和业务逻辑解耦了。
我经常用生活中的类比来解释 AOP:你把一个重要包裹放进快递柜,快递员会在包裹外面套一层气泡膜、贴上运单、录入系统,这些操作和包裹里的物品本身没有关系,是快递流程附加的。AOP 的逻辑注入也是这样,目标方法就像包裹里的物品,切面逻辑就像快递公司附加的包装和服务。理解了这个思维,后面看代理模式、切点表达式都会顺畅很多。
2. Bean 生命周期:容器里最重要的那条流水线
2.1 从 BeanDefinition 到实例化的完整链条
Spring 管理对象的基本单位是 Bean,但在真正创建对象之前,Spring 先要做的是读取配置、解析类、生成 BeanDefinition。BeanDefinition 可以理解为对象的“档案”,里面记录了类的全限定名、作用域(singleton 还是 prototype)、初始化方法、销毁方法、属性配置、是否懒加载等信息。配置解析完成后,所有 BeanDefinition 都被注册进 BeanFactory 的一个 Map 中,等待后续实例化。
实例化阶段并不像我们想象的只是new一个对象。Spring 会先通过反射或工厂方法创建原始实例,然后进行属性填充,把配置里或者在容器中能找到的依赖一一注入进来。填充完成后,Spring 会调用各种 Aware 回调(比如 BeanNameAware、BeanFactoryAware、ApplicationContextAware),让你有机会拿到容器中的上下文信息。
紧接着是 BeanPostProcessor 的postProcessBeforeInitialization方法,这里可以在初始化逻辑之前做自定义处理。然后执行自定义的init-method或者@PostConstruct注解标注的初始化方法。初始化完成后,还会经过postProcessAfterInitialization,此时拿到的对象已经是完整可用的 Bean,如果存在代理需求,也往往是这个阶段生成代理对象。整个流程走完,Bean 才被放入一级缓存,供后续获取。
2.2 三级缓存:循环依赖是这样被绕过去的
循环依赖指的是 A 依赖 B,B 又依赖 A,这种情况下如果直接创建 A 就会陷入死循环:创建 A 需要 B,创建 B 需要 A,一直互相等待。Spring 为了解决单例 Bean 的无参构造循环依赖,设计了三层缓存:
- 一级缓存
singletonObjects:存放已经完全初始化好的成品 Bean。 - 二级缓存
earlySingletonObjects:存放已经实例化但尚未完成属性填充的早期 Bean 引用。 - 三级缓存
singletonFactories:存放能生成早期 Bean 引用的工厂对象。
大致流程如下:创建 A 时,先实例化 A(得到早期引用),把 A 的工厂放入三级缓存;填充 A 时需要 B,于是去创建 B;B 实例化后填充属性时需要 A,此时从三级缓存拿到 A 的工厂,调用工厂得到 A 的早期引用放入二级缓存,同时从三级缓存删除;B 拿到 A 引用后完成创建,注册 destroy 方法放到一级缓存;A 继续执行剩余属性填充和初始化流程,最终也放入一级缓存。
这里有一个特别容易踩的坑:三级缓存只能解决单例 Bean 且默认构造器(或没有显式构造参数依赖)的循环依赖。多例 Bean(prototype 作用域)遇到循环依赖会直接抛异常,因为容器管理它们的生命周期比较短,不做缓存。构造器注入的循环依赖无法通过三级缓存解决,因为实例化阶段就需要完整依赖,早期引用还没有生成。如果你在开发中遇到BeanCurrentlyInCreationException,第一反应就应该是检查有没有复杂的构造器循环依赖,而不是盲目加 @Lazy。
2.3 被忽略的扩展点:BeanPostProcessor 与 Aware 接口
很多人学 Spring 只盯着注解用,很少会主动使用扩展点。但如果你做过中间件、组件封装,或者在公司里维护公共的日志、缓存、权限模块,BeanPostProcessor 这类扩展点几乎是绕不开的。Spring 框架的一大设计哲学就是“留好口子给你改”,而不是把一切做死。
BeanPostProcessor 接口里有两个方法:postProcessBeforeInitialization和postProcessAfterInitialization,分别会在 Bean 初始化前后被调用,你可以拿到目标 Bean 和它的 BeanName,在此处进行包装、代理、属性补充等操作。Spring 自己的很多特性(AOP 自动代理、@Autowired 注入、@Value 解析)本质上都是借助各种 BeanPostProcessor 实现的。比如AutowiredAnnotationBeanPostProcessor负责解析 @Autowired 注解并完成依赖注入,AbstractAutoProxyCreator负责生成 AOP 代理。
需要提醒的是,BeanPostProcessor 本身也是 Bean,Spring 会提前创建它们。如果你自定义的 BeanPostProcessor 内部又依赖了其他普通 Bean,要特别注意初始化顺序问题。我遇到过一个新的公共组件引入后,某个自定义 BeanPostProcessor 里注入了一个 Service,结果这个 Service 的初始化又依赖了别的 Bean,导致启动时出现循环依赖。排查了半天,最后把自定义处理逻辑改成在postProcessAfterInitialization里延迟获取依赖才解决。这类问题在团队协作规模变大时特别常见,设计扩展点时务必把依赖关系理清楚。
3. 深入 AOP 与事务:实际开发里最常踩的雷区
3.1 动态代理的两种实现怎么选
Spring AOP 实现代理有两种方式:JDK 动态代理和 CGLIB 代理。JDK 动态代理要求目标类必须实现接口,它通过接口生成代理对象;CGLIB 代理通过继承目标类生成子类来代理,不要求实现接口。Spring 的默认策略是:如果目标类实现了接口,就使用 JDK 动态代理;如果没有实现接口,则使用 CGLIB。
在 Spring Boot 2.x 之后的版本中,默认设置为spring.aop.proxy-target-class=true,即优先使用 CGLIB。CGLIB 功能更强,能代理没有接口的类,但它是通过生成子类实现的,所以 final 类和方法无法被代理。JDK 动态代理生成代理对象的性能未必比 CGLIB 高,但在 JDK 8 之后,JDK 代理迭代了很多次,很多场景下已可接受。
实际开发中经常忽略的一个问题是:同一个类内部的方法自调用并不会触发代理。比如在 Service 类的saveOrder方法里直接调用this.sendMessage(),而sendMessage上有事务或日志切面,这种情况下代理对象不会被调用,切面逻辑不会生效。解决思路有三个:拆分为两个 Bean、注入代理对象自调用、或者使用AopContext.currentProxy()强制从代理对象发起调用。我在代码评审时发现过很多次类似自调用导致的事务不生效问题,排查起来特别隐蔽,因为日志里没有任何异常,只是行为不符合预期。
3.2 @Transactional 为什么会失效:原理层面的解释
事务是 AOP 最典型的应用场景之一。Spring 通过事务代理把事务开启、提交、回滚的逻辑织入到带有 @Transactional 注解的方法周围。它不是魔法,而是基于代理机制实现的横切逻辑。理解了这一层,很多“事务不生效”的问题都可以直接推断出原因。
最常见的失效场景包括:访问权限不是 public、方法在类内部自调用、异常被 catch 吞掉、异常类型非运行时异常。其中“异常被吞掉”尤其危险。默认情况下 Spring 只对 RuntimeException 和 Error 回滚,如果业务方法里 catch 了异常但不重新抛出,事务感知不到异常,自然无法回滚。如果确实要 catch 后做补偿,需要在 catch 块里手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
还有一类问题与代理方式有关:如果事务方法所在的类没有实现接口,Spring Boot 默认使用 CGLIB;如果类被 final 修饰,或者使用了 JDK 代理模式但类没有实现接口,则 Bean 无法被代理,@Transactional 自然不生效。另外要注意自调用问题:事务方法被同类的非事务方法调用,不会经过代理,事务不生效。用 @Transactional 时最好把事务边界放在入口方法,不要让事务散落在内部私有方法里。
3.3 传播行为与事务边界设计
事务传播行为定义了多个事务方法相互调用时如何协同。默认的 REQUIRED 行为是:如果当前存在事务,则加入;如果不存在,则新建一个。大多数业务场景用默认值就够了,但碰到“主方法报错后日志子操作必须保留”之类的需求,就需要引入 REQUIRES_NEW,让子方法开启独立事务,独立提交,不受外部事务回滚影响。
传播行为的理解不能只停留在背表格,要结合业务场景来设计。比如一个导入任务,主流程是解析文件并批量保存数据,子任务是记录每条数据的处理日志。解析和主保存出错了可以全部回滚,但日志必须每一条都写进数据库,方便后续排查。这种场景就可以用 REQUIRES_NEW 把日志记录单独托管。如果日志方法也参与了主事务,主事务一旦回滚日志就会全部消失,排查起来等于没有日志。
在配置传播行为时要特别小心嵌套调用带来的性能开销和死锁风险。REQUIRES_NEW 会挂起当前事务并创建新的数据库连接,如果调用链特别长,连接数会上升,资源消耗变大;同时,新事务持有的锁等待旧事务释放资源时,如果事务之间互相涉及对方持有的锁,可能产生死锁。我建议事务边界的粒度尽量按“一个业务操作对应一个事务”来设计,不要为了写法好看而在事务方法内部再去嵌套事务方法。
4. Spring Boot 的自动配置原理:约定大于配置到底是怎么做到的
4.1 @EnableAutoConfiguration 与 spring.factories
Spring Boot 最大的贡献是让 Spring 上手成本大幅降低:以前你用 Spring MVC 需要写 web.xml、配数据源、配事务管理器,现在引入一个 starter 依赖,应用启动后它自动就把该配的组件配好了。这个“自动配置”能力,核心就是@EnableAutoConfiguration以及spring.factories/AutoConfiguration.imports文件的加载机制。
当 Spring Boot 启动时,@EnableAutoConfiguration会触发加载META-INF/spring.factories(老版本)或META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports(新版本)中注册的自动配置类。这些配置类本质上是一堆带@Configuration、@ConditionalOnClass、@ConditionalOnMissingBean的类,它们根据当前项目的类路径、属性配置、Bean 定义来判断“该装配的组件是否装配”。
比如你引入了spring-boot-starter-web,类路径上出现了DispatcherServlet和相关类,自动配置类就会创建DispatcherServlet、HandlerMapping、ViewResolver等一系列 Web 组件。如果你自己在容器里已经定义了一个DataSource,自动配置会通过@ConditionalOnMissingBean检测到,不再创建默认的DataSource,避免冲突。理解这个机制,对排查“为什么我加了一个组件但配置不生效”非常有帮助。
4.2 条件装配:@Conditional 系列注解到底怎么判断
自动配置的灵魂是条件装配,核心是各种@Conditional注解。@ConditionalOnClass判断类路径上是否存在指定的类,@ConditionalOnProperty判断配置项是否满足条件,@ConditionalOnMissingBean判断容器中是否没有某个 Bean,@ConditionalOnBean则相反。这些注解让配置类可以“感知环境”,环境不同行为的装配结果也不同。
实际开发中,我们也可以在自己的项目里用这些条件注解来编写具有灵活性的配置类。比如封装一个公共的 Redis 组件,希望在某个服务中通过配置文件决定是否启用;或者写一个多环境适配的数据源配置,开发环境用 H2、生产环境用 MySQL,这些都可以用条件装配实现。Spring Boot 的自动配置就是这么干的,它把“你告诉我要什么”变成了“我看看你现在有什么,再决定给你装什么”。
需要注意,条件注解的判断时机是注册配置类时,不是运行时。如果你在代码里动态修改了类路径或者运行期才决定是否加载某些类,条件注解是来不及反映的。另外,多个自动配置类之间的加载顺序由@AutoConfigureOrder和@AutoConfigureBefore/@AutoConfigureAfter控制,如果你在自定义自动配置时把控不好顺序,很可能会出现 Bean 重复定义或依赖缺失的问题。我在做一个公共 starter 时就踩过这种坑,最后通过给自动配置类显式定义顺序才解决。
4.3 从 Spring 到 Spring Boot 再到 Spring Cloud、Spring AI:生态演进路线
Spring 自身不是万能的,它提供了容器、事务、MVC、数据访问等基础能力,但大型分布式系统的服务注册发现、配置中心、统一网关、负载均衡、链路追踪等场景,需要另外一套解决方案,于是 Spring Cloud 系列出现了。很多人把 Spring Cloud 和 Spring Boot 混为一谈,其实它们不是替代关系——Spring Cloud 是一系列分布式工具集的整合,它依赖 Spring Boot 作为基础框架。Spring Cloud 各个子项目利用 Spring Boot 的自动配置,把注册中心、HTTP 调用、熔断、配置管理封装成开箱即用的组件。
在微服务实践中,一个典型的组合是 Spring Boot + Spring Cloud Alibaba,其中 Nacos 做服务注册与配置中心,OpenFeign 做服务间声明式 HTTP 调用,Sentinel 做流量控制和熔断降级,Seata 处理分布式事务。这里面的服务注册与发现、配置动态刷新能力,底层都离不开 Spring 容器的扩展点。比如动态刷新配置的原理,就是利用了 Spring 对@RefreshScopeBean 的重新创建机制。
更近一点的演进是 Spring AI 的出现。Spring 官方开始拥抱大模型生态,提供了对 OpenAI、智谱、通义千问等模型的统一抽象,把 RAG、Agent、结构化输出等能力嵌入到 Spring 的开发模型里。也就是说,你写 AI 应用时依然可以沿用熟悉的 Spring Boot 工程结构和自动装配思想。Spring AI 的理念跟 Spring Boot 一致的:约定大于配置,用 starter 统一封装外部模型的调用差异。这背后依然是依赖注入、自动配置、扩展点这些基础原理,所以把 Spring 原理学扎实,接触任何上层框架都能更快上手。
5. 常见问题排查与源码阅读技巧
5.1 实际排查中最多见的三类问题
每隔一段时间就会碰到同事一脸疑惑地拿着错误日志来找我,说 Bean 注入失败或者事务不生效。问题样本多了以后,我发现高频问题可以归纳成三类。第一类是NoSuchBeanDefinitionException,含义是容器里找不到对应的 Bean。原因通常是类没有被 Spring 扫描到(包路径不对)、没有加@Component系列注解、或者该 Bean 是接口而容器的实现类不是默认命名,解决时优先检查类路径和扫描配置。
第二类是高并发环境下偶发的循环依赖问题。老项目里用了三级缓存解决循环依赖后,正常启动没问题,但偶尔在高并发或延迟初始化下出现BeanCurrentlyInCreationException。这种问题往往不只是简单的 A 依赖 B,而是多层循环依赖。排查时要顺藤摸瓜,把项目初始化的 Bean 打印出来看依赖链路,必要时通过@Lazy或者调整初始化顺序缓解。
第三类是BeanNotOfRequiredTypeException,报错说容器中的 Bean 类型与期望不符。这通常跟代理有关:类实现了接口,但被 CGLIB 代理后类型变成了子类,如果你直接按类类型获取 Bean,会拿到代理子类而不是原始类,类型不匹配。用接口类型获取,或者确定项目是否统一设置了 proxy-target-class,可以快速排除。
5.2 阅读 Spring 源码的入手顺序
阅读 Spring 源码很多人一开始就乱了,打开 AbstractApplicationContext 的 refresh 方法一看全是抽象调用,瞬间放弃。我给的建议是按照主路径来读,先把启动流程的骨架拉通,再往里填充细节。入门可以从ClassPathXmlApplicationContext或AnnotationConfigApplicationContext的 refresh 方法开始,主线就是:BeanFactory 创建准备 -> BeanDefinition 解析注册 -> 实例化及初始化所有单例 Bean。
第二步可以聚焦finishBeanFactoryInitialization和preInstantiateSingletons,这里会看到所有单例 Bean 的实例化入口。第三步是理解依赖注入的核心doCreateBean:实例化、属性填充、初始化、注册销毁,以及三级缓存的处理逻辑。第三块再单独看 AOP 的入口AbstractAutoProxyCreator和事务的TransactionInterceptor。
读源码的时候建议带着问题去读,而不是从头到尾逐行看。比如“@Autowired 是怎么完成注入的”,直接定位到AutowiredAnnotationBeanPostProcessor;比如“事务是怎么开启和回滚的”,看TransactionInterceptor;比如“自动配置类是怎么加载的”,看AutoConfigurationImportSelector。带着问题定位代码,效率远高于通读全书。
5.3 手写一个迷你 Spring 框架的启发
我在带新人时经常建议一个练习:不要直接啃完整源码,而是花两三天手写一个仅支持最基本功能的迷你 Spring。不用考虑复杂场景,只要实现注解扫描、依赖注入、单例缓存、一个 BeanPostProcessor 扩展点,就足以让原理变得具象。这个练习做完,很多“抽象”的概念就落地了。
我自己写那个迷你脚手架时,最大的收获是理解了 BeanDefinition 的重要性。刚开始我只是扫描类名,直接反射创建对象,结果依赖注入的顺序一乱就出问题。后来按照真实 Spring 的思路,扫描阶段就把所有类的元数据注册成 BeanDefinition 存起来,实例化阶段再逐个创建,整个流程瞬间清晰。这也印证了一条原则:设计一个带容器的框架,先收集元数据、再按顺序创建,比零散反射调用要可靠得多。
手写练习还能帮你发现一个问题:为什么 Spring 要搞那么多接口和扩展点。当你试图在自己的框架里增加“AOP 代理”或者“配置属性解析”时,会发现如果没有统一的生命周期回调方法,就只能把逻辑写死在创建流程里,改一处影响全局。而 BeanPostProcessor 通过回调机制把变化点下沉到了流程之外,架构的稳定性就体现出来了。
写在最后
Spring 原理这个东西,说难很难,因为它层次很多;说容易也容易,只要你抓住容器和代理两条主线,大部分特性都能顺着理解。我在日常工作中能看到,凡是能熟练排查容器问题、事务问题、自动配置问题的工程师,无一例外都对 Bean 生命周期和 AOP 的机制有清晰认知。最后再分享一个小技巧:排查 Spring 相关问题时,不要一开始就猜,打开 debug 日志,看 Bean 的创建顺序、代理类的生成位置,很多时候问题当场就暴露了。Spring 是一个管道,原理就是管道图,图在心里,遇到问题自然有解题思路。