"Spring框架核心"几乎是所有Java后端开发都绕不开的一道坎。我最早学Spring的时候,脑子里全是“new对象、set值、调用方法”那一套,后来跟着一套超详细的Spring核心课程,才慢慢把IOC容器、依赖注入、Bean生命周期、AOP这些概念串成了一条完整的线。很多人学Spring都停在“会用注解”的层面,一旦遇到循环依赖、事务失效、AOP不生效这类问题就完全懵了。这篇内容就是按实战顺序整理出的一份可以直接照着学习的核心笔记,顺带把我踩过的坑和排查思路也写了进去。如果你刚学完Java基础想深入Spring,或者在准备技术面之前的系统复盘,这篇会比单纯看官方文档友好得多。
1. 先搞清楚Spring框架核心到底在解决什么问题
1.1 从“自己找对象”到“容器发对象”:控制反转的逻辑
很多刚接触Spring的人看不懂IOC,其实就是“控制反转”这四个字太抽象了。我用最直白的话解释:传统写法是你要用什么对象,就在代码里自己new出来,也就是主动创建依赖;Spring的写法是,你只声明“我需要什么”,容器在启动的时候把现成的对象发给你,你被动接收。
// 传统写法:Service自己创建Dao,耦合死了 UserDao dao = new UserDao(); UserService service = new UserService(dao); // Spring写法:只声明依赖,容器负责组装 @Service public class UserService { private final UserDao userDao; public UserService(UserDao userDao) { this.userDao = userDao; } }表面上看,这种变化只是把“new”换成了“注入”,但背后的核心价值是解耦。一个类不再需要关心依赖对象是怎么创建、怎么配置生命周期、是单例还是多例,这些全交给容器统一管理。这就是控制反转的本质:对象的创建权和调用权从程序员手里,反转给了Spring容器。
这套东西听起来简单,真正影响深远的是它能带来统一管理能力。你可以想象一个公司,以前每个部门自己招人、自己发工资,乱成一团;现在统一交给人力部门管理,谁入职、谁离职、发多少,都有据可查。Spring的IOC容器就是那个“人力部门”,Bean就是被管理的员工,BeanDefinition就是员工的档案信息。
1.2 Spring核心知识地图:先知道自己要翻哪座山
我见过太多人学Spring,今天学一个注解,明天学一个用法,零散得厉害。按这条线学,会扎实很多:
- IOC容器基础:BeanFactory和ApplicationContext的关系
- 依赖注入:构造器注入、Setter注入、字段注入的取舍
- Bean生命周期:一个类从加载到销毁到底经历了什么
- AOP与动态代理:日志、权限、事务为什么能无侵入地切入
- 事务管理:@Transactional的传播行为、回滚规则和失效场景
- 配置方式:XML、注解、JavaConfig的演进逻辑
- 扩展机制:BeanPostProcessor、BeanFactoryPostProcessor、事件监听
这七块内容,前四块是地基,第五块是公认为最容易出问题的部分,第六、第七块是你从“会用”走向“懂原理”的阶梯。
1.3 适合谁看,需要什么基础
如果你已经会Java基础的反射和动态代理,看起来会轻松很多,因为Spring底层大量使用这两样东西。如果还不太熟也没关系,我在后面会尽量用生活化的类比把原理讲清楚,你只要会写Java类、用过Maven,就具备上手的条件。如果是完全零基础,建议先把Java的接口、继承、构造器这些语法过一遍,再回来看会效率高不少。
2. 深入IOC容器:从BeanFactory到ApplicationContext
2.1 容器初始化时到底做了什么
很多人以为ApplicationContext一启动,所有的Bean就被创建好了。这个理解基本方向对,但不全面。Spring的初始化过程远不是一次性把所有对象new完那么简单,它要加载配置、解析Bean定义、执行扩展点、创建单例Bean,每一步都有严格的顺序。
我用一个简化但够用的流程来描述:
- 解析配置类或XML文件,把每个Bean配置转换成BeanDefinition对象
- 扫描注册:把@Component、@Service标注的类扫描成BeanDefinition
- 执行BeanFactoryPostProcessor,允许修改Bean定义
- 注册BeanPostProcessor,这些是干预Bean创建的扩展点
- 预实例化所有单例Bean:创建实例、填充属性、执行初始化回调
- 发布容器刷新完成事件
这套流程的入口就是ApplicationContext.refresh()方法。我在实际排查问题的时候,经常先问自己:问题是出在BeanDefinition阶段,还是实例化阶段?这两个阶段排查思路完全不一样。比如你想往容器里动态加一个Bean,就应该在BeanFactoryPostProcessor里做,因为这时候Bean还没开始创建。
Spring Boot的出现简化了配置,但底层依然是这套refresh流程。所以理解了refresh,你就同时理解了Spring和Spring Boot的一部分核心。
2.2 Bean的定义从哪里来:XML、注解和JavaConfig
过去Spring写XML,现在主流是注解加JavaConfig,但底层解析结果都一样:都变成BeanDefinition。这三种方式我都用过,说下感受:
XML方式:
<bean id="userDao" class="com.example.dao.UserDao"/> <bean id="userService" class="com.example.service.UserService"> <constructor-arg ref="userDao"/> </bean>XML的好处是集中可见,缺点是写起来繁琐,而且类型安全检查薄弱,所以现在基本只出现在老项目里。
注解扫描方式:
@Service public class UserService { private final UserDao userDao; public UserService(UserDao userDao) { this.userDao = userDao; } }这是现在最常用的方式。只要在配置类上加上@ComponentScan,Spring就会自动扫描包下的注解类。
JavaConfig方式:
@Configuration public class AppConfig { @Bean public UserService userService(UserDao userDao) { return new UserService(userDao); } }@Bean方法的好处是你完全控制Bean的创建过程,适合引入第三方类库,因为这些类的源码上没有@Component注解,没法被扫描。
实际项目里往往是三种混用。比如接入一个第三方SDK,用@Bean手动配置;自己的业务类用@ComponentScan自动扫描;老系统迁移期兼容XML。你不用纠结哪种最好,能用得顺手、符合项目现状就是好方案。
2.3 单例还是多例:Bean作用域别选错
Spring的Bean默认是单例,这一点很多初学者会忽略。单例意味着整个容器只创建一份,所有依赖它的地方拿到的是同一个对象。
@Service @Scope("prototype") public class OrderService { }@Scope("prototype")表示每次获取都创建新实例。这个选择看起来简单,实际很容易踩坑:如果你在一个单例Bean里依赖了一个prototype Bean,那么在单例Bean创建时,prototype Bean只会被注入一次,之后所有调用都用同一个实例,等于你设置了prototype也白设置。
我遇到过一个真实场景:某个状态机工具类被定义成prototype,因为每次调用都要有独立状态,结果它在某个单例Service里被注入,导致并发时状态互相干扰。排查半天才发现是作用域没有生效。
解决办法有几种:用ObjectProvider 延迟获取,用@Lookup方法,或者干脆把状态设计成方法参数,不依赖Bean作用域。最彻底的思路是别让Bean持有可变状态,状态越少,作用域问题越少。
2.4 Bean的生命周期:从class到bean的全路径
Bean生命周期是面试高频题,也是理解Spring AOP、事务的钥匙。我整理了这条路径:
| 阶段 | 关键回调/机制 | 说明 |
|---|---|---|
| 实例化前 | postProcessBeforeInstantiation | 可以返回代理对象,跳过正常实例化流程 |
| 实例化 | 构造器 | 通过反射创建原始对象 |
| 属性填充 | 依赖注入 | 把@Autowired、@Value等字段或构造器参数填进去 |
| Aware接口回调 | BeanNameAware等 | 让Bean感知自己在容器中的名字、工厂等信息 |
| 初始化前 | postProcessBeforeInitialization | BeanPostProcessor执行,可以替换或修改对象 |
| 执行初始化 | @PostConstruct、afterPropertiesSet、init-method | 自定义初始化逻辑 |
| 初始化后 | postProcessAfterInitialization | 这是生成AOP代理的关键节点 |
| 使用 | getBean获取 | 如果配置了代理,这里拿到的是代理对象 |
| 销毁 | @PreDestroy、destroy-method | 容器关闭时释放资源 |
这里最容易混淆的是执行顺序。关于初始化回调,按照优先级排序是:@PostConstruct注解方法,其次InitializingBean的afterPropertiesSet,最后是XML或@Bean上指定的initMethod。实际开发中我建议只用一种方式,多了反而难维护。
真正帮助我们理解AOP的是“初始化后”这个节点。Spring AOP会在这里通过BeanPostProcessor把原对象包装成代理对象,这也是为什么AOP和Bean生命周期绑定得这么紧密。
3. 依赖注入的三种姿势,以及@Autowired的匹配逻辑
3.1 为什么官方推荐构造器注入
Spring团队在官方文档里的态度很明确:构造器注入适合必选依赖,Setter注入适合可选依赖。为什么力推构造器?因为它有三个实打实的好处:
第一,能保证依赖不被篡改。构造器注入可以配合final关键字,Bean在创建后依赖引用就不会变了。字段注入就做不到这一点,Spring用反射强行设置私有字段,等于绕开了访问控制。
第二,能防止“空依赖”问题。如果依赖在构造时传入,那么对象创建完成时它一定已经存在。字段注入则可能出现Bean创建后,依赖还没注入完成就被使用的情况,排查起来很痛苦。
第三,测试友好。单测时直接new一个对象,把mock依赖传进构造器就行,不用启动Spring容器。
@Service public class UserService { private final UserDao userDao; public UserService(UserDao userDao) { this.userDao = userDao; } }我见过不少老项目大量使用@Autowired字段注入,写着方便,但一旦依赖关系复杂,测试和排查的成本会明显上升。如果你正在写新代码,优先考虑构造器注入。
3.2 @Autowired与@Resource、@Inject的差异
这三个注解都用来注入依赖,但底层机制不一样。
| 注解 | 来源 | 默认匹配方式 | 扩展 |
|---|---|---|---|
| @Autowired | Spring | byType(按类型) | 配合@Qualifier按名称 |
| @Resource | Java EE规范 | byName(按名称)再byType | JNDI风格 |
| @Inject | JSR330标准 | byType | 配合@Named |
实际开发中,@Autowired出现频率最高。如果你有多个同类型Bean,比如两个实现类都实现了同一个接口,@Autowired默认会报NoUniqueBeanDefinitionException。这时候有三种处理思路:
- 用@Primary标记主Bean,告诉Spring优先注入它
- 用@Qualifier("beanName")指定名称
- 用@Resource直接指定name属性
我建议优先用@Primary,因为它的语义更明确:这个实现是主选方案。@Qualifier适合少数的特殊场景,如果到处都用@Qualifier,说明你的接口设计本身需要拆分。
3.3 @Autowired找不到Bean时的三个台阶
Spring在做byType匹配时,逻辑上可以分为几个台阶:先把所有类型匹配的候选Bean找出来,然后处理@Primary优先级的候选,再用@Qualifier缩小范围,如果到这一步还不唯一,就抛异常。
有一个细节很多人不知道:@Autowired可以设置required=false,表示找不到Bean也不报错,注入null。这个功能在写通用组件或可选集成的场景很有用,但业务代码里不建议用,因为Bean缺失往往意味着配置有问题,尽早报错更安全。
如果你想要更灵活的方式,可以注入ObjectProvider 。它允许你延迟获取Bean:
@Autowired private ObjectProvider<UserService> userServiceProvider; public void doSomething() { UserService service = userServiceProvider.getIfAvailable(); if (service != null) { service.execute(); } }ObjectProvider的好处是不会在容器启动时就强依赖Bean存在,适合处理一些可选依赖或循环依赖的延迟场景。
3.4 循环依赖:Spring的三级缓存怎么救场
循环依赖长这样:OrderService依赖UserService,UserService依赖OrderService。如果Spring不懂变通,创建OrderService时需要UserService,创建UserService时又需要OrderService,就变成死锁了。
Spring解决单例循环依赖靠的是三级缓存:
- 一级缓存 singletonObjects:存放已经创建完成的单例Bean
- 二级缓存 earlySingletonObjects:存放已经被提前拿到引用、但还没完成初始化的Bean
- 三级缓存 singletonFactories:存放ObjectFactory,能提前生成代理或原始对象
整个流程要配合“先暴露再初始化”的思想。OrderService在实例化之后,先放进三级缓存,接着填充属性时发现需要UserService,于是去创建UserService;UserService实例化后,填充属性发现需要OrderService,此时能从三级缓存拿到OrderService的早期引用,注入进去。UserService完成创建后,OrderService再继续完成自己的初始化。
这个设计最妙的地方在于三级缓存里的ObjectFactory。因为普通Bean直接返回原始对象就够了,但如果目标Bean要生成AOP代理,需要在早期暴露时就把代理创建出来,否则后续注入的引用和最终代理不一致。三级缓存确保了代理对象从开始就是唯一身份。
但这套机制不是万能的。构造器循环依赖救不了,因为构造器阶段对象还没有暴露出去;prototype作用域的循环依赖救不了,因为容器不缓存原型Bean。遇到这两种情况,常见的解法是加@Lazy延迟加载,或者从设计上抽一层,不要让两个Service直接互相依赖。
4. AOP:Spring框架的另一颗心脏
4.1 没有AOP,日志和权限代码会散成什么样
先想一个最简单的问题:每个Service方法都打日志,你会怎么写?最粗暴的方式是在每个方法的第一行和最后一行写日志代码。一个十个方法的服务类,要写二十行日志代码,而且这些代码跟业务逻辑混在一起,后面的维护会让人抓狂。
横切逻辑就是这样:日志、权限校验、性能统计、事务管理,它们不关心业务细节,但必须嵌入到业务的各个节点。AOP做的事情,就是把这部分横切逻辑抽出来,让业务代码只写业务,由代理在方法执行前后替你插入横切逻辑。
@Aspect @Component public class LogAspect { @Around("execution(* com.example.service.*.*(..))") public Object logAround(ProceedingJoinPoint pjp) throws Throwable { long start = System.currentTimeMillis(); Object result = pjp.proceed(); long cost = System.currentTimeMillis() - start; System.out.println(pjp.getSignature().getName() + " cost " + cost + " ms"); return result; } }这段代码的意思是:所有service包下类的所有方法,在执行前后自动计算耗时。业务代码里完全看不到这些逻辑,这就是AOP的实际价值。
4.2 JDK动态代理与CGLIB:底层选谁
Spring AOP实现代理有两种方式:JDK动态代理和CGLIB。JDK动态代理要求目标类实现接口,它生成一个实现了相同接口的代理类;CGLIB则是通过继承目标类来创建子类代理,不要求接口。
如果目标类没有实现接口,Spring只能走CGLIB。而在Spring Boot 2.x之后,默认就使用CGLIB,即使目标类实现了接口。这个变化让很多老代码行为发生了变化,原本能代理接口方法,现在要小心final方法。
CGLIB生成的代理是目标类的子类,所以final类、final方法都没法被代理。底层的rebind逻辑也会拒绝final成员。这个特性是“AOP不生效”的经典原因之一。
还有一个坑:当AOP代理和对象自身调用混在一起时,容易出现ClassCastException。比如你注入的是接口类型,实际对象是CGLIB代理类型,直接强制转型就会出问题。尽量面向接口编程,降低这种风险。
4.3 五个通知类型与执行顺序
Spring AOP提供了五种通知:
| 通知类型 | 注解 | 执行时机 |
|---|---|---|
| 前置通知 | @Before | 目标方法执行前 |
| 返回通知 | @AfterReturning | 目标方法正常返回后 |
| 异常通知 | @AfterThrowing | 目标方法抛出异常后 |
| 最终通知 | @After | 目标方法结束后,类似finally |
| 环绕通知 | @Around | 包裹目标方法,可控制前后逻辑 |
@Around是最灵活的,可以完全控制目标方法是否执行。但注意,如果你在@Around里没有调用pjp.proceed(),目标方法压根不会执行,这在权限校验场景是有意的,但排查问题时容易被忽略,以为方法坏了。
多个切面同时存在时,执行顺序由@Order决定。数字越小优先级越高,前置通知按优先级高到低执行,后置通知相反。这个顺序如果搞错,排查日志时会被顺序误导很久。
4.4 最常见的两个AOP失效场景
第一个场景是同类内部调用。假设一个类有方法A和方法B,A上声明了事务或日志切面,A内部调用B,B上的切面不会生效。原因是Spring生成的代理只拦截外部调用,你通过this调用B,走的是原始对象,不是代理对象。
@Service public class OrderService { @Transactional public void placeOrder() { // ... this.updateStock(); // 这里的this是原始对象,不是为了代理 } @Transactional public void updateStock() { } }解决方案是拆类,或注入自身代理。拆类更干净,因为这违反的是单一职责,也应该拆。
第二个场景是不满足代理条件。final方法、private方法、非Spring管理的类,都不可能被代理。还有一个容易被忽略的:如果切面类的@Component被误删了,切面根本没有注册成Bean,自然也不会生效。这种错误通常不会报任何异常,只能从控制台日志里看不到预期输出来反推。
5. 事务管理:@Transactional的底层与常见翻车点
5.1 传播行为:父子方法之间的事务怎么协同
事务传播行为是Spring事务里最抽象的概念之一。为了方便理解,我把它当作一个问题:当方法A调用方法B,A开启了事务,B也有自己的事务注解,那么B到底该加入A的事务,还是另起一个事务?
Spring定义了七种传播级别,其中三个最常用:
| 传播级别 | 行为 | 使用场景 |
|---|---|---|
| REQUIRED | 没有事务就新建,有就加入 | 默认值,大部分业务用这个 |
| REQUIRES_NEW | 挂起当前事务,新建一个 | 日志流水、异步补偿,不希望被主事务回滚 |
| NESTED | 嵌套事务,基于保存点 | 子事务回滚不影响外层提交 |
最让我觉得麻烦的是REQUIRES_NEW和NESTED,很多人以为差不多,实际差距很大。REQUIRES_NEW是彻底另起炉灶,外层事务回滚对它是无效的;NESTED则是在外层事务里开一个保存点,子事务回滚后,外层事务还可以继续往下走,决定最终是否提交。
真实项目里,我建议谨慎使用REQUIRES_NEW。如果主事务失败了,但某个子操作无论如何都要成功,这种情况才适合用它。如果只是希望部分业务做错了不引起全局回滚,先反思业务设计是不是本身有问题,别动不动就换传播级别。
5.2 回滚规则:为什么checked异常不生效
很多人第一次用@Transactional,写了个检查异常,发现事务居然没回滚,就怀疑Spring出了问题。其实Spring默认只对RuntimeException和Error进行回滚,受检异常不会触发回滚。这个设计是有原因的:Spring认为受检异常通常说明业务可以接受并处理,而非运行时异常往往代表不可恢复的系统或业务错误。
那遇到需要回滚的受检异常怎么办?显式指定rollbackFor:
@Transactional(rollbackFor = Exception.class) public void transfer(String from, String to, BigDecimal amount) throws Exception { // 业务逻辑 }除了rollbackFor,还有两个参数值得关注。readOnly=true适合只读查询,能帮助底层数据库做优化;timeout可以设置事务的最长执行时间,防止长事务卡死。
这里要提醒一句:readOnly并不是数据库层面的强制只读,它更多是给Spring和ORM框架一个优化提示。如果你在只读事务里做了写操作,数据库本身不一定拦得住。
5.3 事务失效的六大场景清单
根据我自己的排查经验,事务失效八成以上逃不过这六类:
- 同类调用不走代理,这是最常见的一类,和AOP失效的原因一模一样
- 方法不是public,Spring的代理无法拦截非public方法
- 异常被try-catch吞掉了,事务感知不到异常
- 抛出的异常是受检异常,又没有配置rollbackFor
- Bean没有被Spring管理,加上注解等于白加
- 数据库表本身不支持事务,比如用了MyISAM引擎
排查看这里有个技巧:先确认调用方拿到的bean是不是代理对象。可以在上下文里打印一下class,如果带了CGLIB或JDKProxy说明代理生效;否则直接检查Bean定义和配置。
另外一个容易踩的坑是,把@Transactional放在接口上。Java注解不会被子类强制继承,接口注解在某些代理模式下的传播行为并不可靠。最稳妥的做法是只把事务注解放在实现类上,而且放在具体方法上,不要放在类上。
6. 配置细节与源码级的关键机制
6.1 @Configuration的Full模式和Lite模式
@Configuration类有两个模式,这个点很多教程没讲透。默认情况下,@Configuration会被CGLIB代理,目的就是保证@Bean方法的调用是单例的。如果你在Bean方法里又调用了另一个@Bean方法,代理会拦截这个调用,直接返回容器里的同一个实例。
@Configuration public class AppConfig { @Bean public A a() { return new A(); } @Bean public B b() { return new B(a()); // 如果容器里有A,直接复用 } }如果把@Configuration改成普通类,或者在Spring Boot里对内部类使用@Configuration,就可能进入Lite模式。Lite模式下,@Bean方法不会被拦截,b()里的a()调用会new出一个新的A实例,打破单例预期。
Spring Boot 2.x之后推荐用@Configuration(proxyBeanMethods = false)来降低启动成本,但前提是你不依赖这种方法级别的拦截。我自己是默认保留proxyBeanMethods=true,除非启动优化压力很大,否则单例语义比那一点点性能开销重要得多。
6.2 FactoryBean和BeanFactory千万别搞混
这两个名字长得像,但完全不是一个东西。BeanFactory是整个IOC容器的顶层接口,而FactoryBean是一个特殊的Bean工厂,用来生产其他Bean。
假设你引入了一个复杂的初始化逻辑,用@Bean去创建太冗长,就可以写一个FactoryBean实现类,在getObject()里返回真正想要的对象。Spring在getBean("beanName")的时候,拿到的不是FactoryBean本身,而是它getObject()到的对象。
如果你确实想拿FactoryBean实例,要用特殊的“&”前缀,比如getBean("&beanName")。这个小细节面试时偶尔会被问到,实际使用中则要注意:如果FactoryBean的getObject()内部依赖了别的Bean,它仍然遵循Bean生命周期,也会走BeanPostProcessor,这点容易被忽略。
6.3 两类PostProcessor的执行时机
BeanFactoryPostProcessor和BeanPostProcessor名字像,但作用时机完全不同。BeanFactoryPostProcessor在处理BeanDefinition的阶段执行,它可以修改、新增、删除BeanDefinition;BeanPostProcessor在Bean实例化前后执行,它干预的是具体对象的创建过程。
一个典型的场景:你想给所有Bean加一个属性,可以在BeanFactoryPostProcessor里遍历BeanDefinition,统一设置属性值。或者你想在某个Bean实例化后做动态代理,那就要用BeanPostProcessor。
执行顺序很重要,BeanFactoryPostProcessor在容器refresh的早期调用,BeanPostProcessor则是在Bean创建过程中调用。Spring Boot里的很多自动配置,都是利用后处理器机制实现魔改的。
6.4 事件监听:容器级别的观察者模式
Spring的事件机制可以让模块之间解耦。比如用户注册成功后,需要发通知、送积分、写日志,如果直接串行调用,代码耦合严重;用事件发布,主流程只负责发布一个注册成功事件,其他模块监听并响应。
@Component public class UserRegisterListener { @EventListener public void onUserRegister(UserRegisterEvent event) { // 处理通知、积分等 } }这里有个细节:默认情况下事件监听是同步执行的,如果监听器抛出异常,发布事件的调用方也会受影响。如果你希望异步监听,需要配合@Async。但这个组合有个坑,异步监听中事务会失效,因为事务上下文不会跨线程传播,需要单独处理事务边界。
7. 实操中总结的避坑清单
7.1 注入为null,先从哪条线查
我遇到Bean注入为null的时候,基本按这个顺序排查:
- 看这个类有没有被Spring扫描到,是否在@ComponentScan覆盖的包路径下
- 看有没有多个同名Bean导致注入的是另一个对象
- 看是否是静态工具类里用了@Autowired,静态字段不会参与依赖注入
- 看是否在构造器里提前调用了这个依赖,如果依赖还没注入,自然为空
- 看是否用了new关键字自己创建了对象,Spring容器根本不知道这个对象的存在
第5点特别常见。很多人写业务时图省事,直接new Service掉,导致Service里的依赖全部为null。省事一时爽,排查火葬场。
7.2 同一个类型的Bean建了两份
NoUniqueBeanDefinitionException的排查一般集中在三块:一是同一种类型被扫描了多个实现类;二是既用了@Component又用了@Bean,导致重复注册;三是有配置文件引入了同一个类多次。
遇到这个异常,先不要急着加@Qualifier,而是先弄清楚为什么会有两个实现。如果两个实现是有意为之,比如策略模式,那就用@Primary标注主实现。如果只是不小心重复注册,那要清理掉多余的那一个。
7.3 Bean创建时序导致的强制循环
前面说了三级缓存能解决大部分setter循环依赖,但要留意构造器循环依赖和启动时序问题。解决这种问题的最佳方式不是硬调配置,而是把循环依赖的类拆开,让依赖关系变成单向的。
如果实在不能拆,可以在某个依赖上使用@Lazy。@Lazy会为依赖生成一个懒加载代理,真正调用这个代理时,才会去容器里找目标Bean。这种方式的代价是引入了一层代理,调试的时候不太直观。
我个人经验是:如果项目中强制约定不允许循环依赖,这些设计问题会更容易暴露。在代码规约层面避开循环依赖,比在Spring里想办法绕开它要可靠得多。
7.4 从Spring 5到Spring 6,要留意哪些变化
如果你最近在升级项目,有几个和Spring核心相关的变化需要关注。Spring 6要求JDK 17+,很多老项目的JDK版本直接不满足;同时javax包被替换成了jakarta包,尤其是@Resource、@PostConstruct这类JSR注解,包名前缀要改。
Spring Boot 2.x默认使用CGLIB代理,Spring Boot 3.x和Spring 6延续了这个策略。如果你的老代码里大量使用接口代理,并且强依赖JDK动态代理的特性,升级后要重点回归一下AOP相关功能。
升级时我建议先在应用启动日志里检查上下文初始化是否有警告,再跑一轮事务和AOP的集成用例。Spring版本大跨步升级,问题往往不在API上,而在这些底层行为变化上。
我自己在复盘Spring这些核心内容时最深的体会是:Spring的设计很像一套精密的流水线,IOC负责管理物件,AOP负责在不打断业务的前提下嵌入处理,事务则依赖AOP实现原子性。你把这些关键机制串成一条线之后,遇到的很多运行时异常就不再是玄学了。这个框架学了不是终点,会用只是及格,能围绕容器扩展机制做二次开发,才算真正把核心握在了手里。