做技术面试这几年,我发现一个特别有意思的规律:凡是简历上写了Spring、Spring Boot的候选人,十个里有九个会在“Bean的一生”“循环依赖”“事务失效”这几个地方卡壳。不是大家不懂,而是很多知识点平时都在用,真到了要梳理为什么、讲清楚内部原理的时候,一下子就没话了。Spring-必问这个话题,其实就是面试官用来判断一个人是“会用框架”还是“理解框架”的分水岭。
这篇文章我不打算按教科书顺序罗列,而是把我自己面试别人和被别人面试时,真正高频出现、且能拉开差距的Spring核心问题,逐个拆开讲清楚。每个点都会说原理、讲场景、给排查思路,也会把我踩过的坑和现在复盘觉得“当时要是这么答就好了”的经验写出来。不管你是准备春招秋招的应届生,还是工作两三年想系统梳理一下知识体系的开发,这篇文章都能帮你把Spring最容易被问到的硬骨头啃下来。
1. 容器与Bean生命周期:面试官最爱从这切入
1.1 为什么几乎所有Spring面试都从IoC容器开始
面试官问Spring,十有八九先问IoC。这不是没话找话,而是IoC容器是整个Spring的地基。你连Bean是怎么被创建、怎么被管理的都说不清,后面聊AOP、事务、Spring Boot自动装配,全都是空中楼阁。
IoC(Inversion of Control,控制反转)的核心思想其实很简单:对象的创建和依赖关系的维护,不再由业务代码自己new、自己管理,而是交给容器统一处理。生活化类比一下:以前你自己做饭,得买菜、洗菜、切菜、炒菜;用了IoC容器,相当于你直接去饭店点菜,厨师(容器)把一切都准备好端上来。你要什么菜、要什么口味,只需要告诉容器就行。
所以面试时别只背“控制反转是把创建对象的权力交给容器”这种一句话定义。更好的回答是:IoC容器启动时会读取配置(XML或者注解),根据Bean定义创建对象、注入依赖,并且管理这些Bean的完整生命周期。这样做的好处一是解耦,调用方不需要关心依赖对象是怎么创建的;二是统一管理,单例、延迟加载、销毁回调这些横切逻辑都由容器兜底。
1.2 Bean生命周期里最容易漏掉的环节
Bean生命周期是Spring面试的保留项目。很多候选人能背出“实例化、属性赋值、初始化、销毁”这四个大阶段,但一旦追问到具体的扩展点,就乱了。
我梳理一个比较完整的Bean生命周期主线(以单例Bean为例):
- BeanDefinition的解析与注册:Spring扫描到@Component等注解后,会生成BeanDefinition并注册到容器。
- 实例化:通过反射调用构造器创建Bean实例,默认是无参构造。
- 属性填充:把@Autowired、@Value等依赖注入进去,这一步也包含对Aware接口的处理,比如BeanNameAware、ApplicationContextAware。
- BeanPostProcessor的before初始化回调:比如@PostConstruct注解的初始方法就在这个阶段之前的后置处理器里被调用。
- 初始化阶段:先走InitializingBean的afterPropertiesSet,再走自定义initMethod。
- BeanPostProcessor的after初始化回调:这里最关键,AOP动态代理的生成就发生在这个阶段。
- 使用Bean。
- 容器关闭时销毁:走DisposableBean.destroy和自定义destroyMethod。
面试时如果你能主动把BeanPostProcessor在“初始化前后”的位置点出来,以及说明AOP代理是在“初始化后”这一步通过AbstractAutoProxyCreator实现的,面试官基本就知道你是真看过源码的人。这个细节我建议每个人都去源码里亲自确认一遍,因为讲不讲的出来,效果差很多。
2. 依赖注入与循环依赖:三级缓存是怎么兜底的
2.1 构造器注入还是字段注入,这是个送命题
依赖注入的几种方式里,字段注入(@Autowired直接打在字段上)、setter注入、构造器注入,面试官会问你推荐哪种。这个没有绝对答案,但你要有自己的立场。
字段注入写起来最爽,但问题在于依赖对外不可见、容易破坏不可变设计、也不利于单元测试。构造器注入是Spring官方文档推荐的方案,好处是Bean在创建时依赖就必须齐全,能保证不可变性,Spring Boot的文档和IDE插件也都会提示你这么做。setter注入算是折中,适合可选依赖的场景。
我个人在项目里的习惯是:核心依赖全部用构造器注入,如果某个依赖是可选的要给默认实现,才考虑setter。面试时我会明确说这个立场,然后补充一句Spring 4.3之后,如果类只有一个构造器,可以省略@Autowired注解,Spring会自动选择那个构造器。这个小细节很多人不知道,能说出来很加分。
2.2 三级缓存解决循环依赖的原理与局限
循环依赖是个经典中的经典:A依赖B,B又依赖A。Spring单例Bean默认能解决这种构造器之外的循环依赖,靠的就是三级缓存。
三级缓存对应三个Map:
- 一级缓存(singletonObjects):存放完整的、已经初始化好的成品Bean。
- 二级缓存(earlySingletonObjects):存放提前暴露的半成品Bean,即已经实例化但还没完成属性填充的对象。
- 三级缓存(singletonFactories):存放ObjectFactory工厂,用于生成提前暴露的对象。
核心流程我用自己的话描述一遍:创建A时,实例化A之后,发现A需要注入B,于是去创建B;B创建时发现需要注入A,此时A对B不可见,因为A还没完全初始化好。注意,A在实例化后,会先把自己包成一个ObjectFactory放入三级缓存,这个工厂在调用getEarlyBeanReference时才会决定是直接返回原始对象,还是生成代理对象。
所以B注入A时,能从三级缓存里拿到A的工厂,调用工厂得到A的早期引用(半成品),把这个引用注入到B中。B初始化完成后,A再继续完成自己的属性填充和初始化,最终把成品放入一级缓存。
这里面最关键的点在于:默认只支持单例、非构造器注入的循环依赖。如果是构造器注入,A实例化时就得注入B,B也得在构造时注入A,两边都缺实例,三级缓存根本帮不上忙。原型Bean(Prototype)的循环依赖也无法解决,因为容器不缓存原型Bean,只有创建时才能拿引用。这些局限面试时主动说出来,显得你理解到了边界。
2.3 循环依赖排查的实操经验
我真实项目里遇到过几次循环依赖,最典型的是两年前接手一个老模块,改造定时任务组件时,A组件要注入B组件做数据处理,B组件又反向依赖A组件做结果回调。启动直接报错,日志里会明确提示:The dependencies of some of the beans in the application context form a cycle。
排查思路我总结过三条:
- 先看报错信息里列出的Bean名,把循环链条画出来,通常是一个A -> B -> C -> A的环。
- 判断环里有没有构造器注入。如果有,优先改造成setter注入或者字段注入,但我不推荐引入字段注入,更建议的方案是重构:打破环。
- 环的破法一般是引入中间层或者使用@Lazy:在被循环依赖的构造器参数上加@Lazy,让其中一个Bean延迟获取代理,而不是在启动时严格构造。
我踩过一次的坑是:明明加了@Lazy,启动成功了,但运行时调那个Bean时发现代理对象一直创建不出来,最后发现是标注位置错了。@Lazy不是打在字段上的,而是打在构造器参数上,即public B(A a)改成public B(@Lazy A a),这样Spring会注入一个A的延迟代理,真正调用时才会创建目标Bean。这个细节书上很少展开,但面试官问到“你会怎么解决循环依赖”的时候,你把这个踩坑经历讲出来,非常加印象分。
3. AOP与事务:Spring里最容易被追问“为什么”的地方
3.1 JDK动态代理和CGLIB的选择逻辑
AOP的底层原理不用死记硬背,关键是理解代理模式。Spring AOP在运行时生成代理对象,调用目标方法时先经过代理,代理里执行切面逻辑,再调用真实方法。
代理有两种实现:JDK动态代理和CGLIB。JDK动态代理要求目标类实现接口,它基于接口生成代理类,性能上反射调用有一定开销。CGLIB通过生成目标类的子类实现代理,不需要接口,但如果目标方法是final的,CGLIB无法重写,因而无法代理。Spring Boot 2.x之后默认使用CGLIB,即使实现类有接口也走CGLIB,这是为了统一行为,避免接口代理模式下拿不到实现类上的注解信息。
实际项目中,我遇到过CSDN上经典的问题:同一个类里,方法A调用方法B,B上面标了@Transactional,结果事务没生效。这个问题的原理就是:Spring AOP生成的是当前类的代理对象,但类内部方法之间的self-invocation走的是this,而不是代理对象,因此切面根本不会触发。
解决方式有几种:最推荐的方法是拆分,把需要事务的方法放到另一个Bean里;或者通过AopContext.currentProxy()获取当前代理对象再调用,但需要在配置里设置exposeProxy=true;还有一种是在类内部注入自身的代理引用,我见过有人这么干,但容易造成循环依赖,不推荐。
3.2 Spring事务失效的六大经典场景
事务这块,面试官最喜欢让你列举“哪些场景下Spring事务会静默失效”。我把高频答案整理成列表,每个都建议能结合自己的项目讲一句:
- 方法不是public:Spring事务默认只代理public方法,private和protected不生效。
- 自调用问题:同类中方法之间调用,事务注解被绕过。
- 异常被吞掉:catch了异常但没有抛出RuntimeException,Spring默认只对RuntimeException和Error回滚,受检异常默认不回滚。
- 数据库引擎不支持事务:比如MySQL的MyISAM引擎,DDL里写了事务也没用。
- 多线程环境下传播:子线程里抛异常,主线程捕获后,事务不一定能感知并回滚。
- 事务方法被final修饰:CGLIB无法重写final方法。
我记得最深的一次线上事故:一个数据统计的定时任务,分批处理几千条数据,中间某批数据处理失败,结果前面所有批次都成功提交了,只有失败那一批回滚。排查下来是传播行为设置成了REQUIRES_NEW,导致整个任务的几个事务段完全隔离,前面已经提交的数据无法回滚。这说明事务传播行为不是随便配的,REQUIRED和REQUIRES_NEW差别非常大。面试聊到事务,我会主动提到这个事故,然后解释传播行为选型要考虑业务语义,单纯为了“独立提交”而用REQUIRES_NEW,小心脏数据一致性上翻车。
3.3 事务传播行为速查与选型心得
事务传播行为描述了事务方法之间调用时,事务如何传递。最常用的是REQUIRED(默认):如果当前没有事务就新建,如果有就加入当前事务。REQUIRES_NEW:无论当前有没有事务都新建一个,外层事务挂起。
为什么要用REQUIRES_NEW?典型的场景是审计日志:你的业务事务执行完成后,需要记录一条日志,但日志记录不能受业务事务影响。如果业务事务回滚了,日志也必须成功记录,这时就要让日志方法跑在REQUIRES_NEW里。
另一个容易被问到的是嵌套调用和回滚之间的关系。比如外层方法A开了事务,内层方法B用REQUIRES_NEW又开了一个事务。A调了B,B抛异常回滚了,如果A没有捕获B的异常,A也会跟着回滚。但如果A捕获了B的异常,A自己继续执行,那么只会B回滚,A那条事务还能继续提交。这个行为很微妙,很多人想当然地以为B回滚了A就一定会回滚,其实取决于异常是否向上传播。
我整理了一个简单的选型原则:默认用REQUIRED,只有当明确需要独立事务时才用REQUIRES_NEW,用之前想清楚两个事务是否真的需要彼此隔离。SUPPORTS、NOT_SUPPORTED这些用得少,面试能说出它们的语义就够了。
4. Spring MVC处理流程与自动装配:从请求到响应的全景拆解
4.1 一次HTTP请求的完整旅程
Spring MVC的问题,面试官不会只让你背DispatcherServlet,而是会让你把一次请求从进入容器到返回响应的全过程讲出来。我习惯按下面这个链路来展开:
- 客户端发送HTTP请求,由Servlet容器(如Tomcat)接收。
- 请求进入DispatcherServlet(前端控制器),它是整个MVC的核心入口。
- DispatcherServlet通过HandlerMapping找到处理该请求的Controller方法(即Handler)。
- 通过HandlerAdapter调用实际的Controller方法,执行前还会经过一系列拦截器(Interceptor)的preHandle。
- Controller方法执行,返回ModelAndView或者@ResponseBody直接返回数据。
- 如果是视图渲染,ViewResolver解析逻辑视图名找到真实模板并渲染;如果是@ResponseBody,则由HttpMessageConverter把对象序列化成JSON返回。
- 拦截器执行postHandle和afterCompletion,最终把响应写回客户端。
这里我建议候选人重点讲两个环节:HandlerMapping的作用是把请求路径映射到Handler,HandlerAdapter的作用是适配不同种类的Handler(比如@Controller方法、HttpRequestHandler)。这两个名字容易混淆,能分清楚说明你真的读过源码里的组件命名逻辑。
4.2 从Spring到Spring Boot,自动装配是怎么实现的
Spring Boot的自动装配是必问点。它的核心机制可以拆成几个层次来说。
首先是@SpringBootApplication注解,它是个组合注解,相当于@Configuration、@EnableAutoConfiguration、@ComponentScan的组合。其中@EnableAutoConfiguration是自动装配的总开关。
@EnableAutoConfiguration通过@Import引入了AutoConfigurationImportSelector,这个类负责读取META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件(新版本)或者spring.factories(老版本),取出所有自动配置类(AutoConfiguration)。
但这些自动配置类不是无条件生效的。每个AutoConfiguration类上通常有@ConditionalOnClass、@ConditionalOnProperty等条件注解,意思是“当类路径下存在某个类时才装配这个配置”。这就是为什么你引入了spring-boot-starter-web,Spring Boot才自动配置Tomcat和DispatcherServlet;引入了spring-boot-starter-data-redis,RedisTemplate才被自动创建。
面试时如果能把“条件化装配”这个点讲透,比单纯背诵自动配置流程强很多。我之前面过一个候选人,他能说出AutoConfiguration.imports的路径和条件注解的作用,甚至能举出自己在项目里写自定义Starter时用@ConditionalOnClass做兜底配置的例子,这基本就是百度文库里没有的实战经验了。
4.3 自定义一个Starter怎么设计
聊完自动装配,面试官经常会顺势问“如果让你设计一个公司内部的Starter,你怎么做”。这个问题很开放,考察的是对自动装配机制的实际应用能力。
我的回答框架是这样的:核心功能封装成AutoConfiguration类,通过条件注解控制生效范围;对外暴露可以通过application.yml配置的属性,用@ConfigurationProperties绑定;确保用户有自定义Bean时,我们的自动配置通过@ConditionalOnMissingBean自动退让。
举个例子,我在某公司做过一个统一日志Starter:核心是LogFilter和LogAspect,AutoConfiguration里导入这两个Bean,通过log.collect.enabled这个配置项控制是否开启。用户如果自己定义了LogFilter,自动配置的@ConditionalOnMissingBean就检测到已有Bean,不再重复注册。这种设计既保留了默认能力,又给使用方留了覆盖入口,是最稳妥的扩展方式。
5. 高频追问场景:手写简化版IoC容器
5.1 为什么面试官让你手写一个IoC容器
在一些偏架构或纯Java方向的面试里,Spring必问的高潮往往是“你能否手写一个简化版的IoC容器”。这不是让你把Spring源码默写出来,而是考察你对Bean生命周期、依赖注入、单例缓存等核心概念是否真的内化。
我当时准备这个点的时候,写了一个核心不超过200行的简化容器,主要包含这么几个能力:
- 根据包路径扫描带有@MyComponent注解的类。
- 缓存单例实例,相当于一级缓存。
- 通过反射执行字段注入,识别@MyAutowired注解。
- 处理构造器注入的循环引用,用早期的ObjectFactory或者提前实例化的方式打破循环。
下面是当时思路的核心伪代码,可以参考:
import java.lang.reflect.Field; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class SimpleContainer { // 单例池:模拟一级缓存 private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(); public Object getBean(Class<?> clazz) throws Exception { String beanName = clazz.getName(); if (singletonObjects.containsKey(beanName)) { return singletonObjects.get(beanName); } // 提前实例化并放入池子,用来打破循环依赖 Object instance = clazz.getDeclaredConstructor().newInstance(); singletonObjects.put(beanName, instance); // 注入依赖 for (Field field : clazz.getDeclaredFields()) { if (field.isAnnotationPresent(MyAutowired.class)) { field.setAccessible(true); Object dependency = getBean(field.getType()); field.set(instance, dependency); } } return instance; } }这一段代码虽然简陋,但它把Spring最核心的心智模型表达出来了:容器先实例化对象、放进缓存,再补依赖。这也是三级缓存的雏形——Spring把这里的“放入缓存”拆成三级,是因为要在代理生成、初始化完成度上做更细的控制。
5.2 从手写容器反推Spring设计哲学
写完简化容器之后,我反而对Spring源码里的不少“奇怪设计”有了更深的理解。比如为什么三级缓存里存的是ObjectFactory而不是Object本身?因为Spring不想让所有Bean都提前执行代理逻辑,只有遇到循环依赖、确实需要提前暴露对象时,才通过ObjectFactory触发getEarlyBeanReference生成代理。
再比如为什么Spring容器的启动过程有那么多扩展点?因为容器不能把所有逻辑都写死,BeanFactoryPostProcessor、BeanPostProcessor这些机制都是留给框架使用者的“钩子”,让容器变得可扩展。手写容器时你不会有这个意识,因为场景单一,但一旦要考虑“不同模块在容器启动时做不同的事”这种真实诉求,就会明白Spring为什么不把机制做封闭。
面试时如果被问到“你从这个手写容器里学到了什么”,我会说:Spring的复杂度本质上来自它对扩展性的极致追求,而理解设计意图比记住实现细节更重要。这个回答往往能让面试官觉得你是有思考深度的人,而不是刷题背答案的选手。
6. 面试问答中的表达技巧与避坑指南
6.1 怎样回答源码题才不显得背书
Spring的必问问题本质上都是源码题,但面试官最怕的就是候选人一个字不差地背答案。同样是讲Bean生命周期,背概念的人会说“先实例化、后属性赋值、再初始化”,理解的人会说“实例化后用BeanPostProcessor先做一轮处理,填充属性,初始化前后都有回调钩子,AOP代理就藏在初始化后的那个后置处理器里”。
差别就在于有没有把“为什么这样设计”串起来。我建议准备这类问题时,不要背源码注释翻译,而是用自己的话把因果链条讲清楚。比如谈到三级缓存,你直接说“一级缓存放完整Bean,二级缓存放半成品,三级缓存放工厂”这种结论是没用的,你要说出来:半成品只有在循环依赖发生时才有意义,而工厂的存在是为了延迟生成代理。
另外一个实战经验是:面试官问到某个机制时,尽量补一个自己项目中的应用场景。比如问AOP,你可以主动说曾经用@Around记录接口耗时和入参出参;问事务传播,你就说在批量任务里因为用了REQUIRES_NEW导致部分提交的事故。案例是表达理解的载体,干讲机制很容易让人听出你在背题。
6.2 这些问题被连环追问时怎么招架
Spring面试的连环追问节奏通常是:IoC容器基本概念 -> Bean生命周期 -> BeanPostProcessor -> AOP代理生成时机 -> 事务失效场景 -> 循环依赖。一个问题接一个问题,逐层深入。
面对连环追问,我的原则是:概念题简洁作答,机制题主动展开,场景题结合案例。概念题像“什么是IoC”这种,两三句话讲清楚即可,不要长篇大论,留出空间让面试官继续深挖。机制题不要绕弯子,按因果链讲,逻辑清晰比术语华丽重要。场景题是拉开差距的机会,一定把“当时的背景、怎么排查的、最后怎么解决”完整说出来。
特别强调一个面试禁忌:不懂装懂。Spring的问题非常容易问到边界,比如问“构造器循环依赖为什么三级缓存也解决不了”。如果你不太确定,就诚实说“这块我有印象但没深挖”,好过支支吾吾瞎编。工程领域的面试官其实很宽容“不知道”,但对“编造原理”非常敏感。
6.3 我复盘过后觉得最值得准备的三个深挖点
基于这几年我和候选人聊Spring的体会,我总结三个值得花时间去源码里确认答案的深挖点:
第一个是BeanPostProcessor的执行顺序。Spring里有很多内置的PostProcessor,比如AutowiredAnnotationBeanPostProcessor、AbstractAutoProxyCreator等,它们的执行顺序直接影响最终生成的Bean是什么样。理解这个顺序,就能理解为什么属性注入和AOP能叠加在同一Bean上。
第二个是BeanFactory和ApplicationContext的区别。很多人把两者混为一谈。ApplicationContext是BeanFactory的子接口,额外提供了国际化、事件发布、资源加载等能力,还预实例化了所有单例Bean。面试官只问一句“你用的ApplicationContext和底层的BeanFactory是什么关系”,就能筛掉一大批人。
第三个是Spring事件机制与观察者模式的关系。@EventListener注解、ApplicationEventPublisher发布事件,这套机制在项目中很有用,比如订单创建后发消息通知、同步缓存等场景。虽然不算高频必问,但能答出来会让人觉得你的知识面是成体系的,而不是只盯着那几个最常背的八股。
准备Spring面试,我最大的体会是:不要以“背题”为目标,而是以一个“能独立设计并讲清楚一个简化容器”为终点。如果你能做到后者,Spring的绝大部分面试题对你来说就是沿着自己的知识树自然延伸出来的,怎么问都不会慌。这个思路也推荐给正在准备面试的你——少刷一道偏题,多花一小时把Bean生命周期画一遍、把三级缓存手写一遍,回报绝对比预期高。