资讯详情

资讯详情

Spring核心机制与实战:IoC、Bean生命周期、AOP全解析

Spring框架在Java后端开发里的地位就像地基之于高楼。不管你是刚接触SpringBoot快速搭建过几个接口还是啃过SSH老项目的代码只要写Java就绕不开Spring的IoC容器、Bean机制和AOP这些核心概念。很多新手学Spring跟着教程能跑通Demo但一旦离开脚手架面对配置失效、Bean注入失败、事务不生效这类问题就手足无措。这篇就当一次把基础掰开揉碎的经验复盘把我实际开发中踩过的坑、理清的原理、总结的判断思路都过一遍适合正在学框架的初学者也适合基础不牢、想回头补一补的开发者。1. 先搞清楚Spring最核心的IoC与依赖注入1.1 控制反转到底反在哪我在带新人的时候最爱问一个问题你已经写了这么久new对象有没有想过对象之间的依赖关系能不能交出去统一管理在没有Spring的年代——或者说你自己纯写代码不引框架的时候Service要用Dao就得自己new一个Dao实现类。看似没毛病但问题立刻冒出来如果Dao有多个实现比如一个走MySQL、一个走Redis缓存、一个走内存mock你在Service里硬编码new哪个实现就意味着想换实现必须改Service代码、重新编译、重新部署。对象之间的耦合被写死在了代码里。Spring的IoC控制反转做的事情就是把创建谁、怎么创建、创建出来给谁用的决定权从具体的业务类手里反转交到一个统一容器手里。你的Service不再自己new Dao而是声明我需要一个Dao容器在合适的时机把合适的Dao实例给到Service。这个反转的本质就是依赖关系不再由使用者创建而是由容器注入。这个设计带来两个最直接的收益一是代码解耦依赖变化不用改业务代码二是对象生命周期统一管理单例还是原型、懒加载还是立即创建都通过配置说明不散落在业务代码里。1.2 依赖注入的三种方式与我的实践建议Spring支持三种依赖注入字段注入、构造器注入、Setter注入。// 字段注入最简洁但我不推荐在核心业务代码里用 Service public class UserService { Autowired private UserDao userDao; }字段注入代码最短用起来也确实爽但它有一个隐患当你把对象new出来做单元测试时这个userDao字段是空的你没法在测试里方便地替换它。而且字段注入把依赖关系藏在了字段里类的依赖不太直观。构造器注入是Spring官方文档推荐的方式Service public class UserService { private final UserDao userDao; // Spring 4.3之后单构造器可以省略Autowired public UserService(UserDao userDao) { this.userDao userDao; } }用final配合构造器好处是依赖不可变、对象一旦创建依赖就齐全不可能出现创建了个残缺对象的情况。缺点就是构造函数参数多了以后代码会有点啰嗦但这个啰嗦其实是在帮你暴露问题——如果一个类依赖四五个东西你就要反思它是不是做太多事了。Setter注入现在用得很少它适合那种可选依赖的场景但实际上选填依赖用Autowired(required false)也能做。我对新人的建议是核心业务组件用构造器注入测试代码或特殊扩展点可以用字段注入。团队如果统一规范那就跟规范走。我自己曾经在一个老项目里全员字段注入后来切到构造器注入时发现以前根本注意不到的环状依赖在构造器注入下启动就报错反而逼着我们重构掉了好几个设计问题。1.3 接口编程一个练手小场景写Spring代码Service接口Impl实现是经典结构。很多新手觉得麻烦明明只有一个实现为什么要多写一个接口我拿一个实际场景解释。项目里一开始用户登录用的是用户名密码后来要接第三方扫码登录。如果你Service层写的是接口A用户模块依赖的是接口那么加一个第三方登录的Impl就能接上。如果全是实现类直接互相引用你就得去改别人的代码。接口在这里是一层契约契约不变实现随便换。而且Spring的代理机制事务、AOP、切面对JDK动态代理是基于接口的。如果你没有接口Spring只能退而求其次用CGLIB做子类代理。我一直建议对外暴露的服务尽量设计接口这不仅是为Spring更是为自己以后软件演进留余地。2. Bean生命周期深度拆解从定义到销毁2.1 作用域选择单例和原型真的理解了吗Spring的Bean默认是单例这一个容器里同一个Bean定义只会有一个实例所有依赖它的人拿到的是同一个。这个设计大幅节省了对象创建开销而且Spring容器本身是线程安全的但Bean实例不一定线程安全——你如果在单例Bean里放了可变状态比如一个HashMap缓存所有用户数据那多线程并发访问时就要特别注意同步问题。原型作用域每次获取都会创建新实例适合有状态对象。但有代价容器不再负责原型Bean的完整生命周期管理销毁回调不保证执行。选择作用域时我的建议一句话总结无状态服务用单例有状态且必须每次新建用原型但优先重新设计——尽量让Bean无状态化把状态放到数据库、Redis或者方法参数里。2.2 初始化和销毁的实际应用我实际用过最多的地方连接资源初始化、线程池创建、缓存预热。Component public class MyCacheManager { private MapString, Object cache new ConcurrentHashMap(); PostConstruct public void init() { // 启动时预加载热点数据 cache.put(hotKey, loadFromDb(hotKey)); System.out.println(缓存预热完成); } PreDestroy public void cleanUp() { // 关闭前清理资源 cache.clear(); } }这里有个常见的坑PostConstruct是在依赖注入完成之后、Bean初始化阶段执行的。它执行的时候字段注入的依赖已经就绪但你如果在一个Config类的Bean方法里自己new对象再调init方法时序就不对。记住一个原则Bean内部的初始化逻辑让Spring容器来触发不要自己在构造器里做复杂初始化。构造器里做太多事情会导致代理创建、依赖注入都变得很别扭。2.3 循环依赖看起来绕但能解决A依赖BB依赖ASpring容器默认单例模式下可以解决字段注入的循环依赖核心机制是三级缓存。第一级是成品Bean缓存第二级是早期暴露的Bean缓存第三级是ObjectFactory工厂缓存。创建A时A还在实例化阶段就被暴露到三级缓存此时A依赖B去创建BB依赖A时能从缓存里拿到A的早期引用完成B的创建后再回填给A。注意几个关键限制构造器注入的循环依赖无法解决因为构造阶段Bean还没实例化完没法暴露早期引用Async等需要代理的情况循环依赖可能因为代理对象创建顺序出问题原型作用域根本不缓存所以循环依赖直接报错遇到循环依赖我的第一反应不是想着怎么把它配置成能跑通而是去审视设计A和B到底可不可以拆开可不可以把公共依赖抽到C循环依赖在职责划分上往往意味着两个类耦合过深。3. AOP切面不只是加日志3.1 动态代理的两套底层机制AOP的核心是代理。Spring里有两套代理JDK动态代理和CGLIB代理。JDK动态代理要求目标类实现接口代理类也实现同一组接口通过InvocationHandler拦截方法调用。CGLIB则是通过字节码生成目标类的子类来代理。SpringBoot 2.x之后默认优先用CGLIB也就是说即使你有接口也没强制用JDK代理了。这两套机制带来的实际差异类被final修饰的方法无法被CGLIB代理无法重写JDK代理只能拦截接口方法实现类里自己新增的非接口方法不会被代理CGLIB生成的代理类比JDK代理在创建时稍重但调用时性能通常更好实际开发里最常见的翻车点是一个方法通过this调用同类方法切面不生效。因为代理只对从外部进入的调用生效你内部this.yyy()调用的还是原始对象的方法代理根本没有参与。3.2 日志切面、性能监控实战代码我会写一个轻量版的操作日志切面顺便演示环绕通知的写法Aspect Component public class OperationLogAspect { Around(annotation(operationLog)) public Object recordLog(ProceedingJoinPoint pjp, OperationLog operationLog) throws Throwable { String methodName pjp.getSignature().getName(); long start System.currentTimeMillis(); Object result; try { result pjp.proceed(); long cost System.currentTimeMillis() - start; System.out.println(方法 methodName 执行成功耗时(ms): cost); return result; } catch (Throwable e) { long cost System.currentTimeMillis() - start; System.out.println(方法 methodName 异常: e.getMessage() 耗时(ms): cost); throw e; } } }注解定义Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface OperationLog { String value() default ; }用的时候直接在方法上标OperationLog(用户注册)切面就能自动帮你记录耗时和异常信息。这套东西看起来简单但有几个细节值得注意注解要配Retention(RetentionPolicy.RUNTIME)否则运行期反射读不到切面类要交给Spring管理加Component如果切面里自己catch了异常记得要重新throw否则事务感知不到异常会照常提交这是隐蔽的数据一致性Bug3.3 事务管理本质就是用AOP实现的Transactional是Spring对事务的封装本质上就是一个事务切面。它通过代理拦截方法进入时开启事务方法正常返回时提交事务抛异常时回滚。正因为是代理机制事务不生效的经典场景全都能用代理原理解释同类内部调用this.method()不经过代理事务失效非public方法Spring AOP默认不拦截事务注解无效方法里catch了异常没抛出去事务看你正常返回了就提交了抛的不是RuntimeException比如受检异常默认不回滚除非rollbackFor指定我在项目里遇到过一次很隐蔽的问题一个导入Excel的方法循环处理每条数据时把异常catch掉继续下一行最后代码看起来处理完成但数据库里只有一部分数据。查了半天发现全部数据都该在一个事务里一旦中间有异常就整体回滚结果catch把异常吞了事务根本无法判断失败。后来改了策略要么收集异常统一抛要么用REQUIRES_NEW按行拆分独立事务并记录失败行号。4. 注解驱动的配置体系省略掉的是那些样板代码4.1 Component家族与组件扫描SpringBoot时代XML配置基本退场剩下的是注解体系。Controller、Service、Repository、Component后面三个其实都是Component的派生注解作用上等价语义上有区别。组件扫描的路径要注意SpringBoot的启动类默认扫描它所在包及子包。很多人把额外配置类、工具类放在启动类平级包之外就会遇到我的Bean怎么null的经典问题。我调过最离奇的一个Bug公司内部分模块化开发A模块启动类在com.xxx.app包里B模块的Service类在com.xxx.biz包里A启动时扫描不到B模块的Bean但B模块也没法自己启动整个服务一直报找不到Bean。查到最后是包路径设计不合理两个模块的包根不在同一个父包下。这种问题把公共包统一归纳到同一个根包下就能预防。4.2 Configuration与Bean的细节区别Configuration类里的Bean方法默认是Full模式用CGLIB增强保证Bean方法返回的单例被代理接管多次调用拿到同一实例。而如果误写成Component搭配Bean就成了Lite模式Bean方法变成普通方法每次调用会新建对象单例就失效了。我在老项目里看到过把Configuration写错成Component导致缓存初始化被调了好几次的案例。因为那个Bean方法每次调用都会new一个资源对象没有复用系统启动时资源泄漏、重复连接的问题接踵而至。排查方法也不难启动日志打上hashCode或者临时在Bean方法里打印一个计数器。开发建议配置类请用Configuration标注这是语义和机制的双重保障。4.3 条件化装配一个开关控制多实现切换条件化装配也是我从实际需求里体会到价值的。做支付对接的时候项目要支持微信、支付宝、银联三种渠道每种渠道都有独立对接类但运行环境只需要激活其中一种。public interface PayService { void pay(PayRequest request); } Service ConditionalOnProperty(name pay.channel, havingValue wechat) public class WechatPayService implements PayService { // 微信支付实现 } Service ConditionalOnProperty(name pay.channel, havingValue alipay) public class AlipayPayService implements PayService { // 支付宝实现 }在application.yml里配pay.channelalipaySpring就只装配支付宝实现类。这个模式在多种短信渠道、多种消息队列实现、多套存储方案切换时都非常好用。它的原理是Condition接口符合条件就注册Bean不符合就跳过。如果你自己公司里有很多类似根据配置切换实现的需求这套思路最优雅不用写一坨if else判断。5. 开发中遇到的常见问题与排查思路5.1 Bean注入失败的几种原因排查注入失败报NoSuchBeanDefinitionException很多人一看到就慌。我的排查路线确认类有没有被Spring管理——看类上有没有Component、Service或者有没有通过Bean注册确认包扫描范围——类在不在启动类所在包及其子包下确认有没有被条件装配过滤掉——检查Condition相关注解和配置文件确认接口和实现类类型匹配——多实现类场景下按类型找不到可能有两个Bean都满足条件需要Qualifier指定名字实际排查中我发现大量注入失败不是代码写错而是类没被扫描到。比如有人把一些通用组件放在了依赖的jar包里却忘了在jar包的META-INF/spring配置文件里注册SpringBoot默认扫描不到jar包内部的组件。5.2 事务失效的排查清单这是一张我压箱底的问题排查表症状原因解决事务没有开启方法非public改成public或者改用AspectJ代理事务运行时抛出但数据没回滚异常被catch并吞掉至少重新抛出RuntimeException同类内部调用事务失效直接this调用绕过代理拆类注入自己代理对象或通过ApplicationContext获取代理数据库表不支持事务比如MyISAM引擎换成InnoDB引擎多线程里的子线程事务不生效事务切面在线程外绑定子线程方法独立事务或用编程式事务管理我最想强调一条事务不是灵丹妙药它的生效环境是单线程同步操作同一个数据源一旦绕开这个默认假设问题就来了。5.3 切面不执行的排查步骤先确认几个关键点按顺序排查效率最高切面类有没有加Aspect和Component切入点表达式能不能匹配到目标方法目标方法是不是在代理对象上调用的是不是通过this内部调用的增强通知本身有没有被SpringBoot自动配置关闭比如AOP依赖缺失有一个我踩了几次坑的细节SpringBoot 2.x如果没引入spring-boot-starter-aopAround这些注解虽然能写但切面完全不生效启动也不报错非常坑。新项目最好直接引入starter别想着手动拼依赖。5.4 开发实践中的几点个人体会写Spring代码几年最大的体会是框架在帮我们处理复杂性的同时也把很多过程封装得过于无感了。看起来一切靠注解就能搞定但注解背后是复杂的动态代理、容器缓存和生命周期管理。遇到诡异问题时不要急着搜一堆配置魔法先回到最原始的三个问题这个对象是谁创建的什么时候创建的创建完后有没有经过什么代理处理把这三点想明白八成以上的问题都能自己定位。而且给Bean和方法加上清晰的职责边界比掌握任何花哨特性都重要。依赖关系清晰、作用域合理、状态无共享这些设计上的克制能避免掉一多半运行时的疑难杂症。框架的特性是工具设计上的规矩才是基本功。就算今天Spring已经发展到SpringBoot、SpringCloud这些底层机制和原则依然是理解一切上层封装的地基。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →