ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring核心原理一次讲透:IOC、三级缓存、AOP与请求链路

Spring核心原理一次讲透:IOC、三级缓存、AOP与请求链路 Spring这个框架在国内Java生态里的地位不需要我再强调。可越是天天用越容易忽略它底层到底干了什么。我前几天扫到一组热词spring三级缓存原理手写springspring aop实现日志记录spring boot对外接口放哪里——很明显一拨人在补原理准备面试另一拨人在真实业务里踩了坑到处找答案。这篇总结上就把Spring最核心的地基一次讲透IOC容器的设计逻辑、Bean生命周期与三级缓存、AOP的代理机制、MVC到Boot的请求链路。这些东西是Spring Boot、Spring Cloud、Spring AI这些上层框架共同的地板地板稳了后面看什么都顺。1. 根问题IOC容器解决的是对象之间怎么协作这个老难题1.1 没容器之前代码里的依赖是怎么失控的写Java的人多少都经历过这种状态一个Service里new了另一个Service那个Service又new了DAODAO里再new一个DataSource。看起来每个类都能跑但改起来就难受了——想换一个实现得把所有new过它的地方全翻出来改想写单元测试还得想办法把真实依赖替换成Mock对象如果构造函数里一堆硬编码new这个替换过程简直是一场灾难。日子久了类与类之间的耦合就像春节返程的高速收费站全都堵在硬编码依赖这个口子上。可能有年轻同事会说这不就是设计模式里的工厂加单例吗我自己也能写。确实可以但问题在于当对象数量从十个涨到一百个、一千个对象之间的依赖关系从直线变成网状手写管理代码本身就是一笔负债。谁负责创建谁负责销毁单例还是原型什么时机初始化这些问题如果都靠业务代码自己回答业务代码就永远在回答这些和业务无关的问题。越写越臃肿越写越难测这才是IOC容器出现的真实背景而不是什么高深的理论。1.2 控制反转和依赖注入说的其实是同一件事Spring的核心解决思路就两个字反转。以前对象自己要去找依赖现在改成容器统一管理所有Bean谁需要什么依赖启动时容器按声明喂给谁。依赖注入DI就是实现控制反转的具体手段控制权从调用方手里交到了容器手里。这听起来轻巧实际上是把对象怎么创建、怎么装配这件事从业务代码里彻底抽离了。打个比方以前去餐厅吃饭你得自己进后厨做菜、端菜、收拾桌子现在你坐下点单后厨容器把菜按顺序做好端到你面前。你只管吃后厨管做。谁做、怎么做、是不是同一个厨师都跟你无关。Spring里的BeanFactory就是容器最核心的接口而ApplicationContext在它基础上加了事件发布、国际化、环境变量解析这些能力。日常开发里我们接触的基本都是ApplicationContext体系但面试时如果有人问BeanFactory和ApplicationContext什么关系本质就是问容器能力的边界。1.3 三种注入方式怎么选优先级和代价都要看清先看字段注入就是最常用的Autowired打在属性上。写起来最短但有个问题依赖被藏起来了看一个类根本不知道它需要什么测试时想换依赖得靠反射或者Spring的测试框架强行注入final字段也没法用。Setter注入灵活一点可以在运行期替换依赖适合可选依赖的场景。构造器注入是我最推荐的方式依赖以构造参数的形式显式声明对象创建的那一刻依赖就齐了还能配合final保证不可变测试时只需要new出来传参根本不需要启动容器。这里有个现实矛盾很多人用IDEA社区版开发Spring Boot项目新建工程没有Spring Initializr插件手写配置时图省事就全部Autowired。方便是方便但项目变大之后想搞清楚某个Bean到底依赖了什么都得去翻源码。网上流行的那套只要能用就行的说法在小项目里没毛病到了多人协作、模块拆分的阶段就是给自己埋雷。1.4 多个同类型Bean怎么选Primary与Qualifier的兜底逻辑注入报错里最经典的就是NoUniqueBeanDefinitionException接口有两个实现类Spring不知道给你哪个。很多人第一反应是加Qualifier(xxx)指定名字这能解决问题但有个更优雅的做法在默认的那个实现上标Primary表示没指定的时候就用我。Qualifier适合的是调用方明确要指定具体实现的场景Primary适合的是绝大多数情况用默认实现个别情况再切换的场景。实际项目里我见过最乱的一种写法到处贴Qualifier换一个实现类要改十几处调用。后来统一改成主实现标Primary只有特殊调用点用Qualifier代码清爽多了。这个细节也属于典型的面试题背后有真实业务价值不懂容器装配接口多实现时一定栽。2. Bean的一辈子生命周期里藏着的三级缓存玄机2.1 一个Bean从出生到销毁要经历的六个关键节点写Spring到现在经常有同事问我Bean到底什么时候初始化。这个问题啃一遍生命周期就全清楚了。Spring管理一个Bean大致走六个节点阶段做什么典型扩展点1. 实例化调用构造器创建原始对象依赖还没注入构造器2. 属性填充把属性、依赖通过字段或Setter塞进去Autowired、Value3. Aware回调让Bean拿到容器能力BeanNameAware、ApplicationContextAware4. 初始化前BeanPostProcessor的前置拦截postProcessBeforeInitialization5. 初始化执行自定义初始化逻辑PostConstruct、InitializingBean、init-method6. 初始化后BeanPostProcessor的后置拦截AOP代理基本在这生成postProcessAfterInitialization销毁阶段对应PreDestroy和DisposableBean。理解了这条线你就知道为什么PostConstruct里可以安全使用注入的依赖而构造器里不行——构造器执行的时候属性根本还没填充。很多诡异问题比如明明注入了为什么调用时是null八成就是有人在构造器里提前使用了依赖。2.2 循环依赖的现场还原报错长什么样、路径是哪条三级缓存能上热词榜是因为循环依赖这坑在真实项目里太容易踩。最常见场景是A依赖BB又依赖ASpring启动时直接抛BeanCurrentlyInCreationException。还原现场创建A时先实例化出原始A对象然后填充属性发现需要B于是转去创建B创建B实例化后填充属性发现需要A而A还在创建中。如果没有特殊机制这里就死锁了B等A完成A等B完成。Spring的处理方式叫提前暴露A在创建过程中先把一个ObjectFactory放进缓存B通过这个工厂拿到A的早期引用先填上等A彻底完成后最终引用和早期引用是同一个对象。如果是普通Bean、没被代理它们就是同一个实例如果被AOP代理了情况稍微复杂一点下一节说。2.3 为什么必须三级缓存二级到底差在哪一级缓存singletonObjects存的是创建完成的成品Bean。循环依赖发生时B需要的是还没完成的A所以必须有地方放早期对象。二级缓存earlySingletonObjects就是干这个的那为什么还要三级缓存singletonFactories关键在AOP。假设A需要AOP代理代理对象应该在Bean后置处理那一步生成。如果只有二级缓存B在填充依赖时直接把早期对象放进二级缓存B拿到的就是原始A而A最终在后置处理时被替换成代理对象结果就是B手里拿着原始A容器最终存的是代理A不一致了。三级缓存的妙处在于二级缓存存的是ObjectFactory而不是对象。每个依赖A的Bean在真正需要时才通过工厂决定给代理还是给原始对象。没人循环依赖它的时候工厂甚至可以不调用避免提前创建代理。这就是为什么三级缓存比二级缓存多绕一道——它是给AOP留了一个按需决策的钩子不是画蛇添足。2.4 构造器循环依赖为什么救不了三级缓存能解决的是Setter/字段注入的循环依赖因为它俩允许先有对象、后填属性。构造器注入不一样对象还没出生就需要依赖而依赖又需要它谁都没有早期引用可用Spring直接放弃报错。这也是构造器注入更严谨的代价。真遇到构造器循环依赖常规解法有三个改成Setter注入、给其中某个依赖加Lazy延迟代理、或者重新审视设计。大部分循环依赖其实是设计信号说明职责边界画得有问题。我处理过好几个循环依赖案例最后发现都是A服务和B服务各管了一半本该属于C的职责拆出来一个C之后A、B都干净了。2.5 手写迷你Spring三级缓存只占容器的百分之一很多人搜手写spring其实不是真要造轮子是想验证自己懂没懂。真要动手路线很清晰写一个Component扫描器解析包路径收集Class维护一张BeanDefinition的Map创建Bean时先反射实例化再填充属性遇到引用就递归创建最后把对象放入单例缓存仿照后置处理器留一个扩展点。把这三步跑通你对IOC会有完全不一样的感觉——你会发现三级缓存只是创建过程遇到循环引用时的兜底管道整个容器的骨架反而是那张BeanDefinition注册表。3. AOP的代理真相从动态代理到Transactional失效3.1 动态代理的两条路线别被默认两个字骗了AOP的底层是代理模式两条主流路线JDK动态代理要求目标类实现接口运行时生成接口的代理类CGLIB直接生成目标类的子类。早年Spring的默认行为是有接口用JDK没接口用CGLIB。但Spring Boot 2.x起默认把proxy-target-class设为true基本都走CGLIB了。很多老面试题说Spring AOP默认JDK代理严格说已经不是当前Boot时代的默认行为回答时最好分版本讲这本身就是加分项。CGLIB通过继承实现代理所以final类、final方法无法被代理private方法也无法被拦截——代理类都生成不了自然拦不到。这解释了一个经典现象你写了一个private方法上面标了Transactional事务完全不生效。方法都不可见代理从何谈起。3.2 自调用失效同类的this调用走的不是代理这是生产环境翻车率最高的一类问题。你在TestService里写了methodAmethodA里调了同类里的methodBmethodB标了Transactional结果事务没生效。原因很简单容器里的TestService其实是代理后的对象外部调用methodA走的是代理事务逻辑被拦截到但methodA内部调methodB用的是this.xxx()this是真实对象而不是代理Transactional根本没机会被拦截。解法有几种把methodB挪到另一个Bean里再注入在methodA里通过ApplicationContext.getBean重新拿代理对象或者使用AopContext.currentProxy()配合EnableAspectJAutoProxy(exposeProxy true)。最干净的做法还是拆Bean。这个坑也解释了为什么我建议事务要么写在调用链最外层要么就写在独立的Service上——层数越深自调用风险越大。3.3 一个可以直接抄的AOP日志记录实现spring aop实现日志记录也是高频词。这个需求我基本每个项目都写过最省心的做法是定义一个切面类用Around统一记录方法入参、返回值、执行耗时和异常信息。贴一个简化版本Aspect Component Order(1) public class OperationLogAspect { private static final Logger log LoggerFactory.getLogger(OperationLogAspect.class); Around(execution(* com.example.service..*.*(..))) public Object logAround(ProceedingJoinPoint pjp) throws Throwable { String method pjp.getSignature().getDeclaringTypeName() . pjp.getSignature().getName(); long start System.currentTimeMillis(); try { Object result pjp.proceed(); long cost System.currentTimeMillis() - start; log.info(method{}, cost{}ms, args{}, method, cost, Arrays.toString(pjp.getArgs())); return result; } catch (Throwable t) { long cost System.currentTimeMillis() - start; log.error(method{}, cost{}ms, error{}, method, cost, t.getMessage(), t); throw t; } } }要注意几个细节pjp.getArgs()拿到的参数数组如果对象很大日志会变成灾难建议做截断或只打关键字段异常一定要继续往上抛别在切面里吞掉耗时统计放在try里还是外面取决于你是否想把异常路径也算进去。还有Aspect类本身不会被AOP代理但它内部用的Bean可以有依赖注入这点可以放心。3.4 切点表达式与多切面顺序写对了是日志写错了是事故切点表达式是AOP最容易翻车的地方。execution(* com.example.service..*.*(..))表示匹配service包及子包下所有类的所有方法。注意两个点..*表示包层级匹配所有子包(..)是任意参数。如果你写漏一个点切面要么完全不生效要么把Controller层也拦了。常见写法匹配范围execution(* com.example.service..*.*(..))service包及子包所有方法execution(public * com.example.*(..))指定包的public方法annotation(com.example.OperationLog)带指定注解的方法within(com.example.controller..*)controller子包内的所有方法检查切面有没有生效也有个土办法在切面方法第一行打日志启动后随便调一个目标方法看日志有没有出来。比对着源码猜快得多。多个切面同时命中时执行顺序由Order控制或实现Ordered接口数字越小越先执行。顺序影响的不只是日志谁先打印还会影响事务切面和缓存切面的嵌套关系——事务在上、缓存在下如果缓存结果先落事务回滚时缓存里可能留着一次没生效的假成功数据。这种隐性Bug排查起来极度耗时我设计多切面时一定会在文档里画一张顺序表确认每个切面的职责边界。4. 从MVC到Boot一个HTTP请求在Spring里走过的路4.1 DispatcherServlet是怎么把请求分发给Controller的Spring MVC的核心是DispatcherServlet这个前端控制器。一个HTTP请求进来先经过一堆Filter再被DispatcherServlet接管它用HandlerMapping找到匹配的Handler就是Controller方法然后通过HandlerAdapter执行执行完返回ModelAndView再交给ViewResolver解析视图。RestController相对简单直接返回JSON由HttpMessageConverter完成序列化。这个链路听起来抽象但实际排查接口超时、参数绑定失败、响应格式不对时脑子里必须有一条明确的路线图请求先过过滤器链再到DispatcherServlet再到Controller。很多为什么我的过滤器没生效为什么统一异常处理没拦住的问题卡的都在这条链路的某个环节。比如ControllerAdvice全局异常只能处理Controller层之后的异常Filter里的异常它管不着。有个知识点适配很多面试场景Filter和HandlerInterceptor的区别。Filter是Servlet标准里的东西能拦所有请求包括静态资源Interceptor是Spring MVC内的概念只能拦进入DispatcherServlet的请求。权限校验适合做在Interceptor跨域、字符集这些适合做在Filter。理解这一层很多拦截需求就不会不知道该放哪。4.2 Boot自动配置省掉的工序到底省在哪Spring Boot做的事情本质上是约定优于配置加自动配置。没有Boot的年代建Spring MVC项目要先配web.xml、配DispatcherServlet、配视图解析器、配数据源现在一个spring-boot-starter-web依赖加一个SpringBootApplication就全有了。自动配置靠AutoConfiguration.imports那堆配置类配合条件注解ConditionalOnClass、ConditionalOnMissingBean按需生效。正因为这样Boot项目跑起来快但要理解为什么会这样还得回到IOC那一层自动配置本质上就是一堆有条件的Bean定义条件满足了就加载你自定义了同类型Bean它就退让。把前面的容器原理吃透看自动配置源码就跟看配置清单一样轻松。这也是为什么面试问Boot最后都会绕到IOC原理上——上层再便利底层逻辑没变。4.3 给第三方开放的接口到底放哪里热搜里有个问题特别实际spring boot对外提供的接口(给第三方)应该放在哪里是单独的服务还是放在对应的模块我的实践经验是分情况但整体倾向单独入口加隔离控制。如果只是偶尔给某个合作方开几个接口放在原服务的独立Controller包、用独立URL前缀比如/api/open就够了配合签名校验和IP白名单成本最低。如果接口要给多个外部方用、来源复杂、有鉴权/限流/计费需求那强烈建议单独拆一个网关服务或开放接口服务。原因有三一是第三方调用方不会按你的内部规范来独立治理不污染内部接口二是安全策略签名、验签、防重放、限流可以统一集中在入口层三是版本演进方便对外接口稳定性要求极高内部可以随便改外部不能每周变。我在电商类项目里见过最惨的案例第三方对接接口直接写在内部OrderController里后来内部重构改了方法签名外部对接方集体报错连夜加班回滚。从那以后我的原则就是对外接口要么独立服务要么独立模块、独立包名、独立前缀绝不允许和内部接口混在同一个业务控制器里。教学项目里也一样哪怕是一个仿天猫购物系统或者就业推荐系统把对外行为和内部接口分开设计后期能省太多事。5. 长期用Spring攒下来的避坑清单5.1 单例Bean的线程安全是新一代开发最容易忽略的Spring默认Bean是单例一个Bean实例被所有线程共享。如果你在Bean里搞了一个有状态的成员变量——比如用HashMap当缓存不加锁、把SimpleDateFormat当公用工具被多线程调用——高并发下问题立刻出来。老手一般把无状态作为设计底线有状态的要么放方法内、要么用ThreadLocal、要么交给外部存储。这也是为什么Controller和Service里尽量不定义可变的成员变量成员变量一多这Bean基本就和线程安全说再见了。5.2 自定义校验别全堆在注解里搜索词里还有spring自定义validate说明很多人的参数校验已经碰到注解搞不定的场景。实体的NotNull、Size、Pattern能解决简单非空和格式问题但业务里总会有手机号格式对不对这个状态能否转换两个字段是否互相矛盾这类跨字段规则纯标准注解写不出来。我的组合做法是简单非空、长度类用标准注解RequestBody参数前加Validated或Valid触发校验跨字段复杂规则写一个自定义校验注解加一个ConstraintValidator实现或者干脆在Service层做独立校验方法。校验失败统一在RestControllerAdvice里接住MethodArgumentNotValidException返回统一错误结构别让框架默认报错直接漏给前端。B站上那些Spring实践视频很多也是这个套路但自己写一遍才知道全局异常处理那几步才是最容易被忽略的。5.3 几个立刻能用的运维小习惯spring boot实现监控都有哪些需求和功能这词条也很接地气。基础方案就是Actuator加Spring Boot Admin先打开health和metrics端点接一个可视化面板看内存、线程、请求耗时。端口配置、上下文路径这些小事也值得规范server.port在application.yml里配好别指望每个环境都改代码。Spring Boot 3.x上要注意Actuator端点暴露策略默认只暴露health其他端点要显式management.endpoints.web.exposure.include打开网上很多教程没提这个照着写会发现指标端点全访问不了。5.4 后续的选题以及我为什么这么排Spring Security、Spring Cloud、Spring AI这些这次先不展开——它们全部都建立在这几块地基上。Security本质是过滤器链加AOP组合Cloud本质是一堆Boot Starter的模块化组合Spring AI比如连接百炼的qwen3.7模型也只是把spring-ai的自动配置引进来Bean的装配逻辑还是老一套。我个人的习惯是先把容器和AOP吃透再往上冲所以这篇总结上聊完IOC、生命周期、三级缓存、AOP、MVC链路和生产避坑下一篇再来聊自动配置源码、Security过滤器链时序、Cloud微服务治理和AI应用接入。写这个系列的过程对我自己也是一次系统复盘顺便把踩过的坑整理成清单后面带团队的时候直接当内部培训材料用。
返回列表