Java 真实面试专题

13 篇 · 免费在线阅读

Spring 全家桶面试题精选

Java 后端真实面试专题 · Spring 全家桶篇

Spring / SpringBoot / SpringMVC / MyBatis 是后端地基,必考。每题三段: ① 标准答(讲透:是什么+为什么+怎么做+原理)→ ② 拓展(成体系带出关联点和必追问的)→ ③ 怎么接到你自己的项目

年限标签:🟢 3年内 🔴 3年+


1. 🟢 说说你对 IoC 和 AOP 的理解?

标准答

  • IoC(控制反转):把对象的创建和依赖关系的管理交给 Spring 容器,对象不再自己 new 依赖,而是由容器注入(DI 依赖注入)。好处是解耦——面向接口编程,改实现不动调用方。
  • AOP(面向切面):把日志、事务、权限、限流这类横切逻辑用动态代理统一织入,不侵入业务代码。核心概念:切面、切点(在哪切)、通知(切入后做什么)。

拓展

  • "IoC 解决了什么痛点?"——对象间硬编码依赖,改一处动全身、难测试;交给容器后能灵活替换、方便单测(注入 mock)。
  • "依赖注入有几种方式?"——构造器注入(推荐,能保证不可变和非空)、setter 注入、字段注入(@Autowired,简洁但不利于测试)。
  • "AOP 的底层?"——动态代理(JDK/CGLIB),见后面专门的题。
  • IoC 的边界是对象装配,它不会替你设计业务流程;AOP 的边界是方法调用拦截,不能自动覆盖数据库触发器、远程服务等代理之外的行为。
  • 构造器注入时依赖图在启动阶段就能被检查,循环依赖也会尽早失败;对可选依赖可使用 ObjectProvider<T>,避免为了“能启动”而把所有依赖改成字段注入。
  • 一个调用通常经过“Controller 代理 → Service 代理 → Repository”的链路,事务、鉴权和日志应放在稳定的服务边界,避免在 DTO 或工具类上堆切面。

往项目引 ⭐:"我项目所有 Service 靠 @Autowired 注入依赖、面向接口写,多渠道支付能随时换实现;又用 AOP + 自定义注解统一打了接口耗时日志和操作审计,业务代码里一行日志都不用写——这就是 IoC 解耦 + AOP 抽横切的实际价值。"

深入答法:IoC 的关键不是“少写 new”,而是容器在启动阶段依据 BeanDefinition 完成实例化、依赖解析和生命周期回调;AOP 则在拿到目标 Bean 后用代理拦截调用。两者组合后,业务对象只依赖稳定接口,横切能力可以独立替换。

@Service
class OrderService {
    private final InventoryClient inventoryClient;
    OrderService(InventoryClient inventoryClient) {
        this.inventoryClient = inventoryClient;
    }
}

边界:只有从容器拿到的代理对象才能触发切面;在方法里直接 new OrderService(...) 或用 this 自调用,都会绕过容器和代理。

判断边界:只有通过容器拿到的引用才会经过代理;把横切逻辑写进业务方法或直接 new 对象,会失去统一拦截和替换能力。


2. 🟢 Spring Bean 的生命周期?

标准答

  1. 实例化:反射调构造器创建对象。
  2. 属性注入:填充依赖(@Autowired 等)。
  3. Aware 回调:如 BeanNameAware、ApplicationContextAware,让 Bean 拿到容器信息。
  4. BeanPostProcessor 前置处理postProcessBeforeInitialization)。
  5. 初始化@PostConstruct → InitializingBean 的 afterPropertiesSet → 自定义 init-method。
  6. BeanPostProcessor 后置处理postProcessAfterInitializationAOP 代理在这步生成)。
  7. 使用
  8. 销毁@PreDestroy → DisposableBean 的 destroy

拓展

  • "AOP 代理在哪步?"——后置处理器里,所以拿到的 Bean 其实是代理对象,这也解释了为什么自调用 AOP 会失效。
  • "BeanPostProcessor 和 BeanFactoryPostProcessor 区别?"——前者处理 Bean 实例、后者处理 Bean 定义(元数据)。
  • 循环依赖的解决(三级缓存)就发生在实例化和属性注入之间。
  • @PostConstruct 发生在属性注入完成后,但此时并不代表所有 Bean 都已经初始化;需要依赖“全体单例就绪”的逻辑可考虑 SmartInitializingSingletonContextRefreshedEvent
  • 原型 Bean 由容器创建但通常不负责完整销毁;线程池、连接、文件句柄等资源应在明确的生命周期回调中关闭,不能只依赖 GC。
  • 启动失败时按“实例化 → 注入 → 初始化 → 后置处理器”顺序看异常,能区分缺 Bean、循环依赖和初始化业务异常。

往项目引 ⭐:"我项目用 @PostConstruct 在 Bean 初始化后预加载字典、配置到本地缓存,省去每次查库。理解生命周期让我知道初始化逻辑该放哪个钩子。" 深入答法:生命周期可以按“定义注册 → 实例化 → 依赖注入 → 初始化回调 → 后置增强 → 使用 → 销毁”记忆。ApplicationContext 通常预实例化非懒加载单例;prototype 每次获取都会重新走创建阶段,但容器默认不负责它的销毁回调。 示例:初始化里只做轻量、可失败重试的准备工作;连接池、线程池等资源要在 @PreDestroy 中关闭,不能把网络长耗时操作塞进构造器。

@PostConstruct void warmUp() { cache.preload("dict"); }
@PreDestroy void close() { executor.shutdown(); }

排查时可在 postProcessBeforeInitializationAfterInitialization 打印 Bean 名称,确认到底是原对象还是代理对象。


3. 🟢 Bean 的作用域有哪些?单例 Bean 线程安全吗?

标准答:作用域——singleton(默认,容器内唯一)、prototype(每次获取新建)、request、session、application(Web 相关)。 单例 Bean 不一定线程安全:如果有可变的成员变量,多线程并发访问会有问题;如果是无状态的(不存可变状态),就是安全的。

拓展

  • "Spring 怎么保证单例线程安全?"——Spring 不负责保证,是否安全取决于你有没有可变状态。
  • "需要线程内状态怎么办?"——用 ThreadLocal,或把单例改成方法局部变量(栈封闭)。
  • "单例 Bean 里注入 prototype Bean 会怎样?"——注入的还是同一个,要每次拿新的得用 @Lookup 或 ObjectProvider。
  • Web 作用域 Bean 注入到 singleton 时,需要作用域代理或 ObjectProvider;否则单例只会在启动时拿到一个请求对象,既不符合语义也可能导致越权。
  • 单例线程安全不等于数据安全:AtomicLong 只能保护计数这一变量,多个字段的“读取-校验-更新”仍需要锁或数据库事务。
  • ThreadLocal 要在请求结束时 remove(),在线程池复用线程时不清理会把上一个请求的用户信息带给下一个请求。

往项目引 ⭐:"我项目 Service 都设计成无状态的(不放可变成员变量),需要请求级状态就用 ThreadLocal 存(如当前用户),所以单例 Bean 没有并发安全问题。" 深入答法:作用域决定“谁共享状态”,线程安全决定“共享状态是否可并发修改”,两者不是一回事。Web 作用域 Bean 注入 singleton 时需要 scoped proxy,否则创建时没有 request/session 上下文会报错。

@Bean
ObjectProvider<RequestContext> requestContext(ObjectProvider<RequestContext> p) {
    return p;
}

边界:ThreadLocal 只能保存当前线程上下文,线程池复用时必须在 finallyremove();异步任务不要假定能自动继承请求线程的 ThreadLocal。


4. 🔴 Spring 怎么解决循环依赖?三级缓存分别是什么?

标准答:用三级缓存解决单例 + 属性注入的循环依赖:

  • 一级缓存(singletonObjects):完整的成品 Bean。
  • 二级缓存(earlySingletonObjects):实例化了但还没注入属性的半成品。
  • 三级缓存(singletonFactories):对象工厂,用于在需要时提前生成代理对象。 A 依赖 B、B 依赖 A 时:A 实例化后把自己的工厂放三级缓存 → A 注入 B 时去创建 B → B 注入 A 时从三级缓存拿到 A 的早期引用(提前暴露)→ B 创建完,A 继续完成,打破循环。

拓展

  • "为什么需要三级而不是二级?"——三级缓存的工厂是为了在有 AOP 时提前暴露的是代理对象而不是原始对象,保证注入的是同一个代理。
  • "哪种循环依赖解决不了?"——构造器注入的循环依赖(对象还没实例化就需要对方,无法提前暴露);prototype 的也不行。
  • 解决不了时报 BeanCurrentlyInCreationException
  • 三级缓存只针对“单例 + setter/字段注入”的典型循环,且只在允许提前暴露时成立;Spring Boot 2.6+ 默认更倾向于禁止循环依赖,应优先拆分职责而不是依赖开关放行。
  • A 先暴露早期引用后,如果 B 调用了 A 的方法,A 可能尚未完成初始化;这也是循环依赖会隐藏初始化顺序问题的原因。
  • 构造器循环的修复顺序:先抽取共同职责到第三个 Bean,再考虑事件解耦或 @Lazy,最后才把注入方式改成属性注入。

往项目引 ⭐:"我项目遇到过两个 Service 互相依赖、启动报循环依赖错——其实是用了构造器注入。改成字段注入或加 @Lazy 就好了。理解三级缓存让我一眼定位是注入方式的问题。" 深入答法:三级缓存只针对“单例 + 属性/Setter 注入”的创建环。A 实例化后先放入 singletonFactories,B 创建时需要 A,工厂才生成 A 的早期引用;若 A 需要代理,此时可以返回代理而不是裸对象,最终成品再进入一级缓存。

边界:构造器循环、prototype 循环、涉及未完成工厂创建的复杂依赖无法靠缓存解决;优先通过拆分职责消除环,而不是依赖 allowCircularReferences


5. 🟢 @Autowired 和 @Resource 的区别?

标准答

  • @Autowired:Spring 提供,**默认按类型(byType)**注入,配合 @Qualifier 指定名字。
  • @Resource:JDK(JSR-250)提供,**默认按名字(byName)**注入,找不到再按类型。

拓展

  • "按类型注入遇到多个实现怎么办?"——会报 NoUniqueBeanDefinitionException,用 @Qualifier("名字") 或在某个实现上加 @Primary
  • "字段注入的缺点?"——不利于单元测试(不能在 new 时传 mock)、可能掩盖循环依赖,官方更推荐构造器注入。
  • @Autowired(required=false) 会把缺失依赖静默变成 null,更可读的做法是 Optional<T>ObjectProvider<T>,并在业务边界明确“没有实现时怎么办”。
  • @Resource(name="...") 明确按名称查找;多个实现时不要依赖类名推断,给 Bean 起稳定的业务名称,重构类名不会改变注入结果。
  • 构造器只有一个时 Spring 通常可以省略 @Autowired;多个构造器则要明确标注,否则可能出现无法创建 Bean 的启动错误。

往项目引 ⭐:"我项目有微信、支付宝多个支付实现,注入时用 @Qualifier 指定,或者干脆用一个 Map<String, PayService> 让 Spring 把所有实现按名字注进来,再按渠道 key 取对应的——这是策略模式 + Spring 注入的经典配合。" 深入答法@Autowired 的候选解析大致是先按类型找候选,再按 @Primary@Qualifier、字段/参数名缩小范围;@Resource 先尝试名称。构造器注入能让依赖成为 final,并在启动时立即暴露缺失依赖。

@Service
class CheckoutService {
  private final PayService pay;
  CheckoutService(@Qualifier("wechatPay") PayService pay) { this.pay = pay; }
}

边界:同类型实现新增后可能让原来正常的注入突然启动失败;给公共接口定义稳定的 qualifier 名称,并用容器启动测试覆盖。


6. 🟢 AOP 的实现原理?JDK 动态代理和 CGLIB 的区别?

标准答:AOP 靠动态代理在目标方法前后织入逻辑:

  • JDK 动态代理:要求目标类实现了接口,基于接口生成代理类(Proxy + InvocationHandler),反射调用。
  • CGLIB:目标类没接口也行,通过生成子类字节码来代理,重写父类方法。 SpringBoot 默认全用 CGLIB(spring.aop.proxy-target-class=true)。

拓展

  • "CGLIB 的限制?"——不能代理 final 类和 final 方法(因为靠继承重写)。
  • "为什么自调用 AOP 失效?"——this.方法() 调的是原始对象不是代理对象,绕过了切面。解决:注入自己、或用 AopContext.currentProxy()
  • 切面执行顺序、多个切面用 @Order 控制。
  • JDK 代理对象只能安全地按接口类型注入;需要按具体实现类注入时要确认代理类型,或者显式启用 class-based proxy。
  • @Around 必须调用 proceed() 才会继续目标方法;切面里吞掉异常会改变事务回滚和上层重试语义,记录日志后应继续抛出。
  • Spring AOP 默认是运行时代理,只拦截经过代理对象的外部调用;private、static、final 方法和同类 this 调用不在常规代理覆盖范围内。

往项目引 ⭐:"我项目用 AOP + 自定义注解 @Idempotent@RateLimit 做接口幂等和限流——原理就是动态代理在方法执行前先做校验。理解了代理机制,也知道为什么这些注解不能用在同类自调用的方法上。" 深入答法:代理对象负责拦截调用,调用链通常是 代理 → MethodInterceptor → 目标方法。JDK 代理只能暴露接口方法,CGLIB 子类代理可保留具体类类型,但 final 类/方法无法重写;代理选择还会影响注入点的类型和序列化。

@EnableAspectJAutoProxy(exposeProxy = true)
@Aspect class MetricsAspect {
  @Around("execution(* com.demo..service..*(..))")
  Object measure(ProceedingJoinPoint p) throws Throwable {
    long t = System.nanoTime();
    try { return p.proceed(); } finally { log(t); }
  }
}

边界:切点表达式过宽会增加开销;同类自调用、private/final 方法不会触发基于代理的切面。


7. 🟢 Spring 事务的传播行为有哪些?

标准答:定义"方法 A 调方法 B 时,B 的事务怎么处理"。常用:

  • REQUIRED(默认):有事务就加入、没有就新建。
  • REQUIRES_NEW:挂起当前事务、自己开一个新的(互不影响)。
  • NESTED:嵌套事务,基于 savepoint,外层回滚带着它回滚,但它能单独回滚到 savepoint。
  • SUPPORTS / NOT_SUPPORTED / MANDATORY / NEVER:按需。

拓展

  • 典型场景:主流程失败要回滚,但"记操作日志"要独立提交——用 REQUIRES_NEW。
  • "NESTED 和 REQUIRES_NEW 区别?"——NESTED 依赖外层(外层回滚它也回),REQUIRES_NEW 完全独立。
  • 传播行为是事务失效的一个隐藏点(用错导致没回滚或没生效)。
  • REQUIRES_NEW 会挂起外层连接并占用新连接;高并发下连接池太小可能出现池耗尽甚至互相等待,审计日志可考虑异步消息或本地消息表。
  • 传播行为只回答“加入哪一个事务”,不等于隔离级别;隔离级别决定并发读写现象,readOnly=true 也不是绝对禁止写入的安全开关。
  • NESTED 需要底层事务管理器和 JDBC savepoint 支持;跨数据库、JPA 或分布式事务场景不能想当然地使用。

往项目引 ⭐:"我项目下单失败要整体回滚,但失败原因日志必须留下来,所以记日志的方法用 REQUIRES_NEW 开独立事务,主事务回滚不影响日志落库——这是传播行为最实用的场景。" 深入答法:传播行为由事务拦截器在进入方法时检查当前线程是否已有事务,再决定加入、挂起或拒绝。事务资源(连接、同步器)绑定在线程上下文,真正提交/回滚由 PlatformTransactionManager 统一完成。

场景常用传播结果
普通业务链路REQUIRED共用一个提交点
独立审计/补偿REQUIRES_NEW挂起外层,单独提交
可回滚局部步骤NESTED同一连接创建 savepoint
@Transactional
public void placeOrder() { orderDao.insert(); auditService.record(); }
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record() { auditDao.insert(); }

边界:NESTED 依赖底层事务管理器和数据库 savepoint;跨线程、跨数据源时传播不会自动把事务变成全局事务。


8. 🟢 Spring 事务在哪些情况下会失效?(高频实战)

标准答

  1. 方法不是 public(动态代理拦不到)。
  2. 自调用this.方法() 绕过代理。
  3. 异常被 catch 没抛出,或抛了但没触发回滚。
  4. 抛的是受检异常但没配 rollbackFor(默认只回滚 RuntimeException 和 Error)。
  5. 类没被 Spring 管理(没加 @Service 等)。
  6. 数据库引擎不支持事务(MyISAM)。
  7. 多线程里——事务是绑在当前线程的,新线程里的操作不在事务内。

拓展

  • "怎么让受检异常也回滚?"——@Transactional(rollbackFor = Exception.class)
  • "自调用怎么解决?"——注入自己(@Autowired private XxxService self;)调 self.方法(),或拆到别的 Bean。
  • 这题几乎必问,能列全 + 说出解决办法很加分。
  • 事务拦截器只在代理边界开启事务:private/final 方法、同类调用、new 出来的对象、未被组件扫描的类都可能绕过代理。
  • 默认回滚规则通常是 RuntimeExceptionError;业务异常若继承 Exception,应明确 rollbackFor,并避免在事务方法里把异常转换成成功返回。
  • @Async、线程池和响应式链路会切换执行线程,传统 ThreadLocal 事务上下文不会自动传播;跨线程要改成消息/补偿方案或使用适配的事务模型。
  • 多数据源时要确认事务管理器绑定的是哪个 DataSource;一个本地事务不能自动覆盖两个数据库。

往项目引 ⭐:"我项目踩过自调用失效的坑——一个方法里调本类另一个带 @Transactional 的方法,事务没生效。后来注入自身代理调用解决。从那以后我写事务方法会特别注意调用方式。" 深入答法@Transactional 本质是代理拦截,不是编译器指令。进入代理后创建事务,方法正常返回才提交;抛出匹配回滚规则的异常才回滚。readOnly 是优化提示,不等价于禁止写入。

@Transactional(rollbackFor = Exception.class)
public void importData(List<Row> rows) throws Exception {
  dao.batchInsert(rows);
  if (invalid(rows)) throw new Exception("校验失败");
}

排查顺序:确认 Bean 来自容器、调用经过代理、事务管理器和数据源一致,再检查异常是否被吞掉以及是否跨线程/异步执行。


9. 🟢 SpringMVC 的完整执行流程?

标准答

  1. 请求到 DispatcherServlet(前端控制器,统一入口)。
  2. 它查 HandlerMapping 找到能处理的 Controller 方法。
  3. 通过 HandlerAdapter 调用 Controller。
  4. Controller 返回 ModelAndView(或 @ResponseBody 返回对象)。
  5. ViewResolver 解析视图并渲染(前后端分离下用 HttpMessageConverter 把对象转 JSON)。
  6. 返回响应。

拓展

  • "前后端分离怎么返回 JSON?"——@ResponseBody/@RestController + HttpMessageConverter(Jackson)。
  • "参数怎么绑定的?"——HandlerMethodArgumentResolver 解析 @RequestParam@RequestBody@PathVariable
  • DispatcherServlet 是核心调度者,体现了"前端控制器"模式。
  • 请求体只能被读取一次,过滤器若提前消费 InputStream,要用可缓存 request wrapper,否则 Controller 会拿不到 @RequestBody
  • 参数校验通常发生在参数解析后、方法调用前;异常统一处理要覆盖 MethodArgumentNotValidExceptionConstraintViolationException 和 JSON 解析异常。
  • 异步请求、文件上传、静态资源和错误派发可能走不同分支,排查“拦截器没进来”时要先确认请求类型和 DispatcherType

往项目引 ⭐:"我项目是前后端分离,Controller 全用 @RestController 返回统一的 Result<T> 包装,再配全局异常处理器兜底返回错误码——前端只需按统一结构处理成功和失败。" 深入答法:SpringMVC 的关键是把协议层请求转换成方法调用,再把返回值转换回响应体。HandlerMapping 找到 HandlerMethodHandlerAdapter 负责参数解析和调用,HttpMessageConverter 根据 Content-Type/Accept 选择 JSON、表单等转换器。

边界:参数校验失败通常在 Controller 方法执行前抛出;文件上传、流式响应和普通 JSON 使用的转换器不同,不能只看方法返回类型。


10. 🔴 SpringBoot 自动装配的原理?

标准答:核心是 @SpringBootApplication 里的 @EnableAutoConfiguration

  1. 它通过 @Import 引入 AutoConfigurationImportSelector
  2. selector 去扫描所有 jar 包的 META-INF/spring.factories(SpringBoot 2.7+ 是 META-INF/spring/...AutoConfiguration.imports)里登记的自动配置类。
  3. 每个自动配置类上有 @ConditionalOnXxx 条件注解,满足条件才生效(如 @ConditionalOnClass 有这个类才配、@ConditionalOnMissingBean 用户没自定义才用默认)。

拓展

  • "约定优于配置"靠的就是 @ConditionalOnMissingBean——你不配就用默认、你配了就用你的。
  • "怎么自定义 starter?"——写自动配置类 + 条件注解 + @ConfigurationProperties 绑定配置 + 在 imports 文件登记。
  • 想看哪些自动配置生效了,启动加 --debug 看 Conditions 报告。
  • 自动配置不是“无条件全加载”:先读取候选类,再按类路径、属性、Bean 和资源条件过滤,最后按顺序注册 Bean 定义;条件不满足时不会创建对象。
  • Spring Boot 2.x 常见 spring.factories,2.7/3.x 的自动配置导入文件是 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports;回答时要说明版本。
  • 排查重复 Bean 时看 --debug 的 Condition Evaluation Report 和 @ConditionalOnMissingBean 的生效顺序,不要只靠猜 starter。

往项目引 ⭐:"我项目封装过公司内部 starter——比如统一的日志、链路追踪、异常处理打成一个 starter,各业务线引依赖就自动生效、零配置。这就是自动装配 + 条件注解的实战,让公共能力复用。" 深入答法:自动装配不是“把所有配置都加载”,而是先收集候选,再经条件评估和排序后注册 BeanDefinition。用户自定义同类型 Bean 通常会让 @ConditionalOnMissingBean 失效,从而覆盖默认实现;排除配置可用 excludespring.autoconfigure.exclude

@AutoConfiguration
@ConditionalOnClass(RedisTemplate.class)
@ConditionalOnMissingBean(TraceReporter.class)
public class TraceAutoConfiguration {
  @Bean TraceReporter traceReporter(TraceProperties p) { return new HttpTraceReporter(p); }
}

验证:启动加 --debug 查看 Condition Evaluation Report;starter 的配置键要有前缀、默认值和文档,避免条件过宽导致误装配。


11. 🟢 @SpringBootApplication 注解做了什么?

标准答:它是组合注解,包含三个:

  • @SpringBootConfiguration:标识这是配置类(本质是 @Configuration)。
  • @EnableAutoConfiguration:开启自动装配。
  • @ComponentScan:扫描启动类所在包及其子包的组件(@Component/@Service 等)。

拓展

  • "为什么启动类要放最外层包?"——@ComponentScan 默认扫描启动类所在包及子包,放外层才能扫到所有业务包。
  • 可以用 @ComponentScan(basePackages=...) 自定义扫描范围。
  • @SpringBootApplication 的默认扫描不等于“扫描整个 classpath”;第三方 starter 的 Bean 主要靠自动配置导入,业务包则靠组件扫描。
  • @Configuration 默认启用配置类代理,配置方法之间直接调用时能复用容器中的单例;普通 @Component 里的 @Bean 方法不应被当作同样语义使用。
  • 扫描范围过大可能带来 Bean 重名和启动变慢,生产项目应保持根包清晰并用显式排除控制边界。

往项目引 ⭐:"我项目早期出过 Bean 注入失败——就是启动类放错了包、有些子包没被扫到。知道 @ComponentScan 的默认范围后,我们规范了包结构,启动类一定放在根包。" 深入答法@SpringBootApplication 只是组合入口,实际扫描和装配仍受包结构、过滤器和排除规则影响。@SpringBootConfiguration 让测试和启动器能找到主配置类,@ComponentScan 默认以该类所在包为根递归扫描。

@SpringBootApplication
@ComponentScan(basePackages = {"com.demo.order", "com.demo.common"})
class OrderApplication { }

边界:把启动类放在过深的子包会漏扫;把多个应用配置放在同一根包还可能产生 Bean 覆盖,建议明确模块边界并关闭意外覆盖。


12. 🟢 拦截器(Interceptor)和过滤器(Filter)的区别?

标准答

  • Filter:Servlet 规范的,在 DispatcherServlet 之前,能拿到原始 request/response,但拿不到 Spring 容器里的 Handler 信息。
  • Interceptor:SpringMVC 的,在 Controller 前后,能拿到 HandlerMethod(方法上的注解)、能注入 Spring Bean。 执行顺序:Filter → Interceptor → Controller → Interceptor → Filter。

拓展

  • "什么时候用哪个?"——需要操作 Spring Bean、拿方法注解(如权限注解)用拦截器;处理编码、跨域、底层请求包装用过滤器。
  • 拦截器有 preHandle/postHandle/afterCompletion 三个时机。
  • 还有 AOP 也能做类似切面,但粒度更细(方法级)。
  • Filter 能覆盖非 SpringMVC 请求(静态资源、错误派发),Interceptor 只在进入 DispatcherServlet 且匹配到处理器后才有意义;AOP 则可以覆盖非 Web 的 Service 调用。
  • preHandle=false 后,后续拦截器和 Controller 不会执行,但之前已执行拦截器的 afterCompletion 仍可能被调用;资源清理不要只写在 postHandle
  • 跨域预检 OPTIONS 必须在鉴权逻辑前正确放行,否则前端会表现为“业务接口没问题但浏览器跨域失败”。

往项目引 ⭐:"我项目用过滤器做请求日志和跨域(底层、所有请求都过),用拦截器做登录校验和权限(能拿到方法上的 @RequiresPermission 注解判断)——按'要不要 Spring 上下文和方法信息'来选。" 深入答法:Filter 先于 DispatcherServlet,适合读取/包装原始字节流;Interceptor 只在 SpringMVC 找到 Handler 后执行,可以读取方法注解。preHandle 返回 false 会短路后续链路,afterCompletion 即使 Controller 抛异常也可做清理。

registry.addInterceptor(authInterceptor)
        .addPathPatterns("/**")
        .excludePathPatterns("/login", "/error");

边界:不要在 Filter 里重复解析业务权限,也不要在 Interceptor 中读取已经被消费且未缓存的 request body;跨域预检必须在认证逻辑前正确放行。


13. 🟢 SpringBoot 怎么做统一异常处理和统一返回?

标准答

  • 统一异常:用 @RestControllerAdvice + @ExceptionHandler,集中捕获业务异常、参数校验异常、系统异常,返回统一格式。
  • 统一返回:定义 Result<T>(code/message/data),所有接口返回它,或用 ResponseBodyAdvice 自动包装。

拓展

  • 配合自定义业务异常(BusinessException)+ 错误码枚举,业务里直接 throw,由全局处理器转成友好响应。
  • 参数校验异常(MethodArgumentNotValidException)也在这里统一处理。
  • 系统异常要兜底(记日志 + 报警 + 返回通用错误),不能把堆栈暴露给前端。
  • 异常处理器要按“业务异常 → 参数异常 → 系统兜底”分层,返回稳定错误码;日志中记录 traceId、用户/租户标识和脱敏请求参数,响应体不要回传堆栈。
  • 统一返回不要重复包装文件流、SSE 和已经是 ResponseEntity 的结果;使用 ResponseBodyAdvice 时要定义明确的跳过规则。
  • 校验错误应返回字段级信息,系统异常返回通用提示并告警;不要为了“前端方便”把所有异常都返回 HTTP 200。
@RestControllerAdvice
class GlobalExceptionHandler {
    @ExceptionHandler(BusinessException.class)
    Result<Void> handleBusiness(BusinessException e) {
        return Result.fail(e.code(), e.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    Result<Void> handleValidation(MethodArgumentNotValidException e) {
        return Result.fail("PARAM_INVALID", e.getBindingResult().getFieldError().getDefaultMessage());
    }
}

往项目引 ⭐:"我项目所有接口异常都走全局处理器:业务异常返回友好提示、参数错误返回字段信息、系统异常记日志报警并返回通用错误码。前端拿到的永远是统一结构,开发体验好、也不泄露内部信息。" 深入答法:异常处理器要按“业务可预期异常 → 参数校验异常 → 未知系统异常”分层,返回稳定错误码而不是把 Java 异常类名暴露出去。统一返回包装要避免把文件下载、流式响应、已包装的 Result 再包一层。

@RestControllerAdvice
class ApiErrors {
  @ExceptionHandler(BusinessException.class)
  Result<Void> business(BusinessException e) { return Result.fail(e.code(), e.getMessage()); }
  @ExceptionHandler(Exception.class)
  Result<Void> system(Exception e) { log.error("request failed", e); return Result.fail("SYS_ERROR", "系统繁忙"); }
}

边界:日志里记录 traceId 和关键参数但要脱敏;校验错误应返回字段级信息,系统异常统一 5xx,不能为了“统一”把所有错误都返回 200。


14. 🔴 BeanFactory 和 ApplicationContext 的区别?

标准答

  • BeanFactory:最底层的容器接口,懒加载(getBean 时才创建),功能基础。
  • ApplicationContext:BeanFactory 的子接口,启动时就预实例化所有单例 Bean,并扩展了国际化、事件发布、资源加载、AOP 集成等企业级功能。 实际开发用 ApplicationContext。

拓展

  • "懒加载 vs 预加载的取舍?"——预加载启动慢但能提前暴露配置错误(启动就报错),生产更安全。
  • ApplicationContext 常见实现:ClassPathXmlApplicationContext、AnnotationConfigApplicationContext、SpringBoot 的 Web 容器。
  • BeanFactory 并不等于“永远懒加载”:是否预实例化还受 FactoryBean、作用域和显式配置影响;面试时先说默认行为,再说例外。
  • ApplicationContext 还负责事件、多语言、资源解析和环境配置,但这些扩展并不意味着它能替代业务层的配置中心或消息系统。
  • 在测试中可按需使用更小的 ApplicationContext,通过 slice test 或显式导入减少启动时间。

往项目引 ⭐:"我项目用的就是 ApplicationContext,启动时单例都初始化好,万一有 Bean 配错、依赖缺失,启动阶段就报错,而不是等到运行时某个请求才暴露——这对线上稳定很重要。" 深入答法ApplicationContext 通过组合 BeanFactory 获得依赖注入、生命周期和作用域,再增加事件、国际化、资源解析、环境属性等能力。默认 eager singleton 的行为可以用 @Lazy 改为按需创建,但懒加载会把配置错误推迟到第一次请求。

try (AnnotationConfigApplicationContext ctx =
     new AnnotationConfigApplicationContext(AppConfig.class)) {
  BillingService service = ctx.getBean(BillingService.class);
} // close 时触发销毁回调

边界:不要在业务代码到处拿 ApplicationContext 当 Service Locator;需要依赖时优先构造器注入,只有框架适配层才直接使用上下文。


15. 🟢 MyBatis 里 #{} 和 ${} 的区别?

标准答

  • #{}预编译占位符,MyBatis 会把它替换成 ? 用 PreparedStatement 设参数,能防 SQL 注入
  • ${}字符串直接拼接到 SQL 里,有注入风险,且不能享受预编译缓存。 能用 #{} 就别用 ${}

拓展

  • "${} 什么时候不得不用?"——动态表名、动态列名、order by 字段这种没法用占位符的地方,但要自己做白名单校验防注入。
  • "为什么 #{} 能防注入?"——参数是作为值传给数据库的,不会被当 SQL 语法解析。
  • #{} 还会根据 JDBC 类型做参数设置,能正确处理日期、null 和特殊字符;${} 在 SQL 生成阶段就完成拼接,可能导致执行计划和日志内容不可控。
  • 动态排序可用枚举映射“外部字段 → 内部列名”,不要把前端传入的字符串原样放进 ${};动态表名也要限制到固定租户/分表集合。

往项目引 ⭐:"我项目里查询条件全用 #{};只有一个动态排序字段是前端传的、必须用 ${} 拼,我先做了字段白名单校验(只允许指定几个列名),从根上防 SQL 注入。" 深入答法#{} 经过类型处理器绑定为 JDBC 参数,值不会参与 SQL 语法解析;${} 在 MyBatis 生成 SQL 文本阶段直接替换,所以动态标识符必须把可选值限制在后端枚举中。

<select id="findPage" resultType="Order">
  select * from orders
  where tenant_id = #{tenantId}
  order by ${safeColumn} ${safeDirection}
</select>
Set<String> columns = Set.of("created_at", "amount");
if (!columns.contains(column)) throw new IllegalArgumentException();

边界:白名单校验要在进入 Mapper 前完成,不能只校验前端下拉框;表名、列名和排序方向都属于 ${} 风险面。


16. 🟢 MyBatis 的一级缓存和二级缓存?

标准答

  • 一级缓存:SqlSession 级别,默认开启。同一个会话里查相同 SQL 走缓存。增删改、提交、关闭会话后失效。
  • 二级缓存:Mapper(namespace)级别,跨 SqlSession,需手动配置开启。

拓展

  • "二级缓存为什么实际项目少用?"——分布式多实例下,各实例的二级缓存不共享、容易脏,所以一般用 Redis 统一做缓存。
  • 一级缓存在不同会话间不共享,所以并发下也可能读到旧数据,要注意。
  • 一级缓存的 key 受 statement、参数、分页和环境影响;同一 SqlSession 中执行更新后才会清理相关缓存,不能把它当跨请求缓存。
  • 二级缓存要求实体可序列化,更新语句会按 namespace 清缓存;多个 Mapper 共同读写同一张表时仍可能出现跨 namespace 的一致性问题。
  • 开启缓存前先确认数据是否允许短暂旧读,并配置容量、TTL 和失效策略;强一致数据通常交给数据库或统一缓存层。

往项目引 ⭐:"我项目没用 MyBatis 二级缓存,统一用 Redis 做缓存——因为服务是多实例部署,二级缓存在节点间不一致,Redis 才能保证所有实例看到同一份。" 深入答法:一级缓存的 key 包含 mapped statement、SQL、参数、分页和环境等信息,默认绑定单个 SqlSession;二级缓存按 namespace 共享,要求对象可序列化,并且写操作会按 namespace 清理。缓存命中不代表数据永远新鲜。

try (SqlSession s = factory.openSession()) {
  s.selectOne("OrderMapper.find", 9L); // 数据库
  s.selectOne("OrderMapper.find", 9L); // 一级缓存
}

边界:批量更新、跨服务写入和多实例部署容易造成脏数据;需要明确 TTL、主动失效和一致性策略,通常把 Redis 作为统一缓存并配版本号/消息失效。


17. 🟢 怎么解决跨域问题?

标准答:跨域是浏览器同源策略(协议+域名+端口任一不同)的限制,本质是后端返回 CORS 响应头放行。方式:

  • @CrossOrigin 注解(方法/类级)。
  • 全局配置 WebMvcConfigurer.addCorsMappings
  • 网关/Nginx 层统一加 CORS 头(推荐,省得每个服务配)。

拓展

  • 关键响应头:Access-Control-Allow-Origin-Methods-Headers-Credentials
  • 复杂请求会先发 OPTIONS 预检请求。
  • 带 cookie 的跨域,Allow-Origin 不能是 *,要指定具体域名。
  • CORS 是浏览器的访问控制,不是服务端防火墙;非浏览器客户端通常不会自动遵守它,鉴权和 CSRF 防护仍要独立设计。
  • 生产环境应维护允许来源白名单,不能把 Origin 原样回显;预检响应可设置合理缓存时间,但变更域名后要及时刷新。
  • 网关统一配置时要避免重复添加响应头,并确认错误响应和 OPTIONS 也带上 CORS 头,否则前端只能看到“网络错误”。

往项目引 ⭐:"我项目前后端分离,统一在网关层配 CORS,比每个微服务各配一遍省事、也好维护——改一处所有服务生效。" 深入答法:CORS 是浏览器的读取授权机制,不等于服务端真正阻止跨域请求。简单请求直接带 Origin;非简单请求先发 OPTIONS,服务端必须返回允许的方法、头和缓存时长,再接受正式请求。

@Bean WebMvcConfigurer cors() {
  return new WebMvcConfigurer() {
    public void addCorsMappings(CorsRegistry r) {
      r.addMapping("/api/**").allowedOrigins("https://app.example.com")
       .allowedMethods("GET", "POST").allowedHeaders("*")
       .allowCredentials(true).maxAge(3600);
    }
  };
}

边界:allowCredentials(true) 时不能把 allowedOrigins 写成 *;网关和服务重复加头会出现浏览器拒绝的多值响应。


18. 🔴 BeanPostProcessor 有什么用?

标准答:是 Bean 初始化前后的扩展点,能对每个 Bean做增强处理(postProcessBeforeInitialization / AfterInitialization)。Spring 的很多功能靠它实现——AOP 代理生成、@Autowired/@Value 注入(AutowiredAnnotationBeanPostProcessor)。

拓展

  • "和 BeanFactoryPostProcessor 区别?"——后者在 Bean 实例化之前改 Bean 定义(元数据,如 PropertyPlaceholderConfigurer 替换占位符),前者在 Bean 实例化之后改实例。
  • 自定义 BeanPostProcessor 可以给特定 Bean 统一做初始化。
  • 后置处理器的返回值可能是代理对象;如果错误地返回 null 或重复包装,会导致后续处理器拿不到 Bean,甚至出现注入类型不匹配。
  • 处理器本身会在容器早期创建,依赖普通业务 Bean 可能形成初始化顺序问题;必要时使用 BeanFactory 延迟获取。
  • postProcessBeforeInitialization 不是“实例化前”,不要在这里修改 BeanDefinition;需要改元数据应使用 BeanFactoryPostProcessor

往项目引 ⭐:"我项目自定义过 BeanPostProcessor——给带某个自定义注解的 Bean 统一注册到本地事件总线,不用每个 Bean 自己写注册逻辑。理解扩展点让我能优雅地做这种横切初始化。" 深入答法:BeanPostProcessor 由容器统一回调,返回值可以是原对象,也可以是代理对象;多个处理器按 PriorityOrderedOrdered 和普通顺序执行。它适合做通用增强,不适合依赖尚未初始化的其他业务 Bean。

@Component
class AuditPostProcessor implements BeanPostProcessor {
  public Object postProcessAfterInitialization(Object bean, String name) {
    return bean.getClass().isAnnotationPresent(Audited.class)
        ? auditProxy(bean) : bean;
  }
}

边界:在处理器里直接调用 getBean 可能触发循环依赖;需要改 BeanDefinition 应用 BeanFactoryPostProcessor,需要读取配置则通过 Environment 注入。


19. 🟢 Spring 里用到了哪些设计模式?

标准答

  • 工厂:BeanFactory/ApplicationContext 生产 Bean。
  • 单例:Bean 默认单例。
  • 代理:AOP 动态代理。
  • 模板方法:JdbcTemplate、RestTemplate。
  • 观察者:事件机制(ApplicationEvent/Listener)。
  • 适配器:HandlerAdapter 适配不同类型的 Controller。
  • 装饰器:BeanWrapper。

拓展

  • 结合具体类说,比只报名词强。
  • 这题常引到"你项目用了什么设计模式"。
  • JdbcTemplate 的模板方法把连接、异常转换和资源释放固定下来,只把 SQL/回调交给调用者;ApplicationEvent 则把发布者和监听者解耦,但事件默认是同步执行的。
  • Spring 的模式通常组合使用:Factory 负责创建,Proxy 负责增强,Strategy/Template 负责替换算法;面试时要说“模式 + 具体类 + 解决的问题”。

往项目引 ⭐:"我项目自己也用策略 + 工厂做多渠道支付——思路就是跟 Spring 学的:面向接口 + 容器管理实现 + 按 key 路由。新增渠道只加实现类、不改原有代码,符合开闭原则。" 深入答法:设计模式不是给 Spring 贴标签,而是解释它如何隔离变化:工厂隔离对象创建,代理隔离横切逻辑,模板方法固定流程、把步骤交给子类,观察者把发布者和监听者解耦。面试时说出“类 + 变化点 + 代价”更有说服力。

模式Spring 例子适合解决的变化
工厂BeanFactory创建方式变化
代理AOP调用前后增强
适配器HandlerAdapter多种 Handler 接口
观察者ApplicationEvent新增监听方
边界:模式也有成本;事件会引入异步/最终一致,代理会影响调试和自调用,不能为了“用模式”而增加抽象层。

20. 🔴 Spring 有哪些常用扩展点?(框架集成必问)

标准答

  • BeanFactoryPostProcessor:改 Bean 定义。
  • BeanPostProcessor:改 Bean 实例。
  • InitializingBean / @PostConstruct:初始化回调。
  • ApplicationListener / @EventListener:监听容器事件。
  • ApplicationContextAware / 各种 Aware:拿容器和环境。
  • ImportSelector / @Import:批量导入配置(自动装配就用它)。
  • FactoryBean:自定义复杂 Bean 的创建逻辑(MyBatis 的 Mapper 就是 FactoryBean 造的)。

拓展

  • 中间件集成 Spring(MyBatis、Dubbo、各种 starter)基本都靠这些扩展点。
  • SmartInitializingSingleton 在所有单例初始化完后回调,适合做"全部就绪后"的动作。
  • 扩展点有明确时序:BeanFactoryPostProcessor → Bean 实例化/注入 → BeanPostProcessor → 初始化回调 → 事件通知。选错时机通常表现为拿不到依赖或代理未生效。
  • ImportSelector 适合按条件批量导入配置,ImportBeanDefinitionRegistrar 适合动态注册 BeanDefinition,FactoryBean 适合把复杂创建逻辑封装成一个可注入对象。
  • 自定义扩展要关注幂等、启动耗时和关闭逻辑;不要在容器刷新阶段执行不可重试的远程调用。

往项目引 ⭐:"我项目用 ApplicationListener 监听容器启动完成事件,做服务预热和向注册中心上报的初始化动作;MyBatis 的 Mapper 接口能注入也是靠 FactoryBean——理解扩展点让我看得懂这些'魔法'。" 深入答法:扩展点要按介入时机选择:改元数据用 BeanFactoryPostProcessor,改实例用 BeanPostProcessor,批量导入配置用 ImportSelector,自定义实例生产用 FactoryBean,应用就绪后动作可监听 ApplicationReadyEvent。不同回调的线程和异常语义不同,初始化失败应让启动失败而不是静默吞掉。

@Component
class ReadyListener {
  @EventListener(ApplicationReadyEvent.class)
  void ready() { warmUp(); }
}

边界:不要用 ApplicationContextAware 取代正常依赖注入;扩展点要有幂等性,应用重启、测试上下文缓存和多实例部署都可能重复触发。


你能答到第几层?

  • 三段都能答、还能往项目引:Spring 这块你稳了。
  • 标准答 + 拓展能成体系答:知识够,差接到项目说出来。
  • 标准答都磕巴:Spring 大但有主线(IoC/AOP → Bean 生命周期 → 事务 → SpringBoot 自动装配 → 扩展点),跟着学一遍就通。

这是面试专题的「Spring 全家桶篇」,网站上还有并发、MySQL、Redis、微服务、项目场景等系统整理。 🌐 更多真实面试专题与资料:smallredtech.com 💬 想系统学 / 简历与辅导咨询,加微信:Ahongbb666(备注「面试题」)