☰
Spring框架核心超详细解析:IoC容器、Bean生命周期与事务机制一本通
2026/10/15 6:42:42 网站建设 项目流程

1. 这套超详细版到底值不值得啃

最近总有人问我:“Spring到底怎么学才不算白学?”说自己也看过不少视频、买过不少课,但一到面试问原理、一到线上排查问题就发懵。我每次都会反问一句:Spring的核心脉络,你脑子里有没有一条清晰的线?如果你只能说出“Spring就是管对象的框架”,那八成是没找对学习材料。我手头一直存着一份流传很广的Spring框架核心超详细版笔记,与其说是笔记,不如说它把Spring官方文档里最啰嗦的部分提炼成了一套可以顺着推的思维链。这套东西的核心价值,不在于多全,而在于它把IoC、AOP、事务、Bean生命周期这些大的概念串在了一起,看完之后你会觉得“原来那些零散的知识点全都是一个整体”。

这份资料最打动我的地方,是它不堆概念。它默认你已经有Java基础,但不默认你已经懂Spring,所以它会把每一个很抽象的名词拆成“它到底解决什么问题、它内部到底怎么干、它踩过的坑是什么”三件事来讲。这种结构非常适合三类人:第一类是刚把SSM框架跑通、但还没系统梳理过原理的初中级开发者;第二类是准备面试,需要快速把核心机制、Bean生命周期、循环依赖、AOP代理这些高频点串成体系的人;第三类是工作中已经大量使用SpringBoot、但遇到奇怪问题只能靠重启解决,想真正看懂启动过程和Bean为什么注入失败的人。如果你是这三类之一,这篇梳理就很值得你花整块时间啃一遍。

我当初啃这份笔记的方式比较笨:第一遍只读不查,顺着把整条线走下来;第二遍开始对照Spring源码,每讲到一个机制就去看它对应的核心类;第三遍是扔掉笔记,自己给自己讲一遍“Spring从启动到Bean就绪到底发生了什么”。说实话,能做到第三遍的时候,你在面试和排障时候的底气就不一样了。下面我把这份超详细版里我认为最值得展开的部分,结合我自己实际项目里踩过的坑,重新给你整理成一篇能直接照着学的东西。

2. 先把地基建起来:IoC容器与Bean生命周期

2.1 BeanFactory和ApplicationContext,到底该记哪个

很多新手一开始就被这两个名词劝退,其实完全不用慌。BeanFactory是Spring里最底层的容器接口,它的职责只有一个:管理Bean的创建和获取,getBean就是它的灵魂方法。而ApplicationContext是BeanFactory的增强版,它额外提供了资源加载、事件发布、国际化、自动注册后置处理器这些能力。你完全可以把BeanFactory想成一个小作坊,能生产零件,但是一套流水线;而ApplicationContext是整座工厂,不仅有流水线,还有食堂、安保、运输系统。日常开发里你基本接触不到BeanFactory,写代码用的都是ApplicationContext,因为SpringBoot启动的时候创建的就是它。

但这里有个容易被人忽视的关键点:ApplicationContext容器里有两大块核心组件,一块是BeanFactory,负责对象管理;另一块是一堆处理器和后置处理器,负责在Bean创建的不同阶段“插手”。这份超详细版笔记的厉害之处,就是它反复强调了一个观念——Spring的整个IoC容器运作过程,本质上是BeanFactory和一堆BeanPostProcessor分工协作的过程。理解了这两者的分工,后面看Bean生命周期、看AOP、看事务才会顺理成章。

2.2 Bean生命周期里的关键节点

Bean生命周期是Spring框架核心面试题里的常客,也是很多人背书背得最痛苦的部分。其实你不需要背那么多扩展接口,只要能把握住一条主线就行:实例化 -> 属性填充 -> 初始化 -> 使用 -> 销毁。这条主线里Spring留了大量“钩子”,方便你在不同阶段插入自定义逻辑。

为了好记,我一般把生命周期拆成两段来看。第一段是“对象本身的成长”:Spring通过构造器创建出实例,然后做依赖注入把属性填上,再执行各种Aware接口回调、BeanPostProcessor的后处理逻辑、以及你自己定义的initMethod。第二段是“容器收尾”:容器关闭时会回调DisposableBean或destroyMethod。你只需要抓住一个核心顺序,即构造 -> 属性填充 -> Aware回调 -> BeanPostProcessor前置/后置 -> init -> 使用 -> destroy,面试怎么问都跑不出这个框架。

阶段关键动作常见自定义方式
实例化通过构造器创建对象构造器参数来自容器
属性填充注入依赖字段@Autowired、@Resource
初始化前BeanPostProcessor.postProcessBeforeInitialization@PostConstruct、InitializingBean
初始化调用initMethod@Bean(initMethod="init")
初始化后BeanPostProcessor.postProcessAfterInitializationAOP代理生成的关键时机
销毁调用销毁逻辑@PreDestroy、DisposableBean

这里我想重点提醒一句:很多人会把@PostConstruct和afterPropertiesSet搞混。@PostConstruct是标准注解,在依赖注入完成后执行,而afterPropertiesSet是InitializingBean接口的方法,它先于@PostConstruct执行。两者差别不大,但面试如果追问先后顺序,你能答出来就是加分项。实际开发中,我强烈建议不要同时用多种初始化方式,一个类里能用@PostConstruct就只用一个,不然排查时容易晕。

2.3 手写一个极简容器的价值

看十遍原理不如动手写一个低配版容器。这是我从那份超详细版的评论区里学来的方法,亲测非常有效。下面这个例子只有两个核心方法:注册Bean和获取Bean。

public class SimpleBeanFactory { private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(); private final Map<String, BeanDefinition> beanDefinitionMap = new ConcurrentHashMap<>(); public void registerBean(String name, Class<?> clazz, boolean singleton) { beanDefinitionMap.put(name, new BeanDefinition(clazz, singleton)); } public Object getBean(String name) { Object bean = singletonObjects.get(name); if (bean != null) { return bean; } BeanDefinition definition = beanDefinitionMap.get(name); if (definition == null) { throw new IllegalStateException("No bean named " + name); } bean = createBean(definition); if (definition.isSingleton()) { singletonObjects.put(name, bean); } return bean; } private Object createBean(BeanDefinition definition) { try { return definition.getClazz().getDeclaredConstructor().newInstance(); } catch (Exception e) { throw new RuntimeException("Create bean failed", e); } } // 省略 BeanDefinition 内部类 }

这个极简容器虽然简陋,但它已经把“注册-创建-缓存单例-获取”这条主链路完整展现了。你在这段代码上多花半小时,就能理解Spring容器最根上的设计:对象不是你自己new的,而是你把类的“描述信息”告诉容器,容器在合适的时机帮你创建并管理。这也是IoC的通俗解释。

3. 依赖注入没你想的那么神秘

3.1 三种注入方式该选哪种

依赖注入是Spring框架核心里天天用的功能,但里面有个特别容易被争论的话题:构造器注入、Setter注入、字段注入,到底选哪个?市面上主流观点是推荐构造器注入,我也是一样的态度。字段注入写起来最爽最省事,但它有两个问题:第一,你很难在单元测试里用new的方式创建这个类,因为它依赖字段注入,测试时不启动完整容器就没法注入;第二,字段注入把依赖关系隐藏在了字段层面,类的外部看不出它到底必须要哪些依赖,这会让代码的可读性和可维护性打折扣。

相比之下,构造器注入能把依赖关系显式地暴露出来,而且一旦对象构造完成,它的所有依赖都是final、不可变的,这会让对象状态非常稳定。有些团队会觉得构造器注入参数太多、类太丑,于是倾向Setter注入。而Setter注入适合那种可选依赖的场景。我的建议是:核心依赖用构造器注入,可选依赖用Setter注入,字段注入尽量少用。不是不能用,而是你心里要清楚它牺牲了什么。

3.2 循环依赖为什么Spring能解决

循环依赖几乎是Spring面试里绕不开的话题。A依赖B,B依赖A,这种情况在真实的业务系统里其实应该尽量避免,但Spring却在不经意间帮你处理了很多。它的处理机制就是“三级缓存”。我第一次看到三级缓存时觉得挺绕,后来用一个生活例子就记住了:这就像你还没买到生日蛋糕,但先把蛋糕券订好交给朋友,朋友手里拿着这个券就能把该送你的礼物先送来。你还不是完整的对象,但你有个“早期引用”可以用。

三级缓存的三个Map分别是:singletonObjects用于存放完整创建好的单例Bean;earlySingletonObjects用于存放提前暴露出去的早期Bean引用;singletonFactories用于存放ObjectFactory,也就是可以用来创建早期引用的工厂。整个运作流程是这样的:A创建时发现需要B,于是先把A的ObjectFactory放进三级缓存,然后去创建B;B创建时发现需要A,从三级缓存里通过ObjectFactory拿到A的早期引用,完成B的创建;B创建完后,A再继续完成属性填充并注册到一级缓存。这就是“先给一个半成品引用,把对方的依赖满足,再回头完善自己”的思路。

但要注意,三级缓存只能解决“单例、非构造器注入”下的循环依赖。如果你用的是构造器注入,A要构造就要先拿到B,B要构造就要先拿到A,两边都要创建完整对象,缓存也救不了。所以遇到构造器循环依赖,Spring启动时就直接报错。这也是我坚持“构造器注入虽好,但它会让循环依赖直接暴露出来”的另一种含义,从架构上看反而是好事。

3.3 @Autowired怎么就找不到了

@Autowired找不到Bean,是每个Spring开发者都遇过的问题。报错信息通常是NoSuchBeanDefinitionException或NoUniqueBeanDefinitionException。前者是“一个都没有”,后者是“有多个但不知道选哪个”。排查思路其实很固定:先确认这个类有没有被Spring扫描到,再确认类型和Bean名称是否匹配。

其中有一个特别容易忽略的细节:@Autowired是先按类型再按名称来匹配的。如果你有两个相同类型的Bean,又没有用@Primary或@Qualifier指定,Spring就会因为不知道该注入哪个而报错。另外一个坑是,如果你自己用new创建了一个对象,那这个对象完全不在容器管理范围内,里面所有@Autowired字段都会是null。现在SpringBoot项目里很多人写工具类时会犯这个错误。解决办法是让工具类里持有ApplicationContext,或者用构造器注入。

4. AOP的核心机制与事务结合

4.1 代理到底是怎么生成的

AOP在Spring框架核心里的地位,再怎么强调都不过分。因为Spring的事务、异步、缓存注解,底层全部是AOP。而AOP的底层,就是代理。Spring里有两种动态代理:JDK动态代理和CGLIB。很多人背概念背得熟,但一到问“SpringBoot默认用哪种”就卡住了。这里直接给结论:SpringBoot 2.x之后,默认不再是必须基于接口的JDK代理了,而是默认使用CGLIB代理。简单说,SpringBoot把proxyTargetClass默认设为true了,它希望直接用CGLIB生成目标类的子类代理,这样即使目标类没有实现接口也能被代理。

JDK动态代理和CGLIB的关键区别在于:JDK代理要求目标类必须实现接口,它是在运行时动态生成一个实现了相同接口的代理类,然后通过反射调用InvocationHandler里的逻辑;CGLIB则是直接生成目标类的一个子类,覆盖目标类的方法,所以它不要求目标类有接口。CGLIB的局限是,final类或final方法不能被代理,因为它没法继承和覆盖。这个细节在真实项目中很容易踩到,尤其是某些老代码里写了final方法,结果事务一直不生效,排查半天都找不到原因。

4.2 切点表达式与通知类型实操

AOP里面最基础也最容易忘的就是execution表达式的写法。我提供一个自己能记住的模板:

execution(修饰符? 返回类型 类名.方法名(参数) 异常?)

最常用的写法就几种。匹配某个包下所有类的所有方法,用execution(* com.example.service..(..));匹配某个类的某个具体方法,用execution(public void com.example.service.UserService.updateUser(..))。参数部分,单个参数用(com.example.model.User),任意参数用(..)。我给个实际切面的例子:

@Aspect @Component public class LogAspect { @Before("execution(* com.example.service.*.*(..))") public void beforeLog(JoinPoint joinPoint) { String methodName = joinPoint.getSignature().getName(); System.out.println("before: " + methodName); } @AfterReturning(pointcut = "execution(* com.example.service.*.*(..))", returning = "result") public void afterLog(Object result) { System.out.println("after, result = " + result); } }

通知类型里我实际项目中最常用的是@Around,因为只有它能同时处理方法执行前、执行后、异常和返回值修改。但@Around也是最容易写错的,因为它需要手动调用proceed(),一旦忘记调用,被代理方法就不会执行。这个错误非常隐蔽,因为代码编译不会报错,只有运行时发现业务方法没被调用才反应过来。

4.3 事务和AOP是怎么绑到一起的

理解了代理,就能理解@Transactional为什么有时不生效。Spring事务本身就是一个AOP环绕通知:目标方法执行前开启事务,执行成功提交事务,抛出RuntimeException则回滚事务。用的就是TransactionInterceptor。如果这个类没有被Spring代理,比如是new出来的,那事务注解自然就失效了。

更常见的失效场景是同类调用:某个类里方法A调用方法B,B上有@Transactional。因为A调B走的是this.方法,不是代理对象.方法,事务拦截器根本没机会介入,所以B的事务不会生效。解决办法是把B抽到另一个类里注入进来调用,或者在类里注入自身代理对象。另一个高频失效点是异常被吞掉:你捕获了异常但没往外抛,事务拦截器看到的是“方法正常返回”,自然就提交了。还有一个点是异常类型,Spring默认只对RuntimeException和Error回滚,如果你抛的是Exception,事务是不回滚的。很多人项目里用@Transactional(rollbackFor = Exception.class)也是在规避这个默认行为。

5. 从XML到注解驱动,Spring是怎么进化的

5.1 配置类里的Bean到底怎么注册的

早期Spring开发,最常见的操作就是写一个超长的XML文件,里面全是 标签。现在用SpringBoot,大家已经很少接触XML了,配置类的形式成了主流。理解@Configuration的核心,关键是搞清楚Spring会把这个配置类本身也当成一个Bean挂在容器里,而且会用CGLIB对配置类做增强。

被CGLIB增强后的配置类,有一个非常关键的行为:当你在配置类里用@Bean方法定义一个Bean时,方法会被拦截。如果多次调用同一个@Bean方法,Spring不会每次都执行方法体创建新对象,而是会先查容器,有就直接取。这就是为什么配置类里的@Bean默认是单例的。如果你在普通类里写一个不带@Configuration的方法返回对象,再配合@Component扫描,那这个方法每次调用都可能新建对象,不会复用容器里的单例。这一点要对面试和项目都有价值,很多人只记住了@Bean,却没意识到@Configuration和@Component的增强差异。

5.2 自动扫描是怎么扫进来的

注解驱动的另一块基石是@ComponentScan。@SpringBootApplication上其实就带着@ComponentScan,默认扫描它所在包及其子包。所以你把新写的Service放在启动类同包下或子包下,就能被扫描到;放到了别的包的上级目录,就扫描不到。这个规则说简单也简单,但项目里我见到的“为什么我的类没生效”问题,一大半都是包路径放错了。

扫描到的@Component、@Service、@Repository、@Controller这些注解,最终都会被Spring统一注册成BeanDefinition,然后走一遍前面说的Bean生命周期。这里值得一提的细节是:@Repository和@Service等注解本质都是@Component的衍生注解,它们除了语义不同,在扫描机制上几乎没有区别。@Repository会额外翻译持久层异常,@Controller会被SpringMVC的处理器映射器识别,但这些都属于后续功能扩展,不改变容器底层的Bean注册逻辑。

5.3 外部配置与条件装配

配置类的选择性装配也是Spring框架核心里一个很精彩的设计。@Value可以把配置文件里的值注入到字段里,但它适合简单场景。复杂一点的对象配置,用@ConfigurationProperties更合适,它能直接把前缀相同的配置项映射成一个Java对象。比如有一组配置是app.user.name和app.user.age,用@ConfigurationProperties(prefix = "app.user")就能自动映射进UserProperties类里,比@Value逐个字段注入优雅得多。

另一种非常实用的机制是条件装配,SpringBoot里大量用到的就是@ConditionalOnProperty、@ConditionalOnClass、@ConditionalOnMissingBean。它的核心价值是“让组件自己决定要不要注册”。你可以写一个配置类,上面加@ConditionalOnProperty(name = "sms.enabled", havingValue = "true"),这样只有当配置里sms.enabled为true的时候,这个配置类里的Bean才会被注册。这个机制在写通用组件、封装框架时极其好用,能把“定制化”从业务代码里剥离出去。

6. 事务管理和传播行为,这一块必须细讲

6.1 @Transactional背后到底做了什么

很多人以为@Transactional是Spring直接去操作数据库事务,其实它做的远比这多。它的完整链路是:通过AOP创建事务代理 -> 在代理方法执行前从连接池拿一个数据库连接 -> 把连接设置成手动提交 -> 执行业务方法 -> 成功则提交,失败则回滚 -> 关闭连接。这里还有一个很关键的细节:它要让同一个事务里的所有数据库操作都使用同一个Connection。

Spring的做法是用ThreadLocal把数据库连接和当前线程绑定在一起,这样同一线程内后续的DAO操作拿到的都是同一个连接,就不会产生多个独立事务。这也顺便解释了为什么Spring事务默认只对单线程有效:跨线程调用时,子线程没有继承这个ThreadLocal里的连接,所以事务会失效。在一个方法里新开线程执行数据库操作,主方法事务回滚了,子线程里的数据却可能已经提交。这是我实际项目里遇到信贷对账数据不一致时排查出来的一条重要经验。

6.2 七种传播行为怎么选

传播行为是事务配置里最让人犯选择困难症的部分。其实不需要死记硬背,你只需要弄明白最关键的三组对比。第一组是REQUIRED和REQUIRES_NEW:REQUIRED表示“有事务就加入,没有就新建”,这是默认值;REQUIRES_NEW表示“无论如何都开一个新事务,把当前事务挂起”。第二组是NESTED和REQUIRES_NEW的区别:NESTED是嵌套子事务,回滚时可以只回滚到内外事务之间的保存点,外层事务仍然可以继续提交;而REQUIRES_NEW的独立性更强,内层事务提交后,外层再回滚,内层也不受影响。第三组是SUPPORTS和NOT_SUPPORTED,一个是有就加入,一个是有也挂起改非事务,一般用在不强制需要事务的查询或非关键路径上。

传播行为含义典型场景
REQUIRED有事务就加入,没有就新建默认,适合大多数业务
REQUIRES_NEW挂起当前事务,新建独立事务独立记录操作日志
NESTED嵌套事务,带保存点批量处理,部分失败不影响整体
SUPPORTS有事务就加入,没有就非事务运行查询方法
NOT_SUPPORTED挂起当前事务,非事务运行大文件导出
MANDATORY必须在事务中运行,否则抛异常严格的数据一致性校验
NEVER必须在非事务中运行,否则抛异常测试或强事务环境下的辅助操作

我在项目里最常用的就是REQUIRED和REQUIRES_NEW。比如下单成功后要写一条订单日志,日志的写入不能因为订单逻辑回滚而被吞掉,我就会把写日志的方法单独抽出去加REQUIRES_NEW。但要注意REQUIRES_NEW需要开启一个新的数据库连接,代价比REQUIRED高,别到处滥用。

6.3 事务自调用和私有方法的坑

自调用失效这个话题前面提到过,这里再展开讲一个容易被忽略的细节:被Spring代理后的对象,方法内部的this指的是原始对象,不是代理对象。你在同类里写的A方法调用同类B方法,无论如何走的都是this.B(),所以Spring的AOP拦截器不会触发。解决方案一般有三个:第一是把B方法移到一个独立的类里,注入进来再调用;第二是在类里通过ObjectProvider或注入ApplicationContext,拿到容器里的代理对象调用自己;第三是使用AopContext.currentProxy(),但需要开启exposeProxy属性。

private方法导致事务失效也常被提起。原因是Spring使用CGLIB生成子类代理时,子类是覆盖不了父类的private方法,所以代理对象根本拦截不到这个方法的调用。更准确地说,private方法上的@Transactional注解在代理模式下基本不生效。同理,final方法也一样。所以我写代码时,事务方法一律用public修饰,而且尽量把事务逻辑控制在短方法内,不要一个几十行的大方法全塞进去,这样事务时间长、锁竞争大、回滚范围也大。

7. 实战里躲不开的几个坑

7.1 多数据源下事务默认只走主库

现在微服务和多数据源场景很多,一个常见的坑是:你配置了多套数据源,但@Transactional注解默认只对主数据源生效。如果你在一个事务方法里操作了第二个数据源,那第二个数据源是在独立事务里执行的,根本不归当前事务管。要正确支持多数据源事务,你需要把事务管理器明确指定为对应的DataSourceTransactionManager,同时还要考虑分布式事务的问题。

我的建议是:核心业务尽量简化数据源数量。如果一个事务里必须同时操作多个库,就不要幻想一个@Transactional就能保证一致性。你需要引入分布式事务方案,或者从业务上做拆分。之前我在某个订单系统里看到过一个很典型的案例,代码里一个方法既写MySQL的订单表,又写Redis的库存缓存,还在同一个事务里用@Transactional,结果MySQL回滚了,Redis的缓存却已经更新成了新值。这个问题不是你配置错了,而是思路错了:跨存储介质的一致性,单靠数据库事务是管不了的。

7.2 把getBean写在业务代码里的坏习惯

有些开发为了方便,直接在Service里注入ApplicationContext,然后到处调getBean("xxx")。这个做法问题很大。它相当于把服务定位器模式又请了回来,让代码在编译期完全看不出依赖关系,也让单元测试变得很难写。更关键的是,如果你在容器还没初始化完成的时候调getBean,可能会触发提前初始化,导致一些依赖没准备好就创建了Bean。

我之前维护过一个老项目,里面很多地方用ApplicationContext.getBean("xxService"),后来升级框架时发现启动变慢,排查后发现就是大量getBean调用导致Bean被反复提前实例化。我后来把业务代码里的getBean全改成了构造器注入,启动时间直接降了三分之一。当然,有些工具类是例外,比如你想在静态方法里拿一个Bean,这时注入ApplicationContext是合理的,但也要控制使用的范围,不要让它成为主流的取对象方式。

7.3 启动慢时怎么一步步定位

Spring项目启动慢,很多人第一反应是加内存,但实际上大部分原因出在Bean初始化和扫描上。排查时我会先看Spring启动日志,找Caused by里最耗时的BeanName,再用Profiler工具看是哪个Bean的初始化方法执行时间最长。最常见的问题有三类:第一是@ComponentScan扫得太宽,把根本不需要的包也扫进来了;第二是某些Bean在初始化时做了大量网络请求或数据库查询,比如@PostConstruct里读取远程配置,没有设置超时或做缓存;第三是Bean数量庞大,单例Bean太多,每个都要实例化和初始化。

有个小技巧是,在配置里临时开启spring.beaninfo.ignore=true,或者关掉不需要的自动配置类。SpringBoot默认加载很多你根本没用到功能的配置,每个自动配置类都可能触发额外的Bean注册,这会白白增加启动成本。另外,SpringBoot 2.4之后提供了带有启动阶段信息的日志,打开后你能直观看到Bean定义注册、Context刷新、SpringApplication启动这些步骤分别耗时多少,定位问题比凭感觉瞎猜快很多。

8. 最后再分享一点我自己的学法体会

每次有人问我Spring框架核心到底怎么才算“学透了”,我都会给一个判断标准:你能不能不看任何资料,从头到尾说出一个请求从进入Controller到返回的过程中,Spring容器在背后做了哪些事。能说得越细,说明你越是真懂。对着这份超详细版笔记去复盘,我建议你不要只满足于“看懂了”,而是找一台电脑把文中的代码、例子、报错场景都亲手跑一遍。错误信息是最值钱的学习材料,我之前在循环依赖、事务失效这些问题上踩的坑,基本都没有白踩,因为每踩一次,你对代理和容器这两个概念的理解就扎实一层。

如果你想继续往深走,下一步可以去看Spring Boot的自动配置源码,你会发现它和你现在掌握的@Configuration、@Conditional、@Bean机制完全是一脉相承的。Spring框架核心这道地基打好了,后面学什么框架都会顺手很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询