Spring 的题都指向同一个机制
IoC、AOP、事务失效、循环依赖,看起来是四个话题,其实都在回答同一个问题:调用有没有经过代理对象。把这条线抓住,一大半问题不用背。
01先看代理在不在任何"注解不生效"的问题,第一步都是确认拿到的是代理还是原始对象。
02用可跑的例子验证循环依赖和 AOP 失效这两节用纯 Java 模拟,输出就是答案。
03对照 Nest 迁移DI、拦截器、异常过滤器一一对应,你已经会的可以直接搬过来。
1
IoC 到底解决了什么问题★
你已经会了,只是名字不一样
IoC(控制反转)就是把"谁来 new 对象"从业务代码里拿出去。你不写
new UserService(),而是声明"我需要一个 UserService",容器负责创建、装配、管理生命周期。DI(依赖注入)是它的实现手段——构造器注入、字段注入、setter 注入。- 两边是同一个模型:
@Injectable对应@Component,Nest 的 Moduleproviders对应 Spring 的@Bean或组件扫描,@Inject对应构造器注入。所以 Spring 的 IoC 你不用重新理解,只是换了注解名。 - IoC 换来的三件事:一是解耦,业务类只依赖接口不依赖实现,换实现改配置不改代码;二是可测试,单测时直接
new UserService(mockRepo),不需要起容器;三是统一管理生命周期,连接池、事务、AOP 都得有个地方挂。 - 为什么推荐构造器注入:字段注入(
@Autowired打在字段上)看着简洁,但对象可能带着 null 依赖被创建出来,问题推迟到运行时才炸。构造器注入保证"对象一旦创建就是完整可用的",而且字段可以声明成final。Spring 官方从 4.3 起,单构造器可以省略 @Autowired。 - 循环依赖是构造器注入的代价:A 的构造器要 B,B 的构造器要 A,容器没法先造任何一个,直接启动失败。而字段注入靠"先实例化再填属性"能绕过去——这也是很多人误以为"字段注入更灵活"的原因。正确做法是重构掉这个环,而不是换注入方式。
- Bean 和对象的区别:Bean 是被容器管理的对象,容器负责它的创建、依赖注入、初始化回调、代理包装、销毁回调。你自己
new出来的对象不是 Bean,所以它上面的 @Transactional、@Async 全部不生效。这是下一节和后面几节反复出现的根因。
面试怎么答IoC 是把对象的创建和装配控制权从业务代码转移到容器,DI 是实现 IoC 的手段。它解决三个具体问题:解耦,业务类只依赖接口;可测试,单测可以直接 new 传 mock;统一生命周期,事务和 AOP 这些横切逻辑需要容器在创建对象时插一脚。实践上推荐构造器注入,因为能保证对象创建出来就是完整的,字段还能声明 final,单构造器从 Spring 4.3 起可以省略 @Autowired。代价是构造器注入解决不了循环依赖,会直接启动失败,这时候应该重构代码消除环,而不是退回字段注入。要特别注意容器外的 new 出来的对象不是 Bean,注解一律不生效。
2
Bean 的一生:从实例化到销毁★
AOP 代理是在哪一步织进去的
一个 Bean 的生命周期是:实例化 → 属性填充 → Aware 回调 → BeanPostProcessor 前置 → 初始化方法 → BeanPostProcessor 后置 → 使用 → 销毁。AOP 代理发生在"后置处理"这一步,所以你在
@PostConstruct 里拿到的 this 还是原始对象,不是代理。- 三个初始化回调的顺序是固定的:
@PostConstruct→afterPropertiesSet()→initMethod。@PostConstruct由CommonAnnotationBeanPostProcessor处理,本质也是一个 BeanPostProcessor,所以它比InitializingBean更早。 - 代理在后置处理生成,这个顺序决定了两件事:一是在
@PostConstruct里调本类的@Transactional方法不生效,因为那时this还是原始对象;二是如果你implements ApplicationContextAware并缓存了 context,要注意拿到的是容器不是代理。 - 单例 Bean 在容器启动时就创建完了(默认
singleton+ 非懒加载),所以启动慢是正常的。加@Lazy可以推迟到第一次使用,但会掩盖启动期的配置错误——本来启动就失败的问题,变成上线后第一次请求才失败。 - prototype 的 Bean 容器不管销毁。容器只负责创建,销毁要调用方自己处理,而且每次
getBean都是新对象。把一个 prototype Bean 注入到 singleton 里,注入的只是那一次创建的对象,不会跟着变,这是常见误解。 - 销毁回调只对单例生效,且只在容器正常关闭时触发(
context.close()/kill -15)。kill -9不会执行,所以靠销毁回调做数据落盘是不可靠的。 - 和 Nest 对照:Nest 的生命周期钩子
onModuleInit/onApplicationBootstrap/onModuleDestroy思路一致,但没有 Spring 这么细的"后置处理"扩展点。Spring 的 BeanPostProcessor 是整个框架最重要的扩展点——AOP、@Autowired、@PostConstruct、@Async 全都是靠它实现的。
面试怎么答Bean 生命周期是实例化、属性填充、Aware 回调、BeanPostProcessor 前置处理、初始化方法、BeanPostProcessor 后置处理、使用、销毁。初始化有三个回调,顺序是 @PostConstruct、InitializingBean 的 afterPropertiesSet、initMethod。关键点是 AOP 代理在 BeanPostProcessor 的后置处理阶段生成,所以 @PostConstruct 里调用本类的 @Transactional 方法不生效,因为那时 this 还是原始对象。单例默认在启动时创建完,prototype 容器不管销毁。扩展点上,BeanPostProcessor 是 Spring 最重要的机制,Autowired、PostConstruct、AOP、Async 都建立在它之上。
3
循环依赖为什么要三级缓存★
两级也能跑,三级是为了延迟创建代理
循环依赖指 A 依赖 B、B 又依赖 A。Spring 靠提前暴露"半成品"解决:A 实例化后先把一个工厂放进三级缓存,B 需要 A 时通过工厂拿到 A 的早期引用,从而完成创建。为什么不用两级?因为工厂可以让"要不要创建代理"这个决定推迟到真正被需要时。
输出 a 开始创建
b 开始创建
a 命中三级缓存,调工厂拿到早期引用
b 完成,进入一级缓存
c 开始创建
a 命中二级缓存
c 完成,进入一级缓存
a 完成,进入一级缓存
b.a 和 a 是同一个对象: true
c.a 和 a 是同一个对象: true
a 的工厂被调用次数: 1
- 看输出的顺序就懂了:a 开始创建后立刻遇到 b,b 又要 a——这时 a 还只是个空壳(属性都没填),但它的引用已经能给出去了。B 拿着这个引用完成创建,A 再回来填自己的属性。最后
b.a == a说明两边拿到的是同一个对象。 - 工厂被调用 1 次,这是二级缓存的价值。c 也依赖 a,如果只有三级工厂,每次都要调一次工厂(真实场景里每次调都可能生成一个新代理对象,那就不是单例了)。工厂调完立刻把结果放二级,后续直接命中二级。没有二级缓存就不保证单例。
- 那为什么还需要三级,直接用二级不就行了:这是最关键的一问。如果只有二级,就意味着实例化完就必须立刻决定"要不要给它生成代理"——而这时 Spring 还没拿到最终的 AOP 通知器列表(通知器是 BeanPostProcessor 在后置处理阶段才应用的)。所以 Spring 改成存一个工厂,等真正有人要这个早期引用时,才通过
getEarlyBeanReference决定要不要提前生成代理;没被循环依赖牵连的 Bean 就永远不会提前创建代理。 - 哪些循环依赖 Spring 也解决不了:构造器注入的循环依赖(对象还没实例化,没法提前暴露引用,直接 BeanCurrentlyInCreationException);prototype 作用域的循环依赖(不做缓存);
@Async方法导致的循环依赖(异步代理的初始化时机更复杂,Boot 2.6 之后默认禁止循环依赖,会报错)。 - Boot 2.6 起默认禁止循环依赖,要开
spring.main.allow-circular-references=true。这个默认值的改变是对的——三级缓存只是让框架不崩,业务上的环本身就是设计问题,应该重构掉。
面试怎么答Spring 用三级缓存解决单例的循环依赖:一级 singletonObjects 存成品,二级 earlySingletonObjects 存提前暴露的早期引用,三级 singletonFactories 存对象工厂。流程是 A 实例化后把工厂放进三级缓存,B 需要 A 时通过工厂拿到 A 的早期引用完成创建,A 再回来填属性。二级缓存的作用是保证单例,工厂调用一次后结果放二级,后续都命中二级。三级缓存不能省的原因是它让"是否提前创建代理"这个决定推迟到真正需要时,避免所有 Bean 在实例化后就生成代理。构造器注入和 prototype 的循环依赖解决不了,Spring Boot 2.6 之后默认直接禁止循环依赖。
4
AOP 的两种代理,怎么选
有接口用 JDK,没接口用 CGLIB
Spring AOP 只支持方法级别的拦截,底层两种代理:JDK 动态代理基于接口,生成实现类;CGLIB 生成目标类的子类。Spring Boot 2.x 起默认用 CGLIB,因为大量 Service 没有接口。
- 怎么看当前用的是哪种代理:在日志里看 Bean 的类型,
$Proxy开头是 JDK 代理,类名里带$$EnhancerBySpringCGLIB$$是 CGLIB。调试 AOP 不生效时,第一步就该确认代理到底生成了没有。 - 为什么 Boot 2.x 改成默认 CGLIB:因为 JDK 代理有个很别扭的限制——注入时必须用接口类型。如果
@Autowired UserServiceImpl userService直接注入实现类,而容器里是 JDK 代理对象(它不是 UserServiceImpl 的子类),就会报类型不匹配。改成 CGLIB 之后按类注入也能用,少了一大类"为什么注入失败"的问题。 - 不同切面的执行顺序用
@Order或实现Ordered控制,值越小优先级越高、越靠外层。同一个切面里多个 @Before 的执行顺序是不确定的(按方法名排序,但不保证),需要确定顺序就拆成多个切面加 @Order。 - 切点表达式怎么写:
execution(* com.example.service..*.*(..))是最常用的。要小心..的范围,写太宽会把 Controller 也织进去;@annotation(com.example.Log)按注解匹配更精确,也更好维护。 - AspectJ 和 Spring AOP 不是一回事:Spring AOP 是运行时代理,只能拦方法,只能拦 Spring 容器里的 Bean;AspectJ 是编译期/加载期织入,能拦字段访问、构造器,但需要额外配置。面试里说"Spring AOP 基于动态代理,AspectJ 基于字节码织入"就够了,别把两者混为一谈。
面试怎么答Spring AOP 是运行时代理,两种实现:JDK 动态代理要求目标类实现接口,生成实现同一接口的代理类;CGLIB 用 ASM 生成目标类的子类,覆盖方法织入通知,所以代理不了 final 类和 final 方法。Spring Boot 2.x 默认用 CGLIB,因为很多 Service 没有接口,而 JDK 代理有个硬限制是必须按接口类型注入,按实现类注入会报类型不匹配。判断用的哪种可以看类名有没有 EnhancerBySpringCGLIB。切面顺序靠 Order 控制,值越小越外层。要注意 Spring AOP 只能拦方法、只能作用于容器管理的 Bean,和 AspectJ 的字节码织入不是一回事。
5
AOP 为什么会失效(能跑的证据)★
this 调用不走代理,一句话解释清楚
AOP 靠代理对象拦截方法调用。同类内部用
this.method() 调用时,走的是原始对象,压根没经过代理,通知自然不执行。这就是"@Transactional 加在私有方法上不生效""同类方法互相调用事务失效"的唯一根因。输出外部调用 proxy.outer():
[代理] 开启事务 -> outer
[业务] outer
[业务] inner ← 注意:这里没有打印"开启事务"
[代理] 提交事务
外部调用 proxy.inner():
[代理] 开启事务 -> inner
[业务] inner
[代理] 提交事务
- 输出把问题说清了:外部调
outer时进了代理,但outer内部调inner时,执行的是real对象上的inner,没经过代理,所以没有事务。这不是配置问题,是调用路径问题。 - 三种解法:一是自注入(把代理注入给自己,用
self.inner()调);二是AopContext.currentProxy()(需要@EnableAspectJAutoProxy(exposeProxy = true));三是把方法拆到另一个 Bean。第三种最干净,本质是把"事务边界"这件事交给调用方设计,而不是让一个类自己调自己。 - 还有几种失效不是 this 导致的:方法是
private(代理覆盖不到);方法是final或static(CGLIB 覆盖不了);类没有被 Spring 管理(自己 new 的、Util静态方法);切点表达式没匹配上(最常见但最容易被忽略,先打印一下代理类确认)。 - 怎么快速确认是哪种:在方法里打印
this.getClass().getName()。带$$EnhancerBySpringCGLIB$$或$Proxy说明拿到了代理,那问题在切点或调用路径;打印出来是原始类名,说明这个对象压根不是 Bean 或者拿到的是原始引用。 - 和 Nest 对照:Nest 的 Interceptor 和 Guard 也存在同样的问题——任何基于代理/切面的框架都逃不掉这个限制。区别是 Nest 里较少有人在 Service 内部互相调用,因为它更倾向于把逻辑放 Controller 层编排。Spring 里 Service 互相调用太自然,所以这个坑踩得更频繁。
面试怎么答AOP 生效的前提是调用经过代理对象。同类内部用 this 调用时,执行的是原始对象的方法,不经过代理,所以通知不执行,这是 @Transactional 和 @Async 在同类方法互调时失效的根本原因。因为代理对象持有的是原始对象引用,它在调用目标方法时,目标方法内部的 this 就是原始对象本身。解决办法有三种:自注入代理、用 AopContext.currentProxy()、或者把方法拆到另一个 Bean。其他失效还包括方法私有、final、static、对象不是 Spring 管理的、切点没匹配上。排查时先打印 this 的类名,确认到底拿到的是代理还是原始对象。
6
@Transactional 失效的六种场景★
不是配置抄错了,是机制决定的
事务失效不是玄学,六种场景都有明确机制:调用没经过代理、方法不能被代理、异常不满足回滚条件、事务在不同线程、数据源不支持、传播行为不对。记住机制比背清单有用。
- 第 ③ 条最隐蔽。很多人习惯在 Service 里把异常 catch 住、打日志、返回失败结果,看起来"处理得很完善",实际上事务已经提交了。判断标准很简单:异常有没有穿过代理对象的边界。没穿过去,回滚就不会发生。
- 第 ④ 条是默认值坑。
@Transactional的rollbackFor默认只有RuntimeException和Error。你抛一个Exception或者自定义的受检异常,事务照样提交。所以项目里通常统一成rollbackFor = Exception.class,或者干脆全用非受检的业务异常。 - 只读事务要显式声明:
@Transactional(readOnly = true)能让数据库走只读优化、也能让 Hibernate 跳过脏检查。但它是提示不是强制,写操作不会报错,所以别指望它做保护。 - 事务的范围要包住整个业务操作。常见错误是在一个事务方法里调用远程接口——远程调用慢,数据库连接被占着不放,连接池很快就耗尽。远程调用、发消息、发邮件都应该放到事务外面(事务提交后通过事件或 MQ 触发)。
- 大事务的其他代价:锁持有时间长、undo log 膨胀、主从延迟变大、回滚成本高。所以"一个方法一个大事务"是反模式,应该按业务边界切小。这也是为什么很多团队禁止在 Controller 上加 @Transactional——那等于把整个请求包进一个事务。
面试怎么答@Transactional 失效有六种典型场景。第一是同类 this 调用不经过代理,这是最常见的。第二是方法不是 public 或者被 final、static 修饰,代理覆盖不到。第三是异常被 catch 了没往外抛,代理感知不到,自然不回滚,这条最隐蔽。第四是抛的是受检异常,因为默认 rollbackFor 只包含 RuntimeException 和 Error,需要显式配 rollbackFor 等于 Exception。第五是事务跨线程,事务状态存在 ThreadLocal 里,子线程拿不到。第六是类没被 Spring 管理,或者是 MyISAM 这种不支持事务的引擎。判断标准始终是异常有没有穿过代理边界。
7
七种事务传播行为怎么记
只记两个常用的,其余知道存在
传播行为回答的是:一个已经有事务的方法,调用了另一个带事务的方法,第二个该怎么办。真正需要记住的只有两个:
REQUIRED(默认,有就加入、没有就新建)和 REQUIRES_NEW(不管有没有,都新建并挂起外层)。- REQUIRED 和 REQUIRES_NEW 的差别在"看到的结果"上:外层方法调内层记日志的方法,用 REQUIRED 时如果外层回滚,日志也跟着回滚——看起来很反直觉,但就是这样。要日志一定留下来,必须用 REQUIRES_NEW,代价是它需要两个数据库连接,并发高时容易把连接池占满。
- REQUIRES_NEW 的隐藏成本经常被忽略:外层事务还没提交,它持有行锁;内层用新事务去改同一行,就会自己锁自己,直接死锁或等待超时。所以 REQUIRES_NEW 里不要操作外层已经改过的数据。
- NESTED 和 REQUIRES_NEW 完全不是一回事:NESTED 是同一个物理事务里的保存点,只有一次提交;REQUIRES_NEW 是两个独立事务,两次提交。NESTED 在内层失败时可以只回滚到保存点,但外层如果最终回滚,内层的成果一起没。
- 保存点有数据库差异:MySQL InnoDB 支持 savepoint,但某些数据库或驱动对嵌套保存点支持不完整,跨库操作时尤其容易出问题。所以 NESTED 的实际使用率远低于前两个。
- MANDATORY 和 NEVER 是防御性设计:用来在代码层面强制"这个方法必须/必须在事务外被调用",把错误的调用方式变成启动期或调用期的显式异常,而不是安静地跑错。规范严格的项目里值得用。
面试怎么答传播行为解决的是事务方法嵌套调用时内层怎么办。默认的 REQUIRED 是有事务就加入、没有就新建,所以内外共用一个事务,内层异常会导致整体回滚,包括外层的写操作。REQUIRES_NEW 是挂起外层、开独立事务,适合日志这种必须留下的操作,但代价是占两个连接,而且如果操作外层已锁定的同一行会自己锁自己。NESTED 是同一物理事务里的保存点,只有一次提交,外层最终回滚的话内层也白搭。剩下四个 SUPPORTS、NOT_SUPPORTED、MANDATORY、NEVER 主要用于表达约束,实际业务里用得少。
8
一个请求在 Spring MVC 里走了哪些地方
十步链路,每步都能插东西
请求进入后:Filter → DispatcherServlet → HandlerMapping 找方法 → HandlerAdapter 调方法(期间做参数绑定、校验、拦截器前置)→ 返回值处理器选消息转换器 → 写出响应 → 拦截器后置。搞清这条链路,才知道某个功能该写在哪一层。
- 参数绑定失败会抛什么:
@RequestBody的 JSON 格式错抛HttpMessageNotReadableException;@Valid校验失败抛MethodArgumentNotValidException;类型转换失败抛MethodArgumentTypeMismatchException。这三种都要在全局异常处理里单独接,否则前端拿到的是 500 和一堆堆栈。 - @RequestBody 和 @RequestParam 的区别是数据来源:前者读请求体(JSON,只能有一个),后者读 URL 参数或表单。一个请求只能有一个 @RequestBody(请求体是一个流,读完就没了),但可以有多个 @RequestParam。
- 消息转换器是可替换的:默认 Jackson。要换 Fastjson 或者加自定义序列化(比如把
Long转成字符串避免前端精度丢失),都是往HttpMessageConverter列表里加。前端拿到的大整数 ID 精度丢失,根因就在这里。 - HandlerMapping 有三种:
RequestMappingHandlerMapping(注解,最常用)、BeanNameUrlHandlerMapping(老式)、RouterFunctionMapping(函数式路由)。启动时就会把所有映射建好,所以两个方法映射同一个 URL 会在启动时报错,不是运行时。 - 和 Nest 对照:Nest 的链路是 Middleware → Guard → Interceptor(前)→ Pipe → Controller → Interceptor(后)→ ExceptionFilter。Guard 在 Spring 里没有直接对应——Spring Security 是用 Filter 链实现的,所以鉴权通常写在 Filter 层而不是拦截器层。Pipe 对应 Spring 的参数解析器 + 校验,ExceptionFilter 对应
@ControllerAdvice。
面试怎么答请求先经过 Servlet 容器的 Filter 链,进入 DispatcherServlet 的 doDispatch,先由 HandlerMapping 按 URL 找到 HandlerMethod,再由 HandlerAdapter 负责调用,期间完成参数解析、数据校验、拦截器 preHandle,然后执行 Controller 方法,返回值由 HandlerAdapter 处理后交给 HttpMessageConverter 序列化,最后走拦截器 postHandle 和 afterCompletion,再回到 Filter 的后半段。异常由 HandlerExceptionResolver 处理,对应 @ControllerAdvice。要能说出三类参数异常的区分:请求体解析失败、校验失败、类型转换失败。对照 Nest 的话,Guard 对应 Spring Security 的 Filter,Pipe 对应参数解析加校验,ExceptionFilter 对应 ControllerAdvice。
9
Filter、Interceptor、AOP 差在哪
三层拦截,能力和时机都不同
三者的归属不同:Filter 属于 Servlet 容器,拿得到原始
ServletRequest;Interceptor 属于 Spring MVC,知道"要调哪个 Controller 方法";AOP 属于业务 Bean,知道方法参数和返回值。越靠外层能拦的东西越少但越早,越靠内层拿到信息越多但越晚。- 执行顺序是固定的:Filter
doFilter前半段 → InterceptorpreHandle→ AOP 环绕前置 → 方法 → AOP 环绕后置 → InterceptorpostHandle→afterCompletion→ Filter 后半段。所以"哪个先执行"这个问题有唯一答案,别答"看配置"。 - Filter 拿不到方法信息,这是硬限制。想在 Filter 里做"只有某个接口需要鉴权"这种判断,只能靠解析 URL 字符串,很脆弱。而 Interceptor 的
preHandle直接给你HandlerMethod,能拿到方法上的自定义注解——所以"按注解做权限"必须写在 Interceptor 里。 - CORS 配在 Filter 层有个经典坑:如果鉴权 Filter 在 CORS Filter 之前执行,预检请求(OPTIONS)会被当成未授权请求拦掉,因为浏览器发 OPTIONS 时不带 Authorization 头。解法是让 CORS 过滤器优先级最高,或者直接放行所有 OPTIONS。
- 请求体只能读一次。Filter 里读了
request.getInputStream(),Controller 里@RequestBody就读不到了(流已到末尾)。要在 Filter 里读请求体做日志,必须用ContentCachingRequestWrapper包一层。 - afterCompletion 一定会执行(只要 preHandle 返回过 true),适合做资源清理和耗时统计。但 preHandle 返回 false 时,它自己的 afterCompletion 不会执行——这是很多人统计耗时时漏数据的原因。
面试怎么答Filter 是 Servlet 规范的一部分,在 DispatcherServlet 之前执行,能拿原始 request 但拿不到 Controller 方法信息,适合做编码、CORS、TraceId。Interceptor 是 Spring MVC 的,能拿到 HandlerMethod,适合按注解做鉴权和权限。AOP 作用于业务 Bean,能拿到方法参数和返回值,事务和缓存都在这层。执行顺序是 Filter 前半段、Interceptor preHandle、AOP 前置、方法、AOP 后置、postHandle、afterCompletion、Filter 后半段。几个实践要点:Filter 的顺序要用 FilterRegistrationBean 的 setOrder 控制,不是 @Order;CORS 要放在鉴权之前否则预检请求会被拦;Filter 里读请求体必须用 ContentCachingRequestWrapper 否则 Controller 读不到。
10
自动装配到底是怎么装上的★
一个注解背后三次跳转
@SpringBootApplication 是三个注解的组合,其中 @EnableAutoConfiguration 通过 @Import 引入一个选择器,选择器去读 classpath 上的候选配置清单文件,把里面几百个自动配置类按条件注解筛一遍,留下该生效的注册成 Bean。@ConditionalOnMissingBean是你覆盖默认配置的入口。你自定义一个DataSourceBean,自动配置里的那个就不生效了。这不是"后加载覆盖前面的",是条件判断根本没让它注册——所以自己写的 Bean 永远不会被框架悄悄替换。- 为什么自己写的 Bean 能优先:用户配置类(
@Configuration)先于自动配置类解析,所以容器里已经有你的 Bean,自动配置的@ConditionalOnMissingBean就判定失败。这个顺序是@AutoConfigureAfter/@AutoConfigureBefore在维护的。 - Boot 2.7 / 3.0 换了清单文件位置,这是一个高频版本差异题。老项目升级到 2.7 之后,如果自定义过 starter,写在
spring.factories里的自动配置不会报错但也不生效,非常难查。新格式的文件名长这样:META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports。 - 排查"自动配置为什么没生效"的标准动作:启动时加
--debug,会打印 CONDITIONS EVALUATION REPORT,列出每个自动配置类是生效了还是被哪个条件否掉、为什么否掉。这是最快的排查手段,比猜快得多。 - 条件注解的评估发生在解析阶段,不是运行阶段。所以 classpath 里有没有某个 jar,直接决定有没有一批 Bean——这也是"加个依赖就自动有了功能"的原理。反过来,想排除某个自动配置用
@SpringBootApplication(exclude = XxxAutoConfiguration.class)。
面试怎么答@SpringBootApplication 等于 @SpringBootConfiguration 加 @ComponentScan 加 @EnableAutoConfiguration。自动装配的核心是 @EnableAutoConfiguration 通过 @Import 引入 AutoConfigurationImportSelector,它去读 classpath 上的自动配置清单文件,Boot 2.7 之前是 spring.factories,之后换成了 AutoConfiguration.imports 这个新文件名,然后几百个候选配置类逐个过条件注解筛选,留下的注册成 Bean。最常用的条件是 @ConditionalOnClass 和 @ConditionalOnMissingBean,后者是自定义覆盖默认配置的机制,而且它不是覆盖而是根本不让默认的注册。排查没生效就加 --debug 看条件评估报告。
11
配置从哪来,谁覆盖谁
优先级记错,线上就会读到错误的值
Spring Boot 的配置有十几层来源,优先级从高到低大致是:命令行参数 > Java 系统属性 > 操作系统环境变量 > profile 专属配置文件 > 默认配置文件。同一个 key 出现在多处时,高优先级的赢,而且不会合并。
- 环境变量会自动做名称转换:
server.port对应SERVER_PORT(点变下划线、全大写)。所以容器化部署时用环境变量覆盖配置不用改任何代码。但要注意下划线和点的映射有歧义,名字里本来带下划线的配置项容易冲突,尽量用application.yml加 profile。 - profile 的激活顺序也有优先级:后激活的 profile 覆盖先激活的。
spring.profiles.active=a,b时 b 覆盖 a。这点经常被搞反。 - @Value 和 @ConfigurationProperties 的行为不同:
@Value是逐个取,不支持松散绑定(server.port不能写成 SERVER_PORT 注入)、不支持复杂类型、不支持校验;@ConfigurationProperties支持松散绑定、嵌套对象、JSR-303 校验、IDE 提示。一组配置项一律用 @ConfigurationProperties。 - 配置中心的优先级通常比本地文件高:Nacos / Apollo 的配置默认会覆盖本地
application.yml,但低于命令行参数。这个顺序可以通过spring.cloud.config.override-none之类开关调整,所以"本地改了不起作用"先查这个。 - 敏感配置不能进代码库:数据库密码、密钥要走环境变量或配置中心加密,别写在
application.yml里。而且/actuator/env端点默认会脱敏显示(把值替换成******),但 actuator 端点别对公网开放——历史上有过通过 env 端点读到明文的事故。
面试怎么答Spring Boot 配置优先级从高到低是命令行参数、系统属性、操作系统环境变量、jar 包外的 profile 配置文件、jar 包外的默认配置、jar 包内的 profile 配置、jar 包内的默认配置、PropertySource、代码里的默认值。环境变量会自动把点转成下划线并大写,所以容器部署覆盖配置很方便。profile 之间后激活的覆盖先激活的。实践上要注意两点:一组配置项用 @ConfigurationProperties 而不是 @Value,因为它支持松散绑定、嵌套和校验;配置中心的优先级通常高于本地文件,所以本地改了不生效要先查这个。
12
全局异常处理和参数校验
别让前端拿到 500 和堆栈
@ControllerAdvice + @ExceptionHandler 把异常统一转成结构化响应。参数校验用 @Valid(JSR-303),嵌套对象要层层加 @Valid 才会级联校验,否则内层字段不校验也不报错。- 四个 handler 缺一不可:业务异常、校验失败、请求体格式错、兜底。少一个就会出现"前端拿到
{status:500,path:...}这种框架默认响应",字段名和项目约定不一致,前端还得单独兼容。 - 校验失败的三种异常别搞混:
@RequestBody+@Valid抛MethodArgumentNotValidException;@RequestParam/@PathVariable上加约束抛ConstraintViolationException(需要类上加@Validated);表单对象绑定校验失败抛BindException。三种都要接。 - 嵌套校验是最容易漏的一条:
class Order { @Valid List<Item> items; },不加 @Valid 的话 items 里的字段完全不校验,而且不报错——你以为校验过了,其实没有。集合元素同理。 - 分组校验解决"新增要校验 id 为空、修改要校验 id 必填":定义
interface Create {}和interface Update {},注解上写@NotNull(groups = Update.class),方法参数写@Validated(Update.class)。比写两套 DTO 轻,但比一套 DTO 加 if 判断清晰。 - 别把异常信息直接返回给前端:SQL 异常里可能带表名、字段名,
NullPointerException的堆栈会暴露代码结构和依赖版本。兜底 handler 一律返回统一文案,完整信息只进日志,并且在日志里带上 TraceId 方便对账。
面试怎么答全局异常处理用 @RestControllerAdvice 配合 @ExceptionHandler,至少要接四类:业务异常、@Valid 校验失败抛的 MethodArgumentNotValidException、请求体解析失败抛的 HttpMessageNotReadableException、以及 Exception 兜底。校验这块有几个高频坑:嵌套对象和集合元素必须在字段上加 @Valid 才会级联校验,不加就静默不校验;@RequestParam 上的约束抛的是 ConstraintViolationException,需要在类上加 @Validated,和 @RequestBody 抛的不是同一个;新增和修改的校验规则不同就用分组校验。兜底 handler 一定不能把堆栈返回给前端。
13
Spring 的事件机制与异步
解耦和踩坑都在这两个注解上
ApplicationEventPublisher.publishEvent() 发事件,@EventListener 收事件。默认是同步的——发布方法会等所有监听器执行完才返回。要异步加 @Async,但要先 @EnableAsync 并配好线程池,否则用的是每次新建线程的执行器。- 同步监听器会让发布方的事务变大:监听器里的操作(比如发邮件、调远程接口)都发生在同一个事务里,连接一直被占着。用
@TransactionalEventListener(AFTER_COMMIT)才是正确姿势——它保证"事务提交了才通知",避免"消息发出去了但事务回滚了"。 - AFTER_COMMIT 里的操作不会再被事务保护:这时事务已经提交,监听器里再操作数据库是独立事务(或没有事务)。失败就只能靠重试或补偿,所以这里适合做"尽力而为"的事:清缓存、发通知、写日志,不适合做"必须成功"的核心逻辑。
- @Async 的四个坑:一是不加
@EnableAsync就完全同步执行,没有任何提示;二是默认执行器SimpleAsyncTaskExecutor每次新建线程、不复用,高并发直接创建成千上万个线程;三是同类 this 调用不生效(和 AOP 一样);四是返回void时异常会被吞掉,要AsyncUncaughtExceptionHandler才能捕获。 - 异步方法里拿不到 ThreadLocal:登录用户、TraceId、事务上下文都在
ThreadLocal里,切到子线程就没了。MDC 的 TraceId 尤其要注意——异步任务的日志会丢 TraceId,排查时断链。解法是给线程池配TaskDecorator,把 MDC 复制过去。 - 和 Node 对照:Node 是单线程事件循环,
EventEmitter的 emit 是同步调用监听器;要异步就靠 Promise/nextTick。Java 这边"事件默认同步"是同样的事实,只是 Java 有真正的多线程,所以"异步"意味着换线程、要考虑线程安全和上下文传递;Node 里异步只是换到下一个 tick,还是同一个线程。
面试怎么答Spring 的事件机制是发布订阅模式,发布方调 publishEvent,监听方用 @EventListener 接收。默认是同步的,发布方法会等所有监听器执行完才返回,而且监听器和发布方在同一个事务里,监听器抛异常会导致发布方回滚。所以做发消息、清缓存这类操作要用 @TransactionalEventListener 的 AFTER_COMMIT 阶段,保证事务提交后才执行,避免消息发出去了但数据回滚。要注意 AFTER_COMMIT 之后事务已经结束,里面的操作不再被原事务保护。@Async 有几个坑:不开 @EnableAsync 就完全同步、默认执行器每次新建线程、返回 void 时异常被吞、ThreadLocal 里的 TraceId 会丢。
14
定时任务和线程池的默认值陷阱
默认配置几乎总是错的
@Scheduled 的默认调度线程池只有一个线程,多个定时任务会串行执行,一个卡住全部卡住。Spring Boot 里 @Async 的默认执行器每次新建线程。这两处默认值都不能用于生产。- 单线程调度器最典型的线上表现:一个任务因为某个数据问题卡住(死循环、等锁、调外部接口超时),其余任务全部停止,而且没有任何报错——看起来像"定时任务没生效"。查的时候先看线程栈里是不是只有一个
scheduling-1线程。 - cron 表达式和时区:默认用 JVM 时区。容器镜像常是 UTC,于是"每天凌晨 2 点"的统计任务在北京时间上午 10 点才跑。要么给
@Scheduled(zone = "Asia/Shanghai"),要么统一容器时区。这个坑在跨国部署时更明显。 - 多实例会重复执行:
@Scheduled是单机调度,部署三个实例就是三份都在跑。要么加分布式锁(Redis / 数据库),要么用专门的调度框架(XXL-JOB、ElasticJob)。"任务重复执行导致数据翻倍"通常就是这个原因。 - 线程池参数别照抄:CPU 密集任务线程数接近核数,IO 密集可以到核数的几倍到十几倍。队列长度也不是越大越好——队列太长会让任务积压到超时,而拒绝策略反而永远不会触发,等于把问题从"快速失败"变成"慢慢烂掉"。
- 拒绝策略的选择:
AbortPolicy(默认,抛异常)适合"必须知道失败"的场景;CallerRunsPolicy让提交任务的线程自己跑,天然形成背压,适合不能丢的场景;DiscardPolicy静默丢弃,生产环境基本不该用——问题被完全掩盖。 - 一定要给线程起名字。
threadNamePrefix不只是好看——出问题时jstack里能看到mail-1,立刻知道是哪个池子;全是pool-1-thread-1就只能猜。
面试怎么答@Scheduled 默认的调度线程池只有一个线程,多个任务会串行执行,一个卡住全部停摆而且不报错,所以生产必须用 SchedulingConfigurer 配置 ThreadPoolTaskScheduler。另外 cron 用 JVM 时区,容器是 UTC 的话执行时间会偏移 8 小时;多实例部署时每个实例都会跑,需要分布式锁或者用 XXL-JOB 这类调度框架。@Async 的默认执行器是 SimpleAsyncTaskExecutor,每次新建线程不复用,也必须自定义 ThreadPoolTaskExecutor。线程池参数上要注意队列太长会让拒绝策略永远不触发,把快速失败变成缓慢积压。线程名一定要起,排查时能直接定位。
15
Spring Boot 3 变了什么★
升级踩坑清单
Boot 3 的三条硬变化:JDK 基线提到 17、javax 包名全部换成 jakarta、自动配置清单文件换位置。前两条会导致编译不过(这是好事),第三条不报错只是不生效(这是坑)。
- javax → jakarta 不是改个包名这么简单。第三方库也必须提供 jakarta 版本,不然会出现"一半 javax 一半 jakarta"的混用,报
ClassNotFoundException或者更隐蔽的"注解不生效"。升级前先确认依赖链上每个库都支持 Boot 3。 - 最容易被忽略的是第 ③ 条。前两条编译期就炸,改起来虽然烦但至少知道要改什么;清单文件位置不对编译完全通过、启动完全正常、功能就是不生效,只能靠
--debug才能看出来。 - Boot 2.7 是过渡版本:它同时支持
spring.factories和新格式,所以很多项目停在 2.7 没暴露问题,一升 3.0 才发现自定义 starter 全废。升级路线应该是 2.7 → 改清单文件 → 3.x。 - Security 6 的默认行为变化会导致"升级后突然 403":授权规则更严格、
requestMatchers的匹配语义也调整了。这块建议单独拉一个迭代做,别和业务改造混在一起。 - 原生镜像不是免费的:AOT 编译要求所有反射、资源加载、动态代理都在编译期可知,Spring 内置的库大部分已经适配,但项目里用的冷门工具库经常需要自己写
RuntimeHints。启动快、内存省,但构建慢、调试难,是否采用要看场景(Serverless、CLI 工具适合,普通 Web 服务收益有限)。
面试怎么答Spring Boot 3 的关键变化有六条。最主要的三条是:JDK 基线提到 17;javax 命名空间整体换成 jakarta,涉及 servlet、persistence、validation、annotation 四大块,而且依赖的第三方库也必须提供 jakarta 版本;自动配置清单文件从 spring.factories 换成了 AutoConfiguration.imports。前两条编译期会报错,第三条不报错只静默失效,是最坑的,因为 Boot 2.7 同时兼容两种格式,很多项目停在 2.7 没暴露问题。另外 Spring Security 6 移除了 WebSecurityConfigurerAdapter、匹配器 API 改名、默认授权更严格,升级后容易突然 403。还有 Micrometer Tracing 取代 Sleuth,以及支持 GraalVM 原生镜像但需要注册运行时提示。
16
速答清单
面试前一小时只看这页
01IoC 和 DI 是什么关系?
IoC 是思想:对象的创建和装配交给容器。DI 是实现手段:构造器注入、setter 注入、字段注入。
02为什么推荐构造器注入?
保证对象创建出来就是完整的,字段能声明 final,单测可以直接 new 传 mock。代价是解决不了循环依赖。
03Bean 的生命周期?
实例化 → 属性填充 → Aware → BeanPostProcessor 前置 → 初始化(@PostConstruct、afterPropertiesSet、initMethod)→ 后置 → 使用 → 销毁。
04AOP 代理是在哪一步生成的?
BeanPostProcessor 的后置处理阶段。所以 @PostConstruct 里调本类的 @Transactional 方法不生效。
05三级缓存分别存什么?
一级 singletonObjects 存成品,二级 earlySingletonObjects 存早期引用,三级 singletonFactories 存对象工厂。
06为什么需要三级缓存,两级不够吗?
两级也能解决循环依赖,但会迫使实例化后就决定要不要创建代理。三级存工厂,把决定推迟到真正被需要时,避免给无循环依赖的 Bean 提前创建代理。
07哪些循环依赖解决不了?
构造器注入的、prototype 作用域的、@Async 导致的。Boot 2.6 之后默认直接禁止循环依赖。
08JDK 动态代理和 CGLIB 的区别?
JDK 基于接口生成实现类,CGLIB 生成子类覆盖方法。CGLIB 代理不了 final 类和 final 方法。Boot 2.x 默认 CGLIB。
09AOP 为什么会失效?
同类 this 调用不经过代理。还有 private、final、static 方法,对象不是 Bean,切点没匹配上。
10@Transactional 失效的六种场景?
this 调用、非 public 或 final、异常被 catch、抛受检异常、跨线程、类没被 Spring 管理或引擎不支持事务。
11@Transactional 默认对哪些异常回滚?
只有 RuntimeException 和 Error。受检异常默认不回滚,要配 rollbackFor = Exception.class。
12事务传播行为默认是哪个?
REQUIRED。有事务就加入,没有就新建,内外共用一个。REQUIRES_NEW 是独立事务并挂起外层。
13REQUIRES_NEW 有什么隐藏成本?
占两个数据库连接;如果内层操作外层已锁定的同一行,会自己锁自己导致死锁或超时。
14NESTED 和 REQUIRES_NEW 的区别?
NESTED 是同一物理事务里的保存点,只有一次提交,外层回滚内层也白搭;REQUIRES_NEW 是两个独立事务。
15一个请求在 Spring MVC 里的链路?
Filter → DispatcherServlet → HandlerMapping → HandlerAdapter(参数解析、校验、preHandle)→ Controller → 返回值处理 → postHandle → afterCompletion → Filter 后半段。
16Filter 和 Interceptor 的区别?
Filter 属于 Servlet 容器,能拿原始 request 但不知道 Controller 方法;Interceptor 属于 Spring MVC,能拿到 HandlerMethod。
17为什么 CORS 配置会拦截 OPTIONS?
预检请求不带 Authorization 头,如果鉴权 Filter 在 CORS 之前就会拦掉。要让 CORS 优先级最高或放行 OPTIONS。
18Filter 里读了请求体,Controller 为什么读不到?
输入流只能读一次。要用 ContentCachingRequestWrapper 包一层。
19自动装配的原理?
@EnableAutoConfiguration 通过 @Import 引入选择器,读自动配置清单文件,按条件注解筛选后注册 Bean。
20@ConditionalOnMissingBean 有什么用?
容器里没有这个 Bean 才生效。这是自定义配置覆盖默认配置的机制,本质是不让默认的注册,不是后加载覆盖。
21Boot 3 的自动配置清单文件在哪?
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,2.7 之前是 spring.factories。
22自动配置没生效怎么排查?
启动加 --debug,看 CONDITIONS EVALUATION REPORT,会列出每个自动配置被哪个条件否掉。
23@Value 和 @ConfigurationProperties 怎么选?
一组配置项用 @ConfigurationProperties,支持松散绑定、嵌套对象、校验和 IDE 提示;单个值可以用 @Value。
24配置优先级最高的是什么?
命令行参数。然后是系统属性、环境变量、jar 外的 profile 配置、jar 外的默认配置。
25环境变量怎么映射到配置项?
点转下划线并全大写,server.port 对应 SERVER_PORT。
26全局异常处理要接哪几类异常?
业务异常、MethodArgumentNotValidException、HttpMessageNotReadableException、Exception 兜底。少一个前端就拿到框架默认响应。
27嵌套对象的校验为什么不生效?
字段上没加 @Valid,不会级联。集合元素同理,而且不报错,属于静默失效。
28Spring 事件默认是同步还是异步?
同步,发布方法会等所有监听器执行完,而且监听器和发布方在同一个事务里。
29@TransactionalEventListener 什么时候用?
需要"事务提交后才执行"时,比如发 MQ、清缓存。避免消息发出去了但数据回滚。
30@Async 的常见坑?
不开 @EnableAsync 就完全同步;默认执行器每次新建线程;同类调用无效;返回 void 时异常被吞;ThreadLocal 的 TraceId 会丢。
31@Scheduled 默认几个线程?
一个。多个任务串行执行,一个卡住全部停摆且不报错。必须配置 ThreadPoolTaskScheduler。
32多实例部署时定时任务会重复吗?
会。@Scheduled 是单机调度,每个实例都会跑。要加分布式锁或用 XXL-JOB 这类调度框架。
33线程池队列太长有什么问题?
任务积压到超时,拒绝策略永远不触发,把快速失败变成缓慢烂掉。
34Boot 3 的三条硬变化?
JDK 基线 17、javax 换 jakarta、自动配置清单换位置。前两条编译报错,第三条静默失效。
35升级 Boot 3 为什么建议先到 2.7?
2.7 同时兼容两种清单文件格式,可以先改好自定义 starter,再升 3.x 就不会突然失效。