ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring核心原理:从IoC容器到AOP切面,一文讲透Bean生命周期与循环依赖

Spring核心原理:从IoC容器到AOP切面,一文讲透Bean生命周期与循环依赖 编写Spring相关的内容对你的学习路线会很有价值。我从“新手理解角度”和“面试追问视角”两个方向把这个题讲透会把容器、Bean生命周期、代理、切面这些概念串成一条线让你不是背结论而是真正明白Spring为什么这样设计。1. IoC的本质把创建对象的权利交出去之后发生了什么1.1 调用方为什么不该自己new对象我先从一段业务代码说起。假设你写一个订单服务里面要调用库存服务扣减库存。最直观的写法是这样public class OrderService { public void createOrder() { InventoryService inventoryService new InventoryService(); inventoryService.deductStock(); } }这段代码看起来没什么问题但隐患藏在两个地方第一OrderService和InventoryService之间是硬编码绑定哪天库存服务改了构造方法、加了入参你所有调用它的地方都要跟着改第二如果库存服务需要在某些场景下换成Mock实现或远程代理实现OrderService完全不支持这种替换因为它是直接 new 出来的。IoCInversion of Control控制反转恰恰解决的就是这个问题把对象创建和依赖注入的控制权从调用方手里拿出去交给一个外部容器统一管理。OrderService不再负责创建InventoryService只声明“我需要一个库存服务”容器会在合适的时机把现成的实例塞给它。1.2 从setter注入到构造器注入的演进Spring支持三种依赖注入方式构造器注入、setter注入、字段注入。我建议你在实际项目中优先选构造器注入原因是它能保证依赖的不可变性和实例化的完整性。Service public class OrderService { private final InventoryService inventoryService; public OrderService(InventoryService inventoryService) { this.inventoryService inventoryService; } }有人会问字段注入写起来不是更简单吗确实Autowired直接打在属性上很省事但它有几个硬伤单元测试时无法手动替换依赖没法通过反射之外的途径传值依赖关系是隐性的IDE和代码阅读者看不到这个类到底需要什么一旦对象创建成功字段依赖就再也不能变了。setter注入适合“可选依赖”或“运行期可能要换实现”的场景但多数业务场景里依赖是强制的用构造器注入最合理。这里提一句Spring官方在早期文档里也明确推荐构造器注入优先因为可以用final修饰配合Lombok还能进一步简化模板代码。1.3 容器管理Bean的核心机制当你写了一个带Service、Component、Repository注解的类Spring容器启动时会做三件事扫描指定的包路径找到所有候选类通过反射读取类的元信息根据注解和类型信息生成对应的BeanDefinition存入容器注册表。BeanDefinition可以理解为一份“Bean的制造说明书”里面记录了类的全限定名、作用域单例还是原型、初始化方法、销毁方法、属性依赖等。真正实例化Bean时容器拿着这份说明书去反射创建对象再按照依赖关系把关联Bean注入进去。整个过程中开发者几乎感知不到容器的存在但容器做了什么、什么时候做就是理解和排查Spring问题的基础。2. Bean实例化的几种方式与生命周期管理2.1 四种实例化机制对比除了最常见的“构造器直接实例化”Spring还给了一些灵活手段。我把它们的底层机制和适用场景整理成表格实例化方式实现要领典型场景构造器实例化反射调用类的构造方法默认无参也可带参数注入绝大多数普通业务组件静态工厂工厂方法是static返回类型必须是接口或父类配置类管理、第三方SDK初始化实例工厂先创建一个工厂Bean再通过它的非静态方法返回目标Bean需要先初始化工厂状态的场景FactoryBean实现FactoryBeanT接口重写getObject()和getObjectType()MyBatis的SqlSessionFactory、RPC客户端的Stub工厂我还想单独说一下FactoryBean。Spring容器里存在两类Bean普通Bean和工厂Bean注意是FactoryBean不是BeanFactory。当你在代码里Autowired一个FactoryBean实现的类型时容器返回的是getObject()方法生成的那个产品对象而不是FactoryBean本身。如果你真的想把FactoryBean本体拿出来需要在Bean名称前加符号。2.2 完整生命周期从定义到销毁看到一个Bean从诞生到消失你才真正理解为什么Spring容器被称为“容器”而不只是一个对象池。完整生命周期分几个阶段实例化前BeanPostProcessor.postProcessBeforeInitialization可以在前置阶段拦截比如做属性校验、动态代理替换。实例化根据BeanDefinition反射创建对象此时依赖还没注入。属性填充容器遍历依赖完成Autowired、Resource、XMLproperty的赋值。Aware回调如果实现了BeanNameAware、ApplicationContextAware、BeanFactoryAware等接口容器会回调对应方法。初始化前BeanPostProcessor.postProcessBeforeInitialization执行。初始化执行PostConstruct标注的方法、InitializingBean.afterPropertiesSet()、XML里配置的init-method。初始化后BeanPostProcessor.postProcessAfterInitialization执行AOP代理通常在这里产生。使用Bean进入就绪状态开始处理业务。销毁前/销毁执行PreDestroy、DisposableBean.destroy()、destroy-method。面试里有个高频追问是PostConstruct、afterPropertiesSet、init-method的执行顺序。我的答案PostConstruct最先然后afterPropertiesSet最后init-method。这个顺序在Spring源码AbstractAutowireCapableBeanFactory的initializeBean方法里是写死的。实际开发中要注意的是如果你在PostConstruct里调用了另一个Bean尚未初始化完成的方法可能触发循环依赖或NPE尤其当另一个依赖是原型作用域时连三级缓存都救不了。所以初始化阶段我只建议做轻量级的自检和缓存预热不要做太重的启动事务。2.3 作用域对生命周期的影响单例singleton的Bean在容器启动时就完成全部生命周期流程只实例化一次原型prototype的Bean每次获取都会重新走一遍实例化和初始化流程而且Spring不会替原型Bean调用销毁方法容器关闭时不负责管理它。因为设计上原型对象生命周期太长、来源不可控容器选择“不管”。request、session、application这几个Web作用域在Spring MVC和Spring Boot里也有实际价值。比如request作用域的Bean每个HTTP请求一个实例适合放请求级状态数据。但我见过很多人误把Controller本身设置成原型这反而会拖慢请求处理速度完全没必要。3. 三级缓存与循环依赖为什么Spring要绕这么大一圈3.1 什么情况下产生循环依赖循环依赖就是两个或多个Bean在创建时互相引用。最典型的是A依赖BB又依赖A。如果容器不做额外处理创建A时要注入B创建B时又要注入A会形成死循环最终栈溢出。Spring解决这个问题靠的是“三级缓存”源码里对应DefaultSingletonBeanRegistry的三个MapsingletonObjects一级缓存存放完全创建好的成品单例Bean。earlySingletonObjects二级缓存存放提前暴露的原始对象引用此时属性还没填充完成。singletonFactories三级缓存存放对象工厂通常是个Lambda表达式可以在需要时生成对象的早期引用或代理对象。3.2 三级缓存每一步在做什么我画一个实际操作流程复盘创建A先从一级缓存查没有。A进入“创建中”状态把A的singletonFactory放入三级缓存。A开始填充属性发现需要B于是去缓存找B。B还不存在容器先创建B同样把B的工厂放入三级缓存。B填充属性时发现需要A此时从一级缓存找不到A从二级缓存也找不到但从三级缓存取出了A的早期引用放入二级缓存。B顺利拿到A的引用尚未初始化完毕完成创建放入一级缓存。A回头继续填充B引用B已经就绪注入成功。A完成初始化放入一级缓存。关键点在于三级缓存存放的不是对象本身而是对象工厂。为什么要多这一层因为如果A需要AOP代理getEarlyBeanReference会提前生成代理对象保证后续拿到的是同一个代理而不是原始类。如果只有一级缓存就意味着A必须在创建初期就生成完整代理注入逻辑会被迫提前很多初始化步骤就没法执行了。3.3 为什么构造器注入无法解决循环依赖既然三级缓存能解决setter注入和字段注入的循环依赖那构造器注入为什么不行原因很简单构造器注入发生在对象new出来之前此时根本没有“早期引用”可以暴露。A的构造器要传入BB的构造器要传入A两边都无法先创建对象缓存机制无从介入。所以如果项目里遇到构造器注入导致的循环依赖解决办法不是调整缓存而是重构设计——拆分类、换setter注入、用Lazy延迟获取。另外有个细节必须提醒你Lazy注解能打破循环依赖但它生成的是代理对象而不是直接引用原始Bean。如果你在事务、切面等场景里配合使用要把代理带来的序列化和类型判断问题考虑进去。4. AOP的动态代理机制JDK代理与CGLIB的取舍4.1 AOP的基本概念和解决的核心问题AOPAspect Oriented Programming面向切面编程解决的是横切关注点问题日志、鉴权、事务、性能监控这些逻辑散落在每个业务方法里如果靠手工维护代码会膨胀得很厉害而且容易漏。举个例子你在十个Service方法里都要写“开始记日志、执行、结束记日志、异常记日志”这就是横切逻辑。用AOP你只需要定义一次切面声明“在这些方法执行时自动追加日志逻辑”业务代码本身保持干净。Spring AOP里有几个术语必须一次弄清楚JoinPoint程序执行的某个位置Spring AOP中指的是方法执行时。Pointcut切点一组匹配哪些JoinPoint的表达式。Advice通知切点匹配后要执行的逻辑分前置、后置、环绕、异常、最终几种。Aspect切面Pointcut Advice 的组合成类。Weaving织入把切面代码应用到目标对象的过程。4.2 JDK动态代理与CGLIB的底层差异Spring AOP默认用哪种代理答案取决于目标类是否实现了接口。如果实现了接口默认走JDK动态代理Proxy.newProxyInstance基于接口生成代理类如果没实现接口走CGLIB通过继承目标类生成子类代理。JDK代理只能代理接口中声明的方法CGLIB可以代理类本身的所有非final方法。从Spring Boot 2.x到Spring Framework 6.xCGLIB已经成为默认策略spring.aop.proxy-target-classtrue原因一是接口代理在某些强类型转场景会踩坑二是CGLIB在现代环境下性能已经不比JDK代理差。我想给追求Code Review质量的同学一个建议事务和自定义切面被AOP代理后方法内部自调用是不生效的。比如UserService里methodA()调用了同类里的methodB()只有methodA走代理逻辑methodB是this直接调用不经过代理。解决方案是拆到不同类或者注入自身代理用Autowired注入自己会被Spring解析成代理对象。4.3 切点表达式和通知类型实战切点表达式最常用的是execution。举个例子Aspect Component public class LogAspect { Before(execution(* com.example.service.*.*(..))) public void beforeLog(JoinPoint joinPoint) { String methodName joinPoint.getSignature().getName(); System.out.println(准备执行方法: methodName); } }execution表达式的语法从前往后依次是修饰符可省略、返回类型、类路径、方法名、参数列表。*代表任意返回类型或任意类..代表任意数量参数。execution(* com.example.service.*.*(..))表示“com.example.service包下任意类的任意方法”。通知类型有五种Before方法执行前。AfterReturning方法正常返回后可以拿到返回值。AfterThrowing方法抛出异常后。After方法结束后不管正常还是异常都执行。Around最强大能完全控制方法执行、传参和返回值事务和性能监控一般都用它。Around通知方法必须返回Object而且第一个参数是ProceedingJoinPoint调用proceed()才执行目标方法。忘记调proceed()是新手最容易犯的错目标方法会被“吞掉”还不报错。5. AOP日志切面从零实现与验证全过程5.1 日志切面的分层设计思路网上很多项目直接把切面写在Controller层记录请求参数和返回值。这没问题但我更推荐按“用户操作日志”和“系统方法日志”两层设计。用户操作日志服务于运营和审计记录谁在什么时间干了什么比如下单、改价、删单系统方法日志服务于排障记录执行耗时、异常链路、入参出参。二者目标不同切点设计也不同。我写的切面会分成两层Web层记录HTTP信息的操作日志Service层记录方法级别的执行日志。下面代码示例展示Service层的方法日志切面。5.2 完整实现注解 切面 切点先自定义一个注解方便更精确地控制哪些方法要记录Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface MethodLog { String value() default ; }在Service方法上打注解Service public class OrderService { MethodLog(创建订单) public void createOrder(OrderRequest request) { ... } }定义切面类Aspect Component public class AroundLogAspect { private static final Logger log LoggerFactory.getLogger(AroundLogAspect.class); Around(annotation(methodLog)) public Object around(ProceedingJoinPoint joinPoint, MethodLog methodLog) throws Throwable { long start System.currentTimeMillis(); String method joinPoint.getSignature().getDeclaringTypeName() . joinPoint.getSignature().getName(); log.info([方法日志] 开始执行: {}描述: {}入参: {}, method, methodLog.value(), JSON.toJSONString(joinPoint.getArgs())); try { Object result joinPoint.proceed(); long cost System.currentTimeMillis() - start; log.info([方法日志] 执行完成: {}耗时: {}ms出参: {}, method, cost, JSON.toJSONString(result)); return result; } catch (Throwable throwable) { log.error([方法日志] 执行异常: {}耗时: {}ms异常信息: {}, method, System.currentTimeMillis() - start, throwable.getMessage()); throw throwable; } } }这里我做了几件“文档里不写但实际必须做的事”第一入参出参都用JSON序列化避免数组的toString()输出内存地址导致日志完全没有排查价值。第二捕获异常后必须重新抛出否则业务上层会收到“假成功”的结果。JSON工具我用了Fastjson的JSON换成Jackson也是同理。第三Around的返回值必须强转成原方法的返回类型直接return原始Object在某些代理场景下可能引发类型转换异常建议用SuppressWarnings或额外做类型校验。5.3 怎么确认AOP真的被启用了搜索词里有人问“怎么查看Spring AOP有没有启用”。我后面排查问题时会先做三步验证检查启动类或配置类上有没有EnableAspectJAutoProxySpring Boot默认自动配置了不需要重复加但如果你自定义了配置类且覆盖了它就可能失效。在切面类里看到Aspect和Component两个注解都在且切面类能被扫描到。在调用Bean的地方打印target.getClass()如果输出包含$Proxy或CGLIB说明代理已经生效。如果以上都满足但切面没执行最可能是切点表达式没匹配。不要只顾着表达式语法还要确认类路径的包名是否一致。另外一个很常见的坑是某方法被final修饰CGLIB无法继承重写它切面自然不生效。5.4 自调用失效和内部类回调的坑我把自调用场景再讲透一点。你用Annotation方式的切面给OrderService.methodB打了日志注解methodA内部调用methodB()此时代理对象调methodAmethodA里是this.methodB()this指向原始对象而不是代理所以注解日志不会打出来。解决方案有三种把methodB的逻辑拆到另一个Service里由外部调外部。用ApplicationContext.getBean(OrderService.class)注入代理对象。用AopContext.currentProxy()获取当前代理但需要配置exposeProxytrue。我个人在团队推行的是第一种因为它同时改善了单一职责和可测试性。还有一个隐藏坑是Spring AOP只对Spring容器管理的Bean生效对象如果是new出来的代理机制完全不介入。6. 事务注解的真实行为AOP在事务中的体现6.1Transactional为什么属于AOP很多人只知道Transactional能管事务不知道它底层是AOP。Spring事务管理也是通过切面实现核心是TransactionInterceptor它在事务通知里开启、提交、回滚事务。为什么回滚默认机制是方法抛出RuntimeException或Error时回滚受检异常不会触发回滚。这是我踩过最多次的坑很多开发者在Service里捕获了异常并返回Result包装对象事务看起来没生效其实是异常被吞了事务拦截器压根没看到异常。6.2 事务失效的几种常见形态不夸张地说我在Code Review里至少见过六种Transactional失效方法被private修饰AOP代理无法拦截。方法自调用代理没有进入。异常被catch后吞掉事务拦截器感知不到。抛出的是受检异常且没有在rollbackFor里指定。类没有被Spring管理。数据库引擎本身不支持事务比如MySQL用MyISAM。如果你的事务“莫名其妙”没生效按顺序检查上面六条90%的问题能定位。6.3 隔离级别和传播行为的选型判断Transactional里比较重要的两个属性是isolation和propagation。isolation控制事务的隔离级别。READ_COMMITTED能防止脏读多数业务用这个就够REPEATABLE_READ能防止不可重复读MySQL默认就是它。像SERIALIZABLE这种最高级别虽然安全但并发性能下降严重不适合常规OLTP。propagation控制事务传播行为。REQUIRED是默认值如果当前有事务就加入没有就新建。REQUIRES_NEW会挂起当前事务并新开一个适合“独立提交日志”“异步任务不参与主事务回滚”的场景。我在分布式事务设计里常用REQUIRES_NEW来隔离审计日志和外部接口调用这样主业务失败回滚时日志或消息还是能落库。7. Bean装配与依赖注入的决策指南7.1Autowired和Resource怎么选很多项目里这两个注解混着用实际上是两种注入思路Autowired按类型注入配合Qualifier可按名称Resource默认按名称注入名称找不到时再按类型这是Java EE标准注解。我的使用习惯是强依赖和唯一类型用Autowired需要兼容JSR-250规范的代码或想显式声明Bean名称时用Resource。一个团队里最好统一别一个类里俩注解交替。7.2 多个实现类的装配策略一个接口有多个实现类时容器没法自动判断注入谁。三种常见解法配合Primary指定默认候选。用Qualifier(beanName)指定具体名。用ListInterface一次性注入全部实现配合策略模式使用。第三种是我在路由、状态机、策略场景里用得最多的它能很好地避免大量if-else利用Spring的集合注入自动把实现全部收集进来再根据业务条件挑选。7.3 条件装配Conditional系列注解的实际价值Spring Boot里ConditionalOnProperty、ConditionalOnClass、ConditionalOnMissingBean属于条件装配。它们解决的问题是同一份代码在不同环境下行为不一致时通过配置开关动态决定加载哪些Bean。比如某功能依赖Redis但本地开发想用本地缓存降级就可以写一个ConditionalOnProperty(name cache.type, havingValue redis)让Redis实现只在指定配置下加载否则加载本地实现。组件里慎用ConditionalOnMissingBean因为它的判断发生在容器内部扫描阶段多个Configuration类之间的加载顺序会影响判断结果。8. 从使用走向解读源码的学习路径建议8.1 先抓两条主线很多后端同学背了一堆Spring概念还是觉得心里发虚是因为没找到阅读源码的切入口。我建议抓两条主线一条是 Bean 的加载链路另一条是 AOP 的代理创建链路。Bean 加载链路的核心入口是AbstractApplicationContext.refresh()沿着finishBeanFactoryInitialization进到DefaultListableBeanFactory.preInstantiateSingletons再进到AbstractAutowireCapableBeanFactory.createBean。这条链路能回答绝大多数“Bean什么时候创建”“属性什么时候填充”的问题。AOP 链路的核心入口是AnnotationAwareAspectJAutoProxyCreator它是BeanPostProcessor的实现在postProcessAfterInitialization阶段判断目标Bean是否匹配切点匹配就创建代理对象。DefaultAopProxyFactory里能看到选择JDK代理还是CGLIB的逻辑目标类有接口且proxyTargetClassfalse就走JDK否则走CGLIB。8.2 手写迷你Spring的练习价值热搜词里有“手写spring”我非常建议你拿一个周末做一遍。不用写全只需要实现一个简单的容器Map存Bean实例、扫描注解、构造器注入、setter注入、简单的AOP切面用JDK动态代理做方法前后拦截。这个过程会强迫你把反射、代理、注解解析这些底层基础全部串起来是“自测是否真懂Spring”的试金石。8.3 观看视频课程时要做的笔记策略看Spring视频不要只是跟着敲。我自己的习惯是每看一个核心知识点就写下三个问题——它解决什么问题、内部关键类叫什么、面试官会从哪个角度追问。比如看完Bean生命周期你应该能回答“BeanPostProcessor和InstantiationAwareBeanPostProcessor的区别”而不是只能说出一串回调顺序。从整条脉络来看IoC和AOP从来不是两个孤立的概念它们都围绕一个中心让容器管理对象的生命周期和行为增强。理解了这条主线Spring Boot的自动装配、Spring Cloud的动态代理本质上都只是这个核心机制的扩展应用。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进