Appearance
Day14 Spring 核心:IOC、AOP、Bean 生命周期、循环依赖
📖 基础知识讲解【基础原理 + 高级深挖知识点】
基础原理
1. 全景:没有 Spring 的世界长什么样
面试问「Spring 解决了什么问题」,背「解耦」三个字是不够的。真正要讲清的是:手动 new 对象时,到底出现了哪三类无法消除的痛点。
text
【无 Spring 时代】
UserController
│ new UserServiceImpl() ← 痛点1:编译期写死实现类,换实现要改代码
▼
UserServiceImpl
│ new UserDaoImpl(new DataSource(url, user, pwd))
▼ ← 痛点2:依赖链越深,装配代码越长
UserDaoImpl
│ 手写 try/catch + conn.commit() + conn.rollback()
▼ ← 痛点3:事务/日志/权限代码散落在每个方法里
【有 Spring 时代】
Spring 容器(IOC Container)
│ 读配置 → 生成 BeanDefinition → 实例化 → 注入 → 初始化
▼
┌ 负责"造对象 + 连对象":IOC / DI 解决痛点 1、2
└ 负责"给对象加能力":AOP 解决痛点 3
│
▼
UserController ← 注入 ← UserService 代理 ← 注入 ← UserDao一句话总结:Spring 核心就两件事 —— IOC 管对象的"生与连",AOP 管对象的"增与强"。Bean 生命周期是 IOC 的执行细节,循环依赖是 IOC 的疑难杂症,事务是 AOP 最典型的落地产物。
为什么面试必考这四块? 因为它们串成一条完整因果链:
| 知识点 | 在链路中的位置 | 不懂它会在线上踩什么坑 |
|---|---|---|
| IOC / DI | 对象从哪来、怎么连上 | 注入了接口的错误实现、@Autowired 歧义启动失败 |
| Bean 生命周期 | 对象从创建到可用中间经过哪些钩子 | 在构造器里用注入的字段 → NPE;初始化顺序错乱 |
| AOP | 对象被容器悄悄换成了代理 | 自调用事务失效、切面不生效、代理类型转换异常 |
| 循环依赖 | IOC 装配顺序打结时容器怎么自救 | Spring Boot 2.6+ 启动直接报 BeanCurrentlyInCreationException |
2. IOC 控制反转:到底"反转"了什么
很多人把 IOC 和 DI 混为一谈。准确说法是:IOC 是一种设计思想(目标),DI 是实现这种思想的手段。
📌 单独澄清:反转的是"控制权",不是"创建动作"
对象终究还是要被 new 出来的,Spring 底层也是走反射调构造器。IOC 反转的是**"谁来决定什么时候 new、new 哪个实现、new 完连给谁"**这个决策权。
| 维度 | 传统方式(控制权在业务代码) | IOC 方式(控制权在容器) |
|---|---|---|
| 谁决定创建时机 | 业务代码执行到 new 那一行 | 容器启动阶段(单例)或 getBean 时(多例) |
| 谁决定用哪个实现类 | 代码里写死 new UserServiceImpl() | 配置 / 注解 / 条件装配决定 |
| 谁负责装配依赖 | 调用方手动一层层传进去 | 容器递归解析依赖图自动注入 |
| 换实现的成本 | 改源码 + 重新编译 | 改配置或换一个 @Service 实现 |
java
// ❌ 控制权在自己手上:UserController 必须"认识"具体实现类
public class UserController {
// 编译期依赖 UserServiceImpl,换成 UserServiceMockImpl 就得改这一行
private UserService userService = new UserServiceImpl(new UserDaoImpl());
}
// ✅ 控制权交给容器:UserController 只声明"我需要一个 UserService"
@RestController
public class UserController {
private final UserService userService; // 只依赖接口
public UserController(UserService userService) { // 容器在实例化时把实现塞进来
this.userService = userService;
}
}DI 依赖注入的三种形态(下一节细讲差异):
text
构造器注入 → 依赖作为构造参数传入,对象"出生即完整"
setter 注入 → 先无参造出对象,再调 setXxx() 填充
字段注入 → 直接反射写字段(@Autowired 打在 field 上),无需 setterDL 依赖查找(Dependency Lookup)也属于 IOC:主动调
context.getBean(XxxService.class)去"捞"对象。它和 DI 的区别是——DI 是"等别人喂",DL 是"自己伸手拿"。业务代码优先用 DI,只有在工厂/策略路由这类运行时才知道类名的场景才用 DL。
3. 容器体系:BeanFactory、ApplicationContext、FactoryBean
这三个名字长得像,是面试故意设的坑,必须一次分清。
text
BeanFactory(IOC 容器的顶层接口,只定义"能取 Bean"这件事)
│
├─ ListableBeanFactory → 能按类型批量取:getBeansOfType()
├─ HierarchicalBeanFactory → 支持父子容器:getParentBeanFactory()
├─ AutowireCapableBeanFactory → 能给"容器外的对象"做依赖注入
│
└─ ApplicationContext(子接口,在 BeanFactory 之上叠企业级能力)
├─ MessageSource → 国际化
├─ ApplicationEventPublisher → 事件发布
├─ ResourcePatternResolver → 资源加载(classpath*: 通配)
└─ EnvironmentCapable → 环境 / Profile / 配置属性
│
├─ ClassPathXmlApplicationContext (XML 配置)
├─ AnnotationConfigApplicationContext (注解配置)
└─ AnnotationConfigServletWebServerApplicationContext(Spring Boot Web)| 对比项 | BeanFactory | ApplicationContext |
|---|---|---|
| 定位 | 基础 IOC 容器,功能最小集 | BeanFactory 的超集,面向应用的"完整容器" |
| 加载策略 | 懒加载,getBean() 时才实例化 | 启动时 refresh() 预实例化所有非懒加载单例 |
| 扩展点注册 | 需手动调用注册 BeanPostProcessor | 自动发现并注册所有 BeanPostProcessor |
| 附加能力 | 无 | 国际化、事件、资源、Environment、AOP 自动代理 |
| 错误暴露时机 | 运行时取 Bean 才报错 | 启动即报错(配置写错当场挂,线上更安全) |
| 实际使用 | 几乎只作为内部引擎(DefaultListableBeanFactory) | 业务开发实际接触的就是它 |
关键认知:
ApplicationContext并不自己存 Bean,它内部组合了一个DefaultListableBeanFactory做真正的仓库,自己只做"门面 + 增强"。所以说 ApplicationContext「继承 BeanFactory 接口、组合 BeanFactory 实现」。
📌 单独澄清:FactoryBean 和 BeanFactory 完全是两回事
| 维度 | BeanFactory | FactoryBean |
|---|---|---|
| 它是什么 | 容器本身 | 容器里的一个普通 Bean,但它是个"生产其他 Bean 的工厂" |
| 解决什么问题 | 统一管理所有 Bean | 某个 Bean 创建过程很复杂(需大量逻辑),交给工厂定制 |
| 取值行为 | getBean("a") 返回 a | getBean("myFb") 返回 getObject() 的结果;加 & 前缀 getBean("&myFb") 才返回工厂自己 |
| 典型使用者 | Spring 内核 | MyBatis 的 MapperFactoryBean、Spring 的 ProxyFactoryBean |
java
// FactoryBean 实战:MyBatis 的 Mapper 接口没有实现类,靠 FactoryBean 产出代理
@Component("userMapperFactory")
public class UserMapperFactoryBean implements FactoryBean<UserMapper> {
@Override
public UserMapper getObject() {
// 这里可以写任意复杂的创建逻辑:动态代理、读远端配置、按环境切换实现
return (UserMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class[]{UserMapper.class},
(proxy, method, args) -> executeSql(method, args));
}
@Override
public Class<?> getObjectType() { return UserMapper.class; }
@Override
public boolean isSingleton() { return true; }
private Object executeSql(Method m, Object[] args) { /* 省略 */ return null; }
}
// 使用方感知不到工厂的存在,拿到的直接是 UserMapper
@Autowired private UserMapper userMapper; // ✅ 实际是 getObject() 的返回值
@Autowired @Qualifier("&userMapperFactory")
private FactoryBean<UserMapper> rawFactory; // ✅ 加 & 才拿到工厂本体4. BeanDefinition:Bean 在容器里的"身份证"
Spring 不是直接"读到注解就造对象",中间一定有一层元数据描述。这层就是 BeanDefinition,它是理解 Spring 启动流程的关键抽象。
text
配置来源(三种殊途同归)
├─ XML: <bean id="userService" class="..."/>
├─ 注解:@Component / @Service / @Bean
└─ 编程式:registry.registerBeanDefinition(...)
│
▼ 各类 BeanDefinitionReader / ClassPathBeanDefinitionScanner 解析
BeanDefinition(一个 Bean 的"配置清单")
├─ beanClass 要造哪个类
├─ scope singleton / prototype / request...
├─ lazyInit 是否懒加载
├─ primary 是否为同类型首选
├─ dependsOn 必须先造好哪些 Bean
├─ initMethodName / destroyMethodName
├─ constructorArgumentValues 构造参数
└─ propertyValues 属性值
│
▼ 注册进 beanDefinitionMap(Map<String, BeanDefinition>)
DefaultListableBeanFactory
│
▼ finishBeanFactoryInitialization() 遍历,按清单造对象
真正的 Bean 实例 → singletonObjects(单例池)记住这条分界线:
BeanDefinition注册完成 ≠ Bean 创建完成。BeanFactoryPostProcessor作用在"清单阶段"(还能改配置),BeanPostProcessor作用在"实例阶段"(只能改对象)。这是高级深挖里最常考的区分点。
java
// 编程式注册 BeanDefinition:Spring Boot 很多自动配置就是这么干的
public class MyRegistrar implements ImportBeanDefinitionRegistrar {
@Override
public void registerBeanDefinitions(AnnotationMetadata meta,
BeanDefinitionRegistry registry) {
AbstractBeanDefinition bd = BeanDefinitionBuilder
.genericBeanDefinition(UserServiceImpl.class)
.setScope(BeanDefinition.SCOPE_SINGLETON)
.setLazyInit(false)
.addPropertyValue("timeout", 3000)
.getBeanDefinition();
registry.registerBeanDefinition("userService", bd);
}
}5. 依赖注入三种方式:为什么官方只推荐构造器注入
| 注入方式 | 写法 | 能否 final | 循环依赖 | 单测友好度 | Spring 官方态度 |
|---|---|---|---|---|---|
| 构造器注入 | 构造参数(单构造器可省 @Autowired) | ✅ 可以 | ❌ 无法解决,启动报错 | ✅ 直接 new 传 mock | 强烈推荐 |
| setter 注入 | @Autowired 打在 setXxx() 上 | ❌ 不行 | ✅ 三级缓存可解 | ✅ 可手动 set | 可选依赖时推荐 |
| 字段注入 | @Autowired 直接打在字段上 | ❌ 不行 | ✅ 三级缓存可解 | ❌ 需反射或 Spring 上下文 | 不推荐(IDEA 会警告) |
java
// ✅ 推荐:构造器注入 —— 依赖不可变、不可为 null、易测试
@Service
public class OrderService {
private final UserService userService;
private final StockService stockService;
// Spring 4.3+ 只有一个构造器时可以不写 @Autowired
public OrderService(UserService userService, StockService stockService) {
this.userService = userService;
this.stockService = stockService;
}
}
//❌ 不推荐:字段注入 —— 三个隐患
@Service
public class OrderServiceBad {
@Autowired private UserService userService; // 1. 不能 final,运行期可被反射改
@Autowired private StockService stockService;// 2. 脱离 Spring 无法 new 出可用对象
// 3. 依赖可以无限加而代码看不出"这个类太重了",掩盖了设计问题
}📌 单独澄清:构造器注入"解决不了"循环依赖,恰恰是它的优点
| 问题 | 字段 / setter 注入 | 构造器注入 |
|---|---|---|
| 遇到循环依赖 | 容器用三级缓存悄悄兜住,程序能跑起来 | 启动直接失败并打印依赖环 |
| 后果 | 环一直在,某天加个 AOP / @Async 就爆炸 | 第一天就被迫重新设计,环被消灭在源头 |
| 口诀 | "能跑但有病" | "跑不起来但健康" |
java
// ❌ 这种环在字段注入下能启动成功,但本质是设计问题
@Service class A { @Autowired B b; }
@Service class B { @Autowired A a; }
// ✅ 正确做法不是换注入方式,而是抽出公共逻辑打破环
@Service class A { private final C c; A(C c){ this.c = c; } }
@Service class B { private final C c; B(C c){ this.c = c; } }
@Service class C { /* A、B 共用的那部分能力放这里 */ }6. 自动装配与歧义解决:一个接口多个实现怎么办
容器按类型找 Bean 时,如果找到多个候选者,会抛 NoUniqueBeanDefinitionException。解决手段有五种,优先级要记清。
text
@Autowired 解析流程
1. 按类型 byType 找候选集合
2. 候选只有 1 个 → 直接注入
3. 候选 > 1 个 → 依次尝试:
① @Primary 标记的候选
② @Priority(数字小的优先)
③ 字段名 / 参数名 与 beanName 匹配(byName 兜底)
④ 都失败 → 抛 NoUniqueBeanDefinitionException
4. 候选 0 个 → required=true 抛 NoSuchBeanDefinitionException
required=false 或 Optional<T> 注入 null / empty| 手段 | 作用 | 适用场景 |
|---|---|---|
@Primary | 标记默认实现 | 有一个明确的"主力实现" |
@Qualifier("beanName") | 注入点指定具体 Bean | 不同调用方要不同实现 |
| 字段名与 beanName 一致 | 利用 byName 兜底 | 临时解法,可读性差,不推荐依赖 |
@Resource(name = "xxx") | JSR-250 注解,默认按名称 | 遗留代码常见 |
注入 List<T> / Map<String,T> | 一次性拿到全部实现 | 策略模式 / 责任链 |
java
public interface PayService { String channel(); void pay(long amount); }
@Service("aliPay") @Primary
class AliPayService implements PayService { public String channel(){ return "ALI"; } ... }
@Service("wxPay")
class WxPayService implements PayService { public String channel(){ return "WX"; } ... }
// ✅ 场景1:要默认实现 → 直接注入,@Primary 生效,拿到 aliPay
@Service
class OrderA { private final PayService payService;
OrderA(PayService payService){ this.payService = payService; } }
// ✅ 场景2:要指定实现 → @Qualifier 精确点名
@Service
class OrderB { private final PayService payService;
OrderB(@Qualifier("wxPay") PayService payService){ this.payService = payService; } }
// ✅ 场景3:策略路由 → 注入 Map,key 自动是 beanName,彻底消灭 if-else
@Service
class PayRouter {
private final Map<String, PayService> strategies; // {aliPay=..., wxPay=...}
PayRouter(Map<String, PayService> strategies){ this.strategies = strategies; }
void pay(String channel, long amount) {
strategies.values().stream()
.filter(s -> s.channel().equals(channel))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("未知渠道"))
.pay(amount);
}
}@Autowired vs @Resource 必考对比
| 维度 | @Autowired | @Resource |
|---|---|---|
| 来源 | Spring 自有注解 | JSR-250 标准(JDK 自带) |
| 默认装配方式 | byType,歧义时才退化 byName | byName,找不到名字再 byType |
| 指定名称 | 配合 @Qualifier | name 属性直接写 |
| 支持 required | 有 required = false | 无此属性 |
| 可用位置 | 构造器 / setter / 字段 / 方法参数 | 字段 / setter(不支持构造器) |
| 处理器 | AutowiredAnnotationBeanPostProcessor | CommonAnnotationBeanPostProcessor |
7. AOP 基础:七大术语 + 五种通知 + 执行顺序
AOP 的术语密度极高,背散装定义很容易混。把它们放进一次方法调用的时间轴上就清楚了。
text
目标对象 TargetObject:UserService 真身(被增强的那个对象)
│
▼ 容器用 Weaving 织入,产出 →
代理对象 Proxy:UserService$$Proxy(调用方实际拿到的东西)
│
▼ 调用 proxy.createUser()
JoinPoint 连接点:程序执行中"可以被拦截"的那个点(Spring AOP 里只有"方法调用")
│
▼ Pointcut 切点:一个表达式,筛选出"我要拦哪些 JoinPoint"
Advice 通知:筛中以后"做什么"(下面五种)
│
Aspect 切面 = Pointcut(拦哪里)+ Advice(做什么),打包成一个类
│
Introduction 引介:给目标类动态加新接口/新方法(极少用)| 通知类型 | 注解 | 执行时机 | 能否阻止目标方法执行 | 能否改返回值 |
|---|---|---|---|---|
| 前置通知 | @Before | 目标方法执行前 | ❌(只能抛异常中断) | ❌ |
| 后置返回通知 | @AfterReturning | 目标方法正常返回后 | ❌ | ❌(只能读) |
| 异常通知 | @AfterThrowing | 目标方法抛异常后 | ❌ | ❌ |
| 最终通知 | @After | 无论正常还是异常,一定执行 | ❌ | ❌ |
| 环绕通知 | @Around | 包裹整个调用,前后都能插代码 | ✅ 不调 proceed() 即可 | ✅ |
单切面执行顺序(Spring 5.2.7 之后已修正为符合直觉的顺序):
text
进入代理方法
├─ @Around 前半段(proceed() 之前的代码)
├─ @Before
├─── 执行目标方法 ───┐
│ ├─ 正常返回 → @AfterReturning
│ └─ 抛出异常 → @AfterThrowing
├─ @After(finally 语义,必执行)
└─ @Around 后半段(proceed() 之后的代码)
退出代理方法多切面顺序:用 @Order 控制,数字小的"外层",形成责任链。
text
@Order(1) 日志切面 ── 进 ──┐
@Order(2) 事务切面 ── 进 ──┐│
@Order(3) 缓存切面 ─ 进 ─┐││
目标方法 │││
缓存切面 ── 出 ───────────┘││
事务切面 ── 出 ─────────────┘│
日志切面 ── 出 ────────────────┘切点表达式常用写法(面试会让你现场写):
java
// execution:最常用,按方法签名匹配
@Pointcut("execution(public * com.xxx.service..*.*(..))") // service 包及子包所有公有方法
@Pointcut("execution(* com.xxx.service.UserService.get*(..))") // 以 get 开头的方法
// @annotation:按方法上的注解匹配(自定义注解切面的标配)
@Pointcut("@annotation(com.xxx.annotation.TimeLog)")
// within / @within:按类匹配 / 按类上的注解匹配
@Pointcut("within(com.xxx.service..*)")
@Pointcut("@within(org.springframework.stereotype.Service)")
// args / @args:按参数类型 / 参数类上的注解匹配
@Pointcut("args(java.lang.Long)")
// 组合:&& || !
@Pointcut("execution(* com.xxx.service..*(..)) && !@annotation(com.xxx.annotation.NoLog)")8. 动态代理:JDK Proxy 与 CGLIB 的真实差异
Spring AOP 的本质是运行期动态代理,这是和 AspectJ(编译期/类加载期织入)最大的区别。
text
Bean 创建到一半(属性填充 + 初始化完成)
│
▼ AbstractAutoProxyCreator#postProcessAfterInitialization
判断这个 Bean 有没有匹配的 Advisor(切面)
├─ 没有 → 原样返回真身
└─ 有 → 进入 DefaultAopProxyFactory#createAopProxy
├─ proxyTargetClass=true → CGLIB
├─ 目标类没有实现任何接口 → CGLIB
├─ 目标本身就是接口 / Proxy 类 → JDK
└─ 其余(实现了接口且未强制 CGLIB) → JDK
│
▼
放进单例池的是【代理对象】,不是原始对象| 对比项 | JDK 动态代理 | CGLIB |
|---|---|---|
| 实现原理 | 运行期生成实现了目标接口的 $Proxy0 类 | ASM 字节码生成目标类的子类并覆写方法 |
| 前提条件 | 目标类必须实现接口 | 目标类不能是 final,方法不能 final/private/static |
| 核心 API | Proxy.newProxyInstance + InvocationHandler | Enhancer + MethodInterceptor |
| 调用性能 | 反射调用,JDK8+ 后已很接近 | 早期更快(FastClass 索引直调),现代 JDK 差距很小 |
| 创建性能 | 更快(无需生成完整字节码) | 更慢(要生成子类) |
| 能代理的方法 | 仅接口中声明的方法 | 所有可覆写的 public / protected 方法 |
| 构造器要求 | 无 | 需要能被调用(Spring 用 Objenesis 绕过) |
| Spring Boot 默认 | — | 默认 proxyTargetClass=true,统一用 CGLIB |
为什么 Spring Boot 默认改成 CGLIB? 因为 JDK 代理只暴露接口方法,常出现「注入时按实现类声明 → 类型转换失败」。统一 CGLIB 后
@Autowired UserServiceImpl也能注入,减少踩坑。代价是不能代理final类/方法。
📌 单独澄清:Spring AOP 和 AspectJ 不是一回事
| 维度 | Spring AOP | AspectJ |
|---|---|---|
| 织入时机 | 运行期生成代理对象 | 编译期(ajc)或类加载期(LTW)改字节码 |
| 拦截粒度 | 只能拦方法调用(且必须走代理) | 方法、构造器、字段读写、静态块都能拦 |
| 自调用 | ❌ 失效(this 不走代理) | ✅ 生效(字节码已被改写) |
| 依赖 | 只借用 AspectJ 的注解和表达式 | 需要专门的编译器 / Java Agent |
| 性能 | 每次调用有代理开销 | 零运行时开销 |
关键认知:项目里
@Aspect、@Around、execution(...)这些注解和语法来自 AspectJ,但执行引擎是 Spring 自己的动态代理。所以才有「用了 AspectJ 注解,却还是自调用失效」这个经典困惑。
9. Bean 生命周期:完整链路逐步拆解
这是 Spring 面试的"终极大题"。能把下面这条链路背全并说清每步为什么存在,基本就稳了。
text
① 实例化前置拦截 InstantiationAwareBeanPostProcessor#postProcessBeforeInstantiation
└─ 若返回非 null,直接短路(AOP 的 TargetSource、Mock 框架会用)
② 实例化 createBeanInstance
└─ 推断构造器(@Autowired 构造器 / 唯一有参构造器 / 无参构造器)→ 反射 newInstance
└─ 此时对象已在堆上,但所有字段都是默认值(null / 0)
③ 合并 BeanDefinition MergedBeanDefinitionPostProcessor#postProcessMergedBeanDefinition
└─ 扫描 @Autowired / @Resource / @Value 元数据并缓存
④ 提前暴露引用 addSingletonFactory
└─ 把"半成品"包成 ObjectFactory 丢进三级缓存,为循环依赖做准备
⑤ 属性填充 populateBean
├─ postProcessAfterInstantiation(返回 false 可跳过填充)
└─ AutowiredAnnotationBeanPostProcessor 反射注入依赖
⑥ Aware 回调 invokeAwareMethods
├─ BeanNameAware#setBeanName
├─ BeanClassLoaderAware#setBeanClassLoader
└─ BeanFactoryAware#setBeanFactory
⑦ 初始化前 BeanPostProcessor#postProcessBeforeInitialization
├─ ApplicationContextAwareProcessor → 补齐 ApplicationContextAware 等回调
└─ CommonAnnotationBeanPostProcessor → 执行 @PostConstruct
⑧ 初始化
├─ InitializingBean#afterPropertiesSet
└─ 自定义 init-method / @Bean(initMethod = "xxx")
⑨ 初始化后 BeanPostProcessor#postProcessAfterInitialization
└─ ★ AOP 代理在这里产生:AnnotationAwareAspectJAutoProxyCreator 返回代理对象
⑩ 注册销毁回调 registerDisposableBean(仅 singleton)
⑪ 放入一级缓存 singletonObjects,Bean 可用
⑫ 容器关闭
├─ @PreDestroy
├─ DisposableBean#destroy
└─ 自定义 destroy-method三组初始化钩子的执行顺序与取舍:
| 钩子 | 归属 | 顺序 | 优点 | 缺点 |
|---|---|---|---|---|
@PostConstruct | JSR-250 标准 | 1 | 无侵入、与 Spring 解耦 | 依赖 javax/jakarta.annotation(JDK11+ 需引包) |
InitializingBean#afterPropertiesSet | Spring 接口 | 2 | 无反射,性能略高 | 业务类被迫实现 Spring 接口 |
init-method / @Bean(initMethod) | 配置声明 | 3 | 完全解耦,适合第三方类 | 方法名字符串,重构不安全 |
销毁顺序对称:@PreDestroy → DisposableBean#destroy → destroy-method。
📌 单独澄清:构造器里拿不到注入的字段,是"时序"而不是"bug"
java
@Service
public class ReportService {
@Autowired private UserDao userDao;
// ❌ 构造器在"②实例化"阶段执行,此时"⑤属性填充"还没发生
public ReportService() {
userDao.count(); // NPE!userDao 还是 null
}
// ✅ 方案 A:构造器注入,依赖随构造参数一起进来
// ✅ 方案 B:把初始化逻辑挪到 @PostConstruct(⑦ 阶段,字段已注入完毕)
@PostConstruct
public void init() {
log.info("当前用户数 = {}", userDao.count()); // 安全
}
}判断口诀:问自己一句「这段代码跑在第几步?」——构造器在第 ②步,字段注入在第 ⑤步,
@PostConstruct在第 ⑦步。任何依赖其他 Bean 的初始化逻辑,都不能早于第 ⑤步。
10. Bean 作用域与单例的线程安全
| 作用域 | 生命周期 | 容器是否管销毁 | 典型用途 |
|---|---|---|---|
singleton | 容器启动创建,容器关闭销毁(默认) | ✅ | 无状态 Service / DAO / 工具类 |
prototype | 每次 getBean 新建 | ❌ 容器不管销毁 | 有状态对象、线程不安全对象 |
request | 一次 HTTP 请求 | ✅ | 请求级上下文(Web 环境) |
session | 一个 HTTP 会话 | ✅ | 用户会话数据 |
application | 整个 ServletContext | ✅ | 全局共享配置 |
websocket | 一次 WebSocket 会话 | ✅ | 连接级状态 |
📌 单独澄清:单例 Bean 线程不安全的根源是"实例变量",不是"单例"本身
| 场景 | 线程安全? | 原因 |
|---|---|---|
| 单例 Bean + 只有方法局部变量 | ✅ 安全 | 局部变量在各线程自己的虚拟机栈上,天然隔离 |
| 单例 Bean + 可变实例字段 | ❌ 不安全 | 所有线程共享同一个对象的同一块堆内存 |
单例 Bean + 不可变字段(final 且引用的对象不可变) | ✅ 安全 | 没有写操作就没有竞态 |
单例 Bean + ThreadLocal 字段 | ✅ 安全 | 每个线程读写自己的副本(注意要 remove) |
java
// ❌ 典型翻车:把请求级状态塞进单例 Bean 的实例字段
@Service
public class BadOrderService {
private Long currentUserId; // 多线程互相覆盖
private SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); // SDF 本身非线程安全
public void handle(Long userId) {
this.currentUserId = userId; // 线程 A 刚写完,线程 B 就覆盖了
doSomething(); // doSomething 读到的可能是 B 的 userId
}
}
// ✅ 正确:状态随参数传递;非线程安全工具改用线程安全版本或局部创建
@Service
public class GoodOrderService {
private static final DateTimeFormatter FMT =
DateTimeFormatter.ofPattern("yyyy-MM-dd"); // 不可变,线程安全
public void handle(Long userId) {
doSomething(userId); // 状态走参数,活在调用线程的栈帧里
}
}📌 prototype 的两个经典陷阱
java
// 陷阱 1:容器不管 prototype 的销毁回调
@Scope("prototype")
@Component
public class Conn {
@PreDestroy public void close(){ /* 永远不会被容器调用! */ }
}
// → 需要自己 try-with-resources 或手动调 beanFactory.destroyBean(obj)
// 陷阱 2:单例里注入 prototype,拿到的永远是同一个实例
@Service
public class SingletonHolder {
@Autowired private Conn conn; // ❌ 只在创建 SingletonHolder 时注入一次
// ✅ 解法 A:注入 ObjectProvider,每次调用现取
@Autowired private ObjectProvider<Conn> connProvider;
public void work(){ Conn c = connProvider.getObject(); }
// ✅ 解法 B:@Lookup 方法注入,容器会重写该方法
@Lookup public Conn createConn(){ return null; } // 方法体会被 CGLIB 覆盖
// ✅ 解法 C:给 prototype Bean 加 @Scope(proxyMode = ScopedProxyMode.TARGET_CLASS)
}11. 循环依赖与三级缓存
什么是循环依赖:A 依赖 B,B 又依赖 A(也可能是 A→B→C→A 的长环,甚至 A→A 的自依赖)。容器创建 A 时要先造 B,造 B 时又要回头拿 A,形成死锁式递归。
哪些能解、哪些不能解:
| 场景 | 能否解决 | 原因 |
|---|---|---|
| 单例 + setter/字段注入 | ✅ | 实例化与属性填充分离,可提前暴露半成品 |
| 单例 + 构造器注入 | ❌ | 实例化阶段就要依赖对象,没有"半成品"可暴露 |
单例 + 构造器注入 + @Lazy | ✅ | @Lazy 注入的是代理,真正调用时才去容器取真身 |
prototype 作用域 | ❌ | 多例不进缓存,无从暴露;直接抛 BeanCurrentlyInCreationException |
@Async / 部分 AOP 场景 | ⚠️ 部分失败 | 代理在初始化后才生成,早期暴露的引用与最终代理不一致 → 抛错 |
| Spring Boot 2.6+ 默认配置 | ❌ 默认禁止 | 需显式开 spring.main.allow-circular-references=true |
三级缓存结构(都在 DefaultSingletonBeanRegistry 里):
| 缓存 | 级别 | 类型 | 存什么 |
|---|---|---|---|
singletonObjects | 一级 | Map<String, Object> | 完整可用的单例 Bean |
earlySingletonObjects | 二级 | Map<String, Object> | 提前暴露的半成品(可能已是代理) |
singletonFactories | 三级 | Map<String, ObjectFactory<?>> | 生产半成品的工厂(决定给真身还是代理) |
查找顺序永远是:一级 → 二级 → 三级(getSingleton 方法里逐级下探,三级命中后把结果升到二级并移除三级)。
完整推演:A ⇄ B 字段注入
text
getBean(A)
├ 一/二/三级缓存都没有 A
├ 标记 A 为"创建中":singletonsCurrentlyInCreation.add("a")
├ ② 实例化 A(堆上有了 a 对象,字段 b = null)
├ ④ 把 ObjectFactory 放进三级缓存:singletonFactories["a"] = () -> getEarlyBeanReference(a)
└ ⑤ 填充 A 的属性,发现需要 B → getBean(B)
├ 缓存都没有 B
├ 标记 B 为"创建中"
├ ② 实例化 B(字段 a = null)
├ ④ singletonFactories["b"] = () -> getEarlyBeanReference(b)
└ ⑤ 填充 B 的属性,发现需要 A → getBean(A)
├ 一级没有 → 二级没有 → 三级命中!
├ 执行工厂 getEarlyBeanReference(a)
│ ├ 若 A 需要 AOP → 此刻提前创建代理,返回 proxyA
│ └ 否则直接返回原始 a
├ 把结果放入二级缓存,从三级移除(保证多次取到同一个对象)
└ 返回半成品 A 给 B
├ B 拿到 A 的引用,属性填充完成
├ ⑧⑨ B 初始化 + 可能的 AOP 代理
└ ⑪ B 完整,放入一级缓存,清掉二三级
├ A 拿到完整的 B,属性填充完成
├ ⑧⑨ A 初始化;若之前已提前代理过,这里直接复用那个 proxyA(不会再造一个)
└ ⑪ A 完整,放入一级缓存📌 单独澄清:为什么必须是三级,两级不够吗?
这是本节最高频的追问。关键在于**"要不要代理"这个决定必须延迟到真正有人来取的那一刻**。
| 方案 | 做法 | 问题 |
|---|---|---|
| 只要一级 | 半成品和成品都放 singletonObjects | 别人可能拿到字段还没填完的 Bean,无法区分完整与否 |
| 两级 | 实例化后立即创建代理放进二级缓存 | 所有 Bean 不管有没有循环依赖,都被迫提前代理,破坏了"AOP 在初始化后织入"的生命周期约定 |
| 三级(现方案) | 只放工厂,有人来取时才决定返回真身还是代理 | 无循环依赖时工厂永远不被调用,零额外开销;有循环依赖时才提前代理 |
java
// 三级缓存里放的工厂,本质是这一行(AbstractAutowireCapableBeanFactory)
addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
// getEarlyBeanReference 的核心逻辑
protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) {
Object exposedObject = bean;
for (BeanPostProcessor bp : getBeanPostProcessors()) {
if (bp instanceof SmartInstantiationAwareBeanPostProcessor sibp) {
// AOP 的 AbstractAutoProxyCreator 在这里提前把代理造出来
exposedObject = sibp.getEarlyBeanReference(exposedObject, beanName);
}
}
return exposedObject; // 没有切面匹配时,原样返回真身
}那二级缓存是干嘛的? 两个作用,缺一不可:
- 保证"同一性":如果只有一三级,B 和 C 都依赖 A 时,工厂会被调用两次 → 产生两个不同的代理对象,单例语义被破坏。命中三级后立刻升到二级,后续全部走二级,保证只有一个。
- 避免重复执行工厂逻辑:代理创建是有成本的,没必要重复做。
一句话背诵版:一级存成品、二级存半成品、三级存工厂;三级解决"代理对象的提前生成",二级解决"提前生成的对象只能有一份"。
📌 为什么 Spring Boot 2.6 之后默认禁止循环依赖?
text
Spring Boot 2.6 起:spring.main.allow-circular-references 默认 false
启动时若检测到环,直接抛:
BeanCurrentlyInCreationException: Requested bean is currently in creation:
Is there an unresolvable circular reference?
官方理由:
① 循环依赖几乎总是设计问题(职责划分不清)
② 三级缓存并非万能:@Async、部分 @Transactional、
被 @Order 影响的 BeanPostProcessor 顺序都可能让它失效
③ 失效时报的错非常隐晦(BeanCurrentlyInCreationException / 代理不一致)
不如启动就暴露四种破环手段,按推荐度排序:
java
// ✅ 首选:重构,抽出公共依赖 C,把环变成树(彻底根治)
// ✅ 次选:@Lazy —— 注入一个代理,首次调用方法时才去容器取真身
@Service
public class A {
private final B b;
public A(@Lazy B b) { this.b = b; } // 构造器注入也能破环
}
// ⚠️ 可用:改为 setter / 字段注入,让三级缓存兜住(治标不治本)
@Service
public class A2 {
private B b;
@Autowired public void setB(B b) { this.b = b; }
}
// ⚠️ 下策:运行时用 ApplicationContextAware 主动查找
@Service
public class A3 implements ApplicationContextAware {
private ApplicationContext ctx;
public void setApplicationContext(ApplicationContext ctx) { this.ctx = ctx; }
public void work() { ctx.getBean(B.class).doSomething(); }
}
// ❌ 最差:spring.main.allow-circular-references=true 全局放开(把警报关掉而已)✨ 高级深挖知识点
1. refresh() 十二步:容器启动的骨架
AbstractApplicationContext#refresh() 是整个 Spring 的"主干方法"。能说出这十二步,面试官基本认定你读过源码。
text
refresh()
① prepareRefresh() 准备:记录启动时间、设置 active 标志、初始化 Environment
② obtainFreshBeanFactory() 创建 DefaultListableBeanFactory,加载 BeanDefinition
③ prepareBeanFactory(bf) 设置类加载器、注册 ApplicationContextAwareProcessor、
注册可解析依赖(BeanFactory/ApplicationContext 自身)
④ postProcessBeanFactory(bf) 子类扩展钩子(Web 容器在这里注册 request/session 作用域)
⑤ invokeBeanFactoryPostProcessors() ★ 执行 BFPP:ConfigurationClassPostProcessor 在这里
解析 @Configuration、@ComponentScan、@Import,
把扫描到的类全部注册成 BeanDefinition
⑥ registerBeanPostProcessors() ★ 把所有 BPP 实例化并排序注册(AOP 代理创建器在此就位)
⑦ initMessageSource() 国际化
⑧ initApplicationEventMulticaster() 事件广播器
⑨ onRefresh() 子类扩展(Spring Boot 在这里创建并启动内嵌 Tomcat)
⑩ registerListeners() 注册 ApplicationListener
⑪ finishBeanFactoryInitialization() ★ 实例化所有非懒加载单例(Bean 生命周期在这里批量跑)
⑫ finishRefresh() 清缓存、发布 ContextRefreshedEvent、
回调 SmartInitializingSingleton / LifecycleProcessor三个 ★ 是全部考点的来源:第⑤步是
BeanFactoryPostProcessor的舞台,第⑥步决定了 AOP 能不能生效,第⑪步是 Bean 生命周期十二步的入口。
2. BeanFactoryPostProcessor vs BeanPostProcessor
| 维度 | BeanFactoryPostProcessor (BFPP) | BeanPostProcessor (BPP) |
|---|---|---|
| 作用对象 | BeanDefinition(配置清单) | Bean 实例(对象) |
| 执行时机 | refresh 第 ⑤ 步,所有 Bean 实例化之前 | 每个 Bean 创建过程中(初始化前/后) |
| 执行次数 | 整个容器只执行一次 | 每个 Bean 都会过一遍 |
| 能做什么 | 改 scope、改属性值、新增/删除 BeanDefinition | 改对象字段、返回代理对象替换原对象 |
| 典型实现 | ConfigurationClassPostProcessor(解析注解配置)、PropertySourcesPlaceholderConfigurer(替换 ${})、MyBatis MapperScannerConfigurer | AutowiredAnnotationBeanPostProcessor(@Autowired 注入)、AnnotationAwareAspectJAutoProxyCreator(AOP)、ApplicationContextAwareProcessor |
java
// BFPP:在 Bean 还没造出来之前,偷偷把所有 Service 改成懒加载
@Component
public class LazyAllServices implements BeanFactoryPostProcessor {
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory bf) {
for (String name : bf.getBeanDefinitionNames()) {
BeanDefinition bd = bf.getBeanDefinition(name);
if (name.endsWith("Service")) {
bd.setLazyInit(true); // 改的是"清单",对象还不存在
}
}
}
}
// BPP:在对象已经造好后替换成代理
@Component
public class LogProxyProcessor implements BeanPostProcessor {
@Override
public Object postProcessAfterInitialization(Object bean, String name) {
if (!(bean instanceof PayService)) return bean;
return Proxy.newProxyInstance(bean.getClass().getClassLoader(),
bean.getClass().getInterfaces(),
(p, m, args) -> {
System.out.println("调用 " + m.getName());
return m.invoke(bean, args); // 注意这里用的是真身 bean
});
}
}
BeanDefinitionRegistryPostProcessor是 BFPP 的子接口,多了postProcessBeanDefinitionRegistry,执行更早,专门用于注册新的 BeanDefinition(BFPP 只适合改已有的)。ConfigurationClassPostProcessor实现的就是它。
3. @Configuration 与 proxyBeanMethods:为什么 @Bean 方法调两次只有一个实例
java
@Configuration // 默认 proxyBeanMethods = true
public class AppConfig {
@Bean public A a() { return new A(b()); } // 这里调了 b()
@Bean public B b() { return new B(); } // 容器也会调一次 b()
}问题:b() 被调用两次,为什么容器里只有一个 B 实例?
text
proxyBeanMethods = true(Full 模式,默认)
└─ ConfigurationClassEnhancer 用 CGLIB 给 AppConfig 生成子类
└─ 覆写每个 @Bean 方法,拦截逻辑:
先查容器里有没有这个 beanName
├─ 有 → 直接返回容器里的(保证单例语义)
└─ 没有 → 调用父类原方法创建并放入容器
→ 代价:启动时多一次 CGLIB 生成 + 每次调用走拦截
proxyBeanMethods = false(Lite 模式)
└─ 不生成 CGLIB 子类,@Bean 方法就是普通方法
→ 直接调 b() 会真的 new 一个新 B(不进容器!)
→ 优点:启动更快,Spring Boot 内部大量自动配置类都用 falsejava
// ✅ Lite 模式下的正确写法:依赖通过方法参数声明,由容器注入
@Configuration(proxyBeanMethods = false)
public class AppConfigLite {
@Bean public B b() { return new B(); }
@Bean public A a(B b) { return new A(b); } // 参数由容器解析,不要手动调 b()
}结论:
@Bean方法之间不要互相直接调用。要依赖就声明成方法参数,这样两种模式都正确,也更能表达依赖关系。
4. AOP 代理的创建时机与 @EnableAspectJAutoProxy
text
@EnableAspectJAutoProxy
└─ @Import(AspectJAutoProxyRegistrar.class)
└─ 注册 AnnotationAwareAspectJAutoProxyCreator(一个 BeanPostProcessor)
│
├─ 它实现 SmartInstantiationAwareBeanPostProcessor
│ → 提供 getEarlyBeanReference(循环依赖时提前代理的入口)
│
└─ postProcessAfterInitialization(Bean 生命周期第 ⑨ 步)
├─ 收集容器中所有 Advisor(@Aspect 类解析成 Advisor 列表)
├─ 用 Pointcut 逐个匹配当前 Bean 的方法
├─ 一个都不匹配 → 返回原对象
└─ 有匹配 → ProxyFactory.getProxy() 生成 JDK / CGLIB 代理Spring Boot 中 AopAutoConfiguration 会自动开启,所以不用手写 @EnableAspectJAutoProxy。两个关键属性:
| 属性 | 作用 | Spring Boot 默认 |
|---|---|---|
proxyTargetClass | 强制使用 CGLIB | true |
exposeProxy | 把当前代理暴露到 AopContext.currentProxy() | false |
java
// exposeProxy = true 后,可以在类内部拿到自己的代理,解决自调用问题
@EnableAspectJAutoProxy(exposeProxy = true)
@Service
public class OrderService {
@Transactional
public void inner() { /* 事务逻辑 */ }
public void outer() {
// ❌ this.inner(); 不走代理,事务失效
((OrderService) AopContext.currentProxy()).inner(); // ✅ 走代理
}
}5. @Transactional 失效的七种场景
事务是 AOP 最典型的落地,失效原因基本都能归到"没走代理"或"异常没被识别"两类。
| 失效场景 | 根因 | 修复方式 |
|---|---|---|
① 同类内部 this.method() 自调用 | 没经过代理对象 | 注入自己 / AopContext.currentProxy() / 拆到另一个类 |
② 方法不是 public | Spring 默认只拦 public(CGLIB 无法覆写 private) | 改为 public |
③ 方法或类是 final / static | CGLIB 无法覆写 | 去掉 final,改为实例方法 |
④ 异常被 try/catch 吞掉 | 代理层感知不到异常,照常 commit | 抛出异常,或手动 setRollbackOnly() |
⑤ 抛出的是受检异常(Exception) | 默认只对 RuntimeException + Error 回滚 | @Transactional(rollbackFor = Exception.class) |
| ⑥ 数据库引擎不支持事务(MyISAM) | 存储引擎层面没有事务 | 改 InnoDB |
| ⑦ 多线程 / 异步里执行 DB 操作 | 事务绑定在 ThreadLocal,子线程拿不到连接 | 子线程内部自己开事务,或改用消息/补偿 |
java
// ❌ 场景①+④ 双重失效的经典写法
@Service
public class OrderServiceBad {
public void createOrder(Order o) {
try {
this.saveAndDeduct(o); // ① this 调用,事务注解完全无效
} catch (Exception e) {
log.error("失败", e); // ④ 即使走了代理,异常被吞也不会回滚
}
}
@Transactional
public void saveAndDeduct(Order o) { orderDao.insert(o); stockDao.deduct(o); }
}
// ✅ 正确:事务入口在外层、让异常往外抛、跨类调用走代理
@Service
public class OrderServiceGood {
private final OrderTxService txService; // 拆到另一个 Bean
public OrderServiceGood(OrderTxService txService) { this.txService = txService; }
public void createOrder(Order o) {
txService.saveAndDeduct(o); // 跨 Bean 调用→ 走代理 → 事务生效
}
}
@Service
public class OrderTxService {
@Transactional(rollbackFor = Exception.class) // 受检异常也回滚
public void saveAndDeduct(Order o) throws BizException {
orderDao.insert(o);
stockDao.deduct(o); // 这里抛异常,上面的 insert 会回滚
}
}6. 事务传播行为:七种取值与实际效果
| 传播行为 | 外层有事务时 | 外层无事务时 | 典型用途 |
|---|---|---|---|
REQUIRED(默认) | 加入外层事务 | 新建事务 | 绝大多数业务 |
REQUIRES_NEW | 挂起外层,自己开新事务 | 新建事务 | 日志记录,主业务回滚也要留 |
NESTED | 在外层事务里开 Savepoint | 新建事务 | 子操作失败只回滚子操作 |
SUPPORTS | 加入 | 不开事务 | 只读查询 |
NOT_SUPPORTED | 挂起外层,自己不开 | 不开事务 | 耗时的非事务操作 |
MANDATORY | 加入 | 抛异常 | 强制要求被包在事务里调用 |
NEVER | 抛异常 | 不开事务 | 强制禁止在事务中调用 |
📌 单独澄清:REQUIRES_NEW 与 NESTED 的差别在"连接"和"回滚粒度"
| 维度 | REQUIRES_NEW | NESTED |
|---|---|---|
| 底层机制 | 挂起外层,另开一个数据库连接 | 同一连接,打 Savepoint |
| 内层回滚 | 只回滚内层,外层不受影响 | 只回滚到 Savepoint,外层不受影响 |
| 外层回滚 | 内层已提交的数据保留 | 内层跟着一起回滚 |
| 连接占用 | 占两个连接(高并发时可能连接池耗尽) | 一个连接 |
| 死锁风险 | 内外层操作同一行时可能互锁 | 无 |
java
@Service
public class PayService {
@Transactional(rollbackFor = Exception.class) // 外层 REQUIRED
public void pay(Long orderId) {
orderDao.updateStatus(orderId, "PAID");
logService.record(orderId); // REQUIRES_NEW:这条日志无论主流程成败都留下
accountDao.deduct(orderId); // 若这里抛异常,上面的 updateStatus 回滚,日志仍在
}
}
@Service
public class LogService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(Long orderId) { logDao.insert(orderId); }
}7. Aware 接口家族:容器能力的"后门"
Bean 默认是"无感知"的,但有时业务确实需要容器能力。Aware 系列就是这些后门,执行顺序在属性填充之后、初始化之前。
| 接口 | 注入的东西 | 常见用途 |
|---|---|---|
BeanNameAware | 自己的 beanName | 日志打印、多实例区分 |
BeanClassLoaderAware | 类加载器 | 动态加载类、SPI |
BeanFactoryAware | BeanFactory | 运行时按名查找 Bean |
ApplicationContextAware | ApplicationContext | 最常用,发事件 / 取 Bean 工具类 |
EnvironmentAware | Environment | 读配置、判断 Profile |
ResourceLoaderAware | ResourceLoader | 加载 classpath 资源 |
ApplicationEventPublisherAware | 事件发布器 | 发布领域事件 |
EmbeddedValueResolverAware | 占位符解析器 | 手动解析 ${} / #{} |
执行机制分两处:
BeanNameAware/BeanClassLoaderAware/BeanFactoryAware由invokeAwareMethods直接调用(第 ⑥ 步);其余由ApplicationContextAwareProcessor这个 BPP 在postProcessBeforeInitialization(第 ⑦ 步)里调用。
java
// 最经典用法:静态工具类拿容器,供非 Spring 管理的代码使用
@Component
public class SpringUtil implements ApplicationContextAware {
private static ApplicationContext ctx;
@Override
public void setApplicationContext(ApplicationContext applicationContext) {
ctx = applicationContext;
}
public static <T> T getBean(Class<T> clazz) { return ctx.getBean(clazz); }
public static <T> T getBean(String name, Class<T> clazz) { return ctx.getBean(name, clazz); }
}8. Spring 事件机制:容器内的发布订阅
事件机制让模块之间不用互相持有引用也能通信,是解耦循环依赖的常用手段。
text
publisher.publishEvent(event)
└─ ApplicationEventMulticaster#multicastEvent
├─ 按事件类型筛选 ApplicationListener(支持泛型精确匹配)
├─ taskExecutor == null → **同步**,在发布者线程里逐个调用
└─ taskExecutor != null → 异步,线程池里执行| 容器内置事件 | 触发时机 |
|---|---|
ContextRefreshedEvent | refresh 第 ⑫ 步,容器就绪 |
ApplicationStartedEvent | Spring Boot 启动完成(Runner 之前) |
ApplicationReadyEvent | 所有 Runner 执行完,应用可接流量 |
ContextClosedEvent | 容器开始关闭 |
java
// 1. 定义事件(Spring 4.2+ 不必继承 ApplicationEvent)
public record OrderPaidEvent(Long orderId, long amount) {}
// 2. 发布
@Service
public class PayService {
private final ApplicationEventPublisher publisher;
public PayService(ApplicationEventPublisher publisher) { this.publisher = publisher; }
@Transactional
public void pay(Long orderId) {
orderDao.updateStatus(orderId, "PAID");
publisher.publishEvent(new OrderPaidEvent(orderId, 100L)); // 默认同步
}
}
// 3. 监听
@Component
public class OrderListeners {
// ⚠️ 默认同步且在同一事务里:监听器抛异常会导致 pay() 回滚
@EventListener
public void onPaid(OrderPaidEvent e) { log.info("订单已支付 {}", e.orderId()); }
// ✅ 异步:需配合 @EnableAsync,脱离发布者线程和事务
@Async
@EventListener
public void sendSms(OrderPaidEvent e) { smsClient.send(e.orderId()); }
// ✅ 事务事件:只在事务真正提交后才执行,避免"事务还没提交就发了短信"
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void pushMq(OrderPaidEvent e) { mqProducer.send(e); }
}📌 单独澄清:@EventListener 默认是同步的,不是"消息队列"
| 维度 | @EventListener(默认) | @Async @EventListener | 真正的 MQ |
|---|---|---|---|
| 执行线程 | 发布者线程 | 线程池线程 | 消费者进程 |
| 是否在同一事务 | ✅ 在(异常会连带回滚) | ❌ 不在 | ❌ 不在 |
| 进程重启后 | 丢失 | 丢失 | 持久化、可重试 |
| 适用场景 | 同事务内的强一致副作用 | 进程内解耦的非关键逻辑 | 跨服务、要求可靠投递 |
9. Bean 加载顺序的四种控制手段
| 手段 | 控制什么 | 注意点 |
|---|---|---|
@DependsOn("beanA") | 强制 beanA 先初始化完成 | 只影响初始化顺序,不产生依赖注入 |
@Order / Ordered | 同类型多个 Bean 的集合排序 | ❌ 不影响 Bean 创建顺序,只影响 List<T> 注入顺序和切面链顺序 |
@Priority | 自动注入时的候选优先级 | JSR-250,比 @Order 少用 |
@AutoConfigureBefore/After | 自动配置类之间的顺序 | 仅对 spring.factories / AutoConfiguration.imports 里的类有效,对 @Component 无效 |
java
// ❌ 常见误解:以为 @Order 能让 A 比 B 先创建
@Order(1) @Component class A {}
@Order(2) @Component class B {} // A 不一定先于 B 被实例化!
// ✅ 想控制"谁先初始化好"用 @DependsOn
@Component
@DependsOn("dataSourceInitializer") // 保证表结构初始化完才创建这个 Bean
public class UserCacheWarmer {
@PostConstruct public void warmUp(){ /* 需要表已存在 */ }
}
// ✅ @Order 真正的用武之地:控制集合注入顺序与责任链顺序
@Component @Order(1) class AuthFilter implements BizFilter {}
@Component @Order(2) class LogFilter implements BizFilter {}
@Service
class FilterChainRunner {
private final List<BizFilter> filters; // 注入时已按 @Order 升序排好
FilterChainRunner(List<BizFilter> filters){ this.filters = filters; }
}10. SmartInitializingSingleton 与启动回调时机对比
| 回调 | 时机 | 能否保证"所有 Bean 都就绪" |
|---|---|---|
@PostConstruct | 当前 Bean 初始化时 | ❌ 其他 Bean 可能还没创建 |
InitializingBean | 当前 Bean 初始化时 | ❌ 同上 |
SmartInitializingSingleton#afterSingletonsInstantiated | 所有单例实例化完毕后 | ✅ |
ApplicationListener<ContextRefreshedEvent> | 容器 refresh 结束 | ✅(但父子容器会触发多次) |
ApplicationRunner / CommandLineRunner | Spring Boot 启动最后 | ✅(仅 SpringBoot) |
java
// 场景:要收集全部策略实现并建索引 —— 必须等所有 Bean 都创建完
@Component
public class StrategyRegistry implements SmartInitializingSingleton, ApplicationContextAware {
private ApplicationContext ctx;
private final Map<String, PayService> index = new HashMap<>();
@Override public void setApplicationContext(ApplicationContext ctx) { this.ctx = ctx; }
@Override
public void afterSingletonsInstantiated() {
ctx.getBeansOfType(PayService.class).values()
.forEach(s -> index.put(s.channel(), s)); // 此刻一定全都在了
}
}11. @Async 与循环依赖、代理的三个坑
java
// 坑 1:@Async 方法同类自调用 → 和 @Transactional 一样失效(不走代理)
// 坑 2:@Async + 循环依赖 → 启动直接报错
// 原因:AsyncAnnotationBeanPostProcessor 不是 SmartInstantiationAware,
// 无法参与"提前代理",三级缓存给出的半成品与最终代理不一致
// 报错:BeanCurrentlyInCreationException /
// "Bean with name 'a' has been injected into other beans [b] in its raw version"
// 坑 3:@Async 默认线程池是 SimpleAsyncTaskExecutor —— 每次来任务新建线程,不复用!java
// ✅ 必须自定义线程池,否则高并发下线程数失控
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(32);
executor.setQueueCapacity(1000);
executor.setThreadNamePrefix("biz-async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
// void 返回值的异步方法抛异常会被"静默吞掉",必须接这个处理器
return (ex, method, params) -> log.error("异步方法 {} 异常", method.getName(), ex);
}
}12. 条件装配:@Conditional 家族
Spring Boot 自动配置的"大脑",面试常问「自动配置怎么做到按需生效」。
| 注解 | 生效条件 |
|---|---|
@ConditionalOnClass | classpath 存在某个类 |
@ConditionalOnMissingClass | classpath 不存在某个类 |
@ConditionalOnBean | 容器中已有某个 Bean |
@ConditionalOnMissingBean | 容器中没有某个 Bean(用户自定义优先的关键) |
@ConditionalOnProperty | 配置项存在且匹配指定值 |
@ConditionalOnWebApplication | 当前是 Web 环境 |
@ConditionalOnExpression | SpEL 表达式为 true |
java
// 自定义条件:只有配置了国际化开关才注册这个 Bean
@Configuration
public class I18nConfig {
@Bean
@ConditionalOnProperty(name = "app.i18n.enabled", havingValue = "true")
@ConditionalOnMissingBean(MessageSource.class) // 用户自己定义了就不用我的
public MessageSource messageSource() {
ResourceBundleMessageSource ms = new ResourceBundleMessageSource();
ms.setBasename("i18n/messages");
ms.setDefaultEncoding("UTF-8");
return ms;
}
}
@ConditionalOnMissingBean的顺序陷阱:它判断的是「判断那一刻容器里有没有」,所以依赖 BeanDefinition 注册顺序。这就是自动配置类必须用@AutoConfigureAfter保证「在用户配置之后才评估」的原因;在普通@Component上用它结果不可预测。
13. 单例 Bean 中安全使用请求级状态:ThreadLocal 与作用域代理
java
// 方案 A:ThreadLocal —— 自己管,务必 finally remove,否则线程池复用导致脏数据 + 泄漏
public class UserContext {
private static final ThreadLocal<Long> HOLDER = new ThreadLocal<>();
public static void set(Long id) { HOLDER.set(id); }
public static Long get() { return HOLDER.get(); }
public static void clear() { HOLDER.remove(); } // ★ 必须调
}
@Component
public class UserInterceptor implements HandlerInterceptor {
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object h) {
UserContext.set(Long.valueOf(req.getHeader("uid")));
return true;
}
public void afterCompletion(HttpServletRequest req, HttpServletResponse res,
Object h, Exception e) {
UserContext.clear(); // 不清就会被下一个请求读到上一个用户的 id
}
}
// 方案 B:request 作用域 + 作用域代理 —— 交给容器管,单例里可直接注入
@Component
@Scope(value = WebApplicationContext.SCOPE_REQUEST,
proxyMode = ScopedProxyMode.TARGET_CLASS) // ★ 没有这行,单例无法注入 request Bean
public class RequestContext {
private Long userId;
public Long getUserId(){ return userId; }
public void setUserId(Long id){ this.userId = id; }
}
@Service
public class ReportService {
private final RequestContext requestContext; // 注入的是代理,每次调用路由到当前请求的实例
public ReportService(RequestContext rc){ this.requestContext = rc; }
}
proxyMode为什么必需? 单例 Bean 在容器启动时就创建,那一刻根本没有 HTTP 请求,无法注入 request 作用域的真实实例。加了作用域代理后,注入的是一个空壳代理,方法调用时才去RequestContextHolder找当前线程绑定的实例。
14. 父子容器:Spring MVC 的经典两层结构
text
Root WebApplicationContext(父,由 ContextLoaderListener 创建)
├─ Service、DAO、事务、数据源
│
└─ Servlet WebApplicationContext(子,由 DispatcherServlet 创建)
├─ Controller、HandlerMapping、ViewResolver
└─ 可以访问父容器的 Bean;父容器 ❌ 不能访问子容器的 Bean| 规则 | 说明 |
|---|---|
| 查找方向 | 子 → 父 单向;先查自己,没有再问父容器 |
| 同名 Bean | 子容器的覆盖父容器(对子容器可见范围而言) |
| 父容器的切面/事务 | ❌ 不会作用于子容器的 Bean(切面要在哪一层生效就在哪一层声明) |
| 经典坑 | 事务配置在子容器、Service 在父容器 → 事务完全不生效 |
Spring Boot 默认只有一个容器(
AnnotationConfigServletWebServerApplicationContext),所以这类坑少了很多。但 Spring Cloud Feign 的FeignClient配置、Dubbo 的部分场景仍会用到子容器隔离。
15. Spring 6 / Boot 3 的变化:AOT 与反射注入
text
传统运行方式:反射 + 运行期动态代理 + 运行期 classpath 扫描
└─ 启动慢(扫描 + 代理生成)、内存占用高、无法 GraalVM 原生镜像
Spring 6 AOT(Ahead-Of-Time):
├─ 构建期把 BeanDefinition "固化"成 Java 代码(BeanFactoryInitializationAotContribution)
├─ 构建期生成反射/代理/资源的注册元数据(reflect-config.json 等)
└─ 运行期不再扫描、不再动态生成 → 启动毫秒级,可编成原生镜像| 变化点 | 影响 |
|---|---|
| 最低 JDK 17 | 可用 record / sealed / 文本块写 Bean 和配置 |
javax.* → jakarta.* | @Resource、@PostConstruct 包名全变,升级必改 |
| AOT / 原生镜像 | 运行期动态注册 BeanDefinition、运行期生成代理的"黑魔法"会失效 |
| 可观测性 | 内置 Micrometer Observation API,替代手写 AOP 埋点 |
@HttpExchange | 声明式 HTTP 客户端进入 Spring 核心(类似 Feign) |
面试加分表述:「AOT 的本质是把 Spring 启动期做的决策提前到编译期。代价是放弃运行期的动态性——所以我们项目里没有使用那种
if (环境==X) registry.registerBeanDefinition(...)的运行期动态注册写法,全部改成@Conditional条件装配,保证将来能平滑迁到原生镜像。」
16. 配置属性注入:@Value、Environment 与 @ConfigurationProperties
依赖注入有两条线:注入 Bean(@Autowired)和注入配置值(@Value)。后者在面试里经常被忽略,但它是 IOC 容器的另一半能力。
text
Environment(ConfigurableEnvironment)
└─ MutablePropertySources 有序的属性源列表,查找时「从前往后,第一个命中即返回」
① commandLineArgs --server.port=8081
② SPRING_APPLICATION_JSON
③ systemProperties -Dkey=value(JVM 参数)
④ systemEnvironment 操作系统环境变量
⑤ application-{profile}.yml 指定 profile 的配置
⑥ application.yml 主配置
⑦ @PropertySource 配置类上手动引入
⑧ SpringApplication.setDefaultProperties 代码兜底默认值
注入链路:
PropertySourcesPlaceholderConfigurer(一个 BFPP)
└─ 向 BeanFactory 注册 embeddedValueResolver(底层就是 Environment)
└─ AutowiredAnnotationBeanPostProcessor 填充 @Value 字段时
├─ resolveEmbeddedValue("${db.url}") → 字符串替换
├─ BeanExpressionResolver 解析 "#{...}" → SpEL 求值
└─ ConversionService 类型转换 → String 转 int/List/Duration${} 与 #{} 是两套完全不同的机制
| 写法 | 机制 | 求值时机 | 典型用途 |
|---|---|---|---|
${a.b} | 属性占位符替换 | 注入前,纯文本 | 读配置文件 |
${a.b:默认值} | 占位符 + 冒号默认值 | 同上 | 配置缺失时兜底,强烈建议都写默认值 |
#{1 + 2} | SpEL 表达式 | 注入时求值 | 计算、调用方法、引用其他 Bean |
#{@otherBean.getName()} | SpEL 引用 Bean | 注入时求值 | 跨 Bean 取值 |
#{'${a.b}'.split(',')} | 先 ${} 再 #{} | 两阶段 | 把逗号字符串切成数组(注意单引号必须有) |
@Value vs @ConfigurationProperties
| 维度 | @Value | @ConfigurationProperties |
|---|---|---|
| 粒度 | 单个值,散落在各处 | 一整棵配置树绑定到一个对象 |
| 松散绑定 | ❌ 不支持,db-url 和 dbUrl 不通 | ✅ 支持 db-url / db_url / DB_URL / dbUrl |
| SpEL | ✅ 支持 #{} | ❌ 不支持 |
| 校验 | 无 | ✅ 可配 @Validated + JSR-303 做启动期校验 |
| 复杂类型 | 需手写切割 | ✅ 原生支持 List / Map / 嵌套对象 / Duration |
| 元数据提示 | 无 | ✅ IDE 自动补全(spring-configuration-processor) |
| 适用场景 | 一两个零散开关 | 一个模块的配置集合(推荐默认选它) |
java
// ✅ 推荐:配置类 + 构造器绑定 + 启动期校验,配置写错启动就失败
@Validated
@ConfigurationProperties(prefix = "order.pay")
public record PayProps(
@NotBlank String appId,
@Min(1) int retryTimes,
Duration timeout, // 直接写 "3s" / "500ms",自动转换
List<String> whiteList) {} // 直接写 order.pay.white-list[0]=...
// 注册方式二选一:配置类上 @EnableConfigurationProperties(PayProps.class)
// 或 类上加 @ConfigurationPropertiesScan 扫描
// ❌ 三个高频错误写法
@Component
public class Wrong {
@Value("${db.url}")
private static String url; // ① static 字段注入不生效,永远是 null
@Value("${db.port}")
private int port; // ② 配置缺失直接启动失败,应写 ${db.port:3306}
@Value("${db.hosts}")
private List<String> hosts; // ③ @Value 不会切割逗号,须 #{'${db.hosts}'.split(',')}
}面试加分表述:「配置注入我们团队统一用
@ConfigurationProperties+@Validated+ 构造器绑定:一是配置写错在启动期就失败,不会等到半夜调用才报 NPE;二是配置项是不可变的,不会被运行期误改;三是松散绑定让 K8s 环境变量(ORDER_PAY_APP_ID)和 yml(order.pay.app-id)能共用同一套字段。@Value只保留在少数临时开关上。」
17. 组件扫描与注解元模型:@ComponentScan 到底怎么找到你的 Bean
@Component 能被扫到不是魔法,而是一条可以完整说清的链路。能讲清这条链路,才解释得了「为什么自定义组合注解能生效」「为什么注解加在父类上子类没继承到」。
text
ConfigurationClassPostProcessor(一个 BeanDefinitionRegistryPostProcessor)
└─ ComponentScanAnnotationParser 解析 @ComponentScan
└─ ClassPathBeanDefinitionScanner#doScan(basePackages)
① 用 PathMatchingResourcePatternResolver 把包名转成 classpath*:com/x/**/*.class
② 对每个 .class 用 ASM 直接读字节码(MetadataReader)
→ 关键:只读元数据,不加载类、不触发静态初始化,所以扫描很快且无副作用
③ excludeFilters 先判断,再判断 includeFilters(默认 include = @Component 及其派生)
④ isCandidateComponent:必须是独立的具体类(非接口/非抽象,或有 @Lookup 方法)
⑤ 生成 ScannedGenericBeanDefinition
⑥ BeanNameGenerator 生成 beanName(默认类名首字母小写;内部类是 Outer.Inner)
⑦ registry.registerBeanDefinition(name, bd)| 过滤器类型 | 含义 | 示例 |
|---|---|---|
ANNOTATION(默认) | 标了某注解 | @ComponentScan(includeFilters = @Filter(MyMark.class)) |
ASSIGNABLE_TYPE | 是某类/接口的子类型 | 扫描所有 BaseHandler 实现 |
ASPECTJ / REGEX | 表达式匹配类名 | @Filter(type = REGEX, pattern = "com\\.x\\..*Impl") |
CUSTOM | 自己实现 TypeFilter | 拿到 MetadataReader 做任意判断 |
注解元模型:组合注解为什么能生效
Spring 不用 JDK 原生的 getAnnotation,而是用自己的 MergedAnnotations,会递归向上搜索元注解,并支持 @AliasFor 做属性互映射。
java
// @Service 本身并没有被扫描逻辑硬编码,它只是「标了 @Component 的注解」
@Component // ← 元注解,Spring 递归搜索到它就认定是组件
public @interface Service { @AliasFor(annotation = Component.class) String value() default ""; }
// ✅ 所以可以自定义一个「带默认事务 + 带监控」的组合注解
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Service // 继承组件语义
@Transactional(rollbackFor = Exception.class) // 继承事务语义
public @interface TxService {
@AliasFor(annotation = Service.class)
String value() default "";
}
@TxService("orderBiz") // 等价于同时标了 @Service("orderBiz") + @Transactional
public class OrderBiz { }三个最容易答错的边界
| 现象 | 真相 |
|---|---|
父类标了 @Component,子类没标,子类没被注册 | 类上的注解默认不被继承(@Component 没有 @Inherited)。扫描是逐个 class 文件判断的 |
父类方法标了 @Transactional,子类重写后失效 | 方法注解从不继承。Spring 事务靠 AnnotationTransactionAttributeSource 主动向父类/接口查找,但 @Transactional 写在接口上对 CGLIB 代理不可靠,所以一律标在实现类的方法上 |
同一个类被两次扫到(如 @ComponentScan 包含了配置类所在包) | 第二次注册会走 isCompatible 判断,同名同源会静默跳过;但若两个不同类生成了同名 beanName,默认直接抛 ConflictingBeanDefinitionException |
面试加分表述:「我们内部 Starter 里把
@Service + @Transactional + @Validated合成了一个@BizService组合注解,靠的就是 Spring 的MergedAnnotations会递归搜元注解这个特性。另外排查过一个坑:有同学把@Component加在抽象基类上,以为所有子类都会被注册——实际上类注解不会继承,扫描器也会跳过抽象类,正确做法是在每个实现类上标注,或用ASSIGNABLE_TYPE过滤器批量纳入。」
18. Spring 里的设计模式全景:每个模式都能指到具体类
「Spring 用了哪些设计模式」是必问题,但只背名字会被追问到露底。正确答法是每个模式都对应到一个能说出细节的类。
| 设计模式 | Spring 中的落点 | 关键细节(追问时答这一列) |
|---|---|---|
| 工厂方法 | BeanFactory#getBean、FactoryBean#getObject | BeanFactory 是容器本身的工厂;FactoryBean 是某个 Bean 的工厂,&name 才能拿到工厂自身 |
| 单例(注册表) | DefaultSingletonBeanRegistry 的 singletonObjects | 不是经典单例(私有构造器),而是Map 缓存 + 双重检查,作用域是容器而非 JVM |
| 模板方法 | AbstractApplicationContext#refresh | refresh() 定好十二步骨架,obtainFreshBeanFactory、onRefresh 留给子类实现 |
| 代理 | JdkDynamicAopProxy / CglibAopProxy | AOP、@Transactional、@Async 全靠它;自调用失效的根因就在这里 |
| 责任链 | ReflectiveMethodInvocation#proceed | 用 currentInterceptorIndex 递归推进,所以它有状态、不可复用 |
| 观察者 | ApplicationEventMulticaster + @EventListener | 默认同步执行,加 @Async 才异步;@TransactionalEventListener 挂在事务钩子上 |
| 适配器 | AdvisorAdapter、MVC 的 HandlerAdapter | 把 MethodBeforeAdvice 等不同通知统一适配成 MethodInterceptor |
| 装饰器 | BeanWrapper、*Decorator、DelegatingFilterProxy | BeanPostProcessor 返回包装对象本质也是装饰 |
| 策略 | InstantiationStrategy、BeanNameGenerator | 反射实例化 vs CGLIB 实例化(有 replace-method 时)由策略决定 |
| 建造者 | BeanDefinitionBuilder、UriComponentsBuilder | 编程式注册 BeanDefinition 时常用 |
| 桥接 | JdbcTemplate 等 *Template | 抽象(模板)与实现(具体 DB 驱动/异常翻译)分离 |
text
一句话串起来(面试口述模板):
容器启动是「模板方法」定骨架(refresh 十二步),
Bean 的产出靠「工厂」(BeanFactory / FactoryBean),
单例靠「注册表式单例」(三级缓存 Map),
扩展靠「观察者」(事件)和「装饰」(BeanPostProcessor 包装),
增强靠「代理」(JDK / CGLIB),
多个增强串成「责任链」(MethodInvocation),
不同类型的通知靠「适配器」统一成 MethodInterceptor。面试加分表述:「我更愿意讲这些模式解决了什么问题:模板方法让 Spring 的启动流程既固定又可扩展,所以 Boot 只需实现
onRefresh就能把内嵌 Tomcat 塞进去;责任链让通知可以任意叠加而互不感知,但代价是MethodInvocation有状态不能复用——我们自己写拦截器链时就踩过这个坑,把 invocation 对象缓存起来复用,结果并发下索引错乱。」
❓ 高频面试题【基础简答 + 连环追问链 + 精简口述答案 + 项目关联话术】
题 1:什么是 IOC 和 DI?BeanFactory 和 ApplicationContext 有什么区别?
精简口述答案(90 秒版)
IOC 控制反转是一种设计思想,反转的是"对象创建时机、实现类选择、依赖装配"这三项控制权,从业务代码交给容器。注意它反转的是控制权,不是取消创建动作,底层仍然是反射调构造器。
DI 依赖注入是 IOC 最主流的实现手段,分构造器、setter、字段三种;另一种实现手段是 DL 依赖查找,也就是主动 getBean。
BeanFactory 是 IOC 容器的顶层接口,只提供最基础的「按名/按类型取 Bean」,而且是懒加载,getBean 时才实例化。ApplicationContext 是它的子接口,在此之上叠了国际化、事件发布、资源加载、Environment 四大企业级能力,并且启动时就预实例化所有非懒加载单例,配置写错在启动阶段就暴露,线上更安全。
实际结构上,ApplicationContext 并不自己存 Bean,而是组合了一个 DefaultListableBeanFactory 当仓库,自己做门面和增强。
连环追问链
| 追问 | 答题要点 |
|---|---|
FactoryBean 和 BeanFactory 的区别? | BeanFactory 是容器本身;FactoryBean 是容器里的一个普通 Bean,但它负责生产别的 Bean。getBean("x") 拿到的是 getObject() 结果,加 & 前缀才拿到工厂本体。MyBatis 的 Mapper 代理就靠它 |
ApplicationContext 有哪些常用实现? | ClassPathXmlApplicationContext(XML)、AnnotationConfigApplicationContext(注解)、Spring Boot 的 AnnotationConfigServletWebServerApplicationContext |
| 容器启动流程大致几步? | refresh() 十二步,关键三步是:⑤ invokeBeanFactoryPostProcessors 解析配置注册 BeanDefinition、⑥ registerBeanPostProcessors 让 AOP 代理器就位、⑪ finishBeanFactoryInitialization 批量创建单例 |
BeanDefinition 是什么? | Bean 的元数据清单(beanClass、scope、lazyInit、primary、dependsOn、构造参数等)。注册 BeanDefinition ≠ 创建 Bean,这条分界线决定了 BFPP 和 BPP 的职责划分 |
| 为什么推荐构造器注入? | 依赖可 final 不可变、不可能为 null、单测能直接 new 传 mock、构造参数一多就能暴露"这个类职责过重";而且构造器注入遇到循环依赖会启动失败,等于强制你在第一天修正设计 |
✅ 项目关联话术
「我们项目所有 Service 统一用构造器注入 + Lombok 的 @RequiredArgsConstructor,字段全部 final。好处很直接:有一次新同学给一个 Service 加了第 9 个依赖,Code Review 时构造参数列表一眼就看出职责过重,顺势拆成了两个 Service。另外多实现类的场景(比如支付渠道)我们注入 Map<String, PayService>,key 就是 beanName,彻底消灭了渠道路由的 if-else,新增渠道只要加一个 @Service 实现类。」
题 2:Spring AOP 的底层原理?JDK 动态代理和 CGLIB 有什么区别?哪些场景 AOP 会失效?
精简口述答案(90 秒版)
Spring AOP 的本质是运行期动态代理。容器在 Bean 生命周期的「初始化后」这一步,由 AnnotationAwareAspectJAutoProxyCreator 这个 BeanPostProcessor 收集所有 Advisor,用切点表达式匹配当前 Bean 的方法;匹配上就生成代理对象,放进单例池的是代理,不是真身。
代理方式二选一:目标类实现了接口且没强制 CGLIB,就用 JDK 动态代理,运行期生成实现同样接口的 $Proxy0;没有接口或 proxyTargetClass=true,就用 CGLIB,用 ASM 生成目标类的子类并覆写方法。Spring Boot 默认 proxyTargetClass=true 统一走 CGLIB,避免「按实现类注入时类型转换失败」。
AOP 失效归根到底就一句:没走代理对象。具体有四类——同类内部 this.xxx() 自调用、private 方法、static 方法、final 类或方法。
连环追问链
| 追问 | 答题要点 |
|---|---|
| Spring AOP 和 AspectJ 的区别? | Spring AOP 运行期代理,只能拦方法调用,自调用失效;AspectJ 编译期/类加载期改字节码,能拦字段、构造器,自调用也生效。我们用的 @Aspect、execution() 只是借用 AspectJ 的注解和表达式语法,执行引擎还是 Spring 自己的代理 |
| 五种通知的执行顺序? | @Around 前半段 → @Before → 目标方法 → (正常)@AfterReturning / (异常)@AfterThrowing → @After → @Around 后半段。多切面用 @Order,数字小的在外层,形成责任链 |
| 自调用失效怎么解决? | ① 拆到另一个 Bean(最推荐,顺带理清职责);② 注入自己(Spring 4.3+ 允许自注入);③ @EnableAspectJAutoProxy(exposeProxy = true) + AopContext.currentProxy();④ 换 AspectJ 编译期织入 |
| CGLIB 代理的对象,构造器会执行几次? | 两次。父类(真身)构造一次,CGLIB 子类构造一次。所以构造器里有副作用(埋点、注册监听)的类用 CGLIB 要特别小心;Spring 内部用 Objenesis 可以绕过构造器 |
@Around 的 ProceedingJoinPoint 和 JoinPoint 区别? | ProceedingJoinPoint 是 JoinPoint 的子接口,多了 proceed(),只有 @Around 能用。不调 proceed() 目标方法就不执行,可以用来做熔断、缓存短路 |
✅ 项目关联话术
「我们用自定义注解 + @Around 做了三个切面:接口耗时监控(超过 500ms 打 WARN 并上报)、操作审计日志、接口幂等(基于 Redis setnx)。业务代码里一行埋点都不用写,只打一个 @TimeLog 注解。踩过一个坑:有个同事把幂等注解打在了 private 方法上,上线后发现完全没生效;后来我们在 CI 加了一条 ArchUnit 规则,自定义注解只允许出现在 public 方法上,从机制上杜绝了这类问题。」
题 3:Spring 怎么解决循环依赖?三级缓存分别是什么?为什么是三级而不是两级?
精简口述答案(90 秒版)
三级缓存都在 DefaultSingletonBeanRegistry 里:一级 singletonObjects 存完整成品;二级 earlySingletonObjects 存提前暴露的半成品(可能已是代理);三级 singletonFactories 存生产半成品的 ObjectFactory。查找顺序永远是一级 → 二级 → 三级。
流程:A 实例化后立刻把 ObjectFactory 放进三级缓存;A 填充属性时发现要 B,去创建 B;B 填充属性时发现要 A,一二级都没有,三级命中,执行工厂拿到半成品 A(如果 A 需要 AOP,这一刻就提前把代理造出来),同时把结果升到二级并移除三级;B 完成后进一级缓存;A 拿到完整的 B,完成后也进一级缓存。
为什么要三级? 关键是「要不要代理」这个决定必须延迟到真正有人来取的那一刻。如果只有两级,就得在实例化后立刻给所有 Bean 创建代理,这破坏了「AOP 在初始化后织入」的生命周期约定,也给没有循环依赖的 Bean 带来无谓开销。三级只放工厂,没人取就永远不执行。
那二级缓存的作用是什么? 保证同一性。如果 B 和 C 都依赖 A,没有二级缓存,工厂会被执行两次,产生两个不同的代理对象,单例语义就破了。
连环追问链
| 追问 | 答题要点 |
|---|---|
| 构造器注入的循环依赖能解决吗? | 不能。实例化阶段就需要依赖对象,连"半成品"都造不出来,无从暴露。只能靠 @Lazy 注入代理来绕过,或者重构打破环 |
prototype 的循环依赖能解决吗? | 不能。多例 Bean 不进单例缓存,没有提前暴露的机制,直接抛 BeanCurrentlyInCreationException |
| 三级缓存是万能的吗? | 不是。@Async 的 AsyncAnnotationBeanPostProcessor 不是 SmartInstantiationAwareBeanPostProcessor,无法参与提前代理,导致早期暴露的引用和最终代理不一致,会报 "has been injected into other beans in its raw version"。这正是 Spring Boot 2.6+ 默认禁止循环依赖的原因 |
| Spring Boot 2.6 以后为什么默认禁止? | 三点:① 循环依赖几乎总是设计问题;② 三级缓存并非万能(见上条);③ 失效时报错非常隐晦,不如启动就暴露。要恢复得显式配 spring.main.allow-circular-references=true |
| 怎么破环?按推荐度排序 | ① 重构抽公共依赖,把环变成树(根治);② @Lazy 注入代理;③ 改 setter/字段注入让三级缓存兜住(治标);④ ApplicationContextAware 运行时查找;⑤ 全局放开配置(最差,等于把警报关掉) |
✅ 项目关联话术
「我们从 Spring Boot 2.3 升到 2.7 时,启动直接挂了,报了三处循环依赖。我们没有图省事去开 allow-circular-references,而是逐个分析:其中两处是 UserService 和 OrderService 互相调用,我们把两边共用的「用户额度校验」抽成了独立的 QuotaService,环就断了;第三处是一个历史遗留的通知模块,短期先用 @Lazy 兜住并打了 TODO,下个迭代重构掉。现在我们团队的规范是:构造器注入 + 不允许全局放开循环依赖,相当于把设计问题的检测左移到了启动阶段。」
题 4:完整讲一下 Spring Bean 的生命周期,有哪些扩展点可以介入?
精简口述答案(90 秒版)
大致分四大阶段、十二小步:
① 实例化前:InstantiationAwareBeanPostProcessor#postProcessBeforeInstantiation,返回非 null 会直接短路。 ② 实例化:推断构造器 → 反射 newInstance,此时字段全是默认值;随后把 ObjectFactory 放入三级缓存提前暴露。 ③ 属性填充与初始化:populateBean 反射注入依赖 → Aware 回调(BeanName / BeanClassLoader / BeanFactory)→ BeanPostProcessor 前置(这里执行 @PostConstruct、补齐 ApplicationContextAware)→ InitializingBean#afterPropertiesSet → init-method → BeanPostProcessor 后置(AOP 代理在这里产生)。 ④ 注册与销毁:注册销毁回调 → 放入一级缓存可用 → 容器关闭时 @PreDestroy → DisposableBean#destroy → destroy-method。
最重要的扩展点有两类:BeanFactoryPostProcessor 作用在 BeanDefinition 清单阶段(整个容器只执行一次,能改配置、能加删 Bean 定义);BeanPostProcessor 作用在实例阶段(每个 Bean 都过一遍,能改对象、能返回代理替换原对象)。AOP 和 @Autowired 注入都是 BPP 实现的。
连环追问链
| 追问 | 答题要点 |
|---|---|
| 三种初始化回调的顺序? | @PostConstruct(JSR-250,由 CommonAnnotationBeanPostProcessor 执行)→ InitializingBean#afterPropertiesSet → init-method。销毁顺序对称:@PreDestroy → DisposableBean#destroy → destroy-method |
构造器里访问 @Autowired 字段为什么 NPE? | 时序问题:构造器在第②步执行,字段注入在第⑤步。初始化逻辑要么改用构造器注入,要么挪到 @PostConstruct(第⑦步) |
| 怎么保证"所有 Bean 都就绪后"再执行逻辑? | @PostConstruct 不行(别的 Bean 可能还没建)。要用 SmartInitializingSingleton#afterSingletonsInstantiated、监听 ContextRefreshedEvent、或 Spring Boot 的 ApplicationRunner |
prototype Bean 的销毁回调会执行吗? | 不会。容器创建完就不再持有引用,@PreDestroy 永远不被调用。需要自己 try-with-resources 或手动调 beanFactory.destroyBean(obj) |
BeanDefinitionRegistryPostProcessor 是什么? | BFPP 的子接口,执行更早,专门用于注册新的 BeanDefinition。ConfigurationClassPostProcessor 实现的就是它,@ComponentScan、@Import 都在这里被解析 |
✅ 项目关联话术
「我们写过一个自定义 BeanPostProcessor 做敏感字段脱敏:Bean 初始化后扫描带 @Sensitive 注解的字段,给它包一层代理。还写过一个 BeanFactoryPostProcessor,在预发环境把所有外部 RPC 客户端的 BeanDefinition 换成 Mock 实现类,不用改一行业务代码就能做隔离测试。踩过一个生命周期的坑:有个缓存预热的 @PostConstruct 方法依赖另一个 Bean 已经把表建好,偶发失败,最后用 @DependsOn 显式声明顺序才稳定——这让我彻底搞清了 @Order 不控制创建顺序,只控制集合注入顺序。」
题 5:@Autowired 和 @Resource 有什么区别?一个接口多个实现时怎么注入?
精简口述答案(90 秒版)
@Autowired 是 Spring 自有注解,默认按类型装配,歧义时才退化为按名称;由 AutowiredAnnotationBeanPostProcessor 处理,支持 required = false,可用在构造器、setter、字段和方法参数上。
@Resource 是 JSR-250 标准注解,默认按名称装配,找不到名字再按类型;由 CommonAnnotationBeanPostProcessor 处理,不支持 required,也不能用在构造器上。注意 Spring Boot 3 / Spring 6 之后包名从 javax.annotation 变成了 jakarta.annotation。
一个接口多个实现时,@Autowired 的解析顺序是:按类型拿到候选集合 → 只有一个直接注入 → 多个则依次尝试 @Primary、@Priority、字段名与 beanName 匹配 → 都失败就抛 NoUniqueBeanDefinitionException。
实战上有三种解法:有明确主力实现用 @Primary;注入点要指定具体实现用 @Qualifier;需要全部实现做策略路由,就注入 Map<String, T> 或 List<T>,这是消灭 if-else 最优雅的方式。
连环追问链
| 追问 | 答题要点 |
|---|---|
注入 List<T> 时顺序怎么定? | 用 @Order 或实现 Ordered 接口,数字小的在前。注意 @Order 只影响集合顺序和切面链顺序,不影响 Bean 创建顺序 |
注入 Map<String, T> 的 key 是什么? | beanName。@Service("aliPay") 就是 aliPay,不写名字则是类名首字母小写 |
| 找不到 Bean 怎么优雅处理? | @Autowired(required = false)、注入 Optional<T>、或注入 ObjectProvider<T> 后调 getIfAvailable() |
单例里注入 prototype,为什么拿到的总是同一个? | 注入只在创建单例时发生一次。要每次都新建得用 ObjectProvider#getObject()、@Lookup 方法注入,或给 prototype Bean 加 @Scope(proxyMode = ScopedProxyMode.TARGET_CLASS) |
| 泛型注入能区分吗? | 可以。Spring 4+ 支持泛型作为限定条件,@Autowired Handler<OrderEvent> h 能精确匹配到 Handler<OrderEvent> 的实现,不会和 Handler<PayEvent> 混淆 |
✅ 项目关联话术
「我们支付模块有六个渠道实现,早期是一个 40 行的 switch。重构时改成注入 Map<String, PayService>,在 SmartInitializingSingleton 里按渠道码建索引,新增渠道只要加一个 @Service 实现类,路由代码零改动。另外我们有一个注入歧义引发的事故:两个模块各自定义了一个 RestTemplate Bean,启动就挂。排查后我们定了规范——所有基础设施类 Bean 必须显式命名 + 给默认实现打 @Primary,并且在自动配置里统一用 @ConditionalOnMissingBean 让业务侧可覆盖。」
题 6:@Transactional 在哪些情况下会失效?事务传播行为有哪几种?
精简口述答案(90 秒版)
失效原因归两大类:没走代理 和 异常没被识别。
没走代理的四种:① 同类内部 this.method() 自调用;② 方法不是 public;③ 方法或类是 final / static;④ 类根本没被 Spring 管理。
异常没被识别的两种:④ 异常被 try/catch 吞掉,代理层感知不到就照常提交;⑤ 抛的是受检异常,Spring 默认只对 RuntimeException 和 Error 回滚,必须显式写 rollbackFor = Exception.class。
另外还有两种环境因素:数据库引擎不支持事务(MyISAM);以及在子线程里执行 DB 操作——事务通过 ThreadLocal 绑定连接,子线程拿不到。
传播行为七种,最常考三个:REQUIRED 默认,有就加入、没有就新建;REQUIRES_NEW 挂起外层、另开一个数据库连接,外层回滚时内层已提交的数据保留;NESTED 同一连接打 Savepoint,外层回滚时内层跟着一起回滚。这个「外层回滚时内层数据是否保留」正是两者最本质的区别。
连环追问链
| 追问 | 答题要点 |
|---|---|
| 自调用失效的根因和解法? | this 是真身引用,不经过代理,切面逻辑完全没机会执行。解法按推荐度:拆到另一个 Bean > 自注入 > exposeProxy=true + AopContext.currentProxy() > 用 TransactionTemplate 编程式事务 |
REQUIRES_NEW 有什么风险? | 占两个数据库连接,高并发下可能耗尽连接池;内外层操作同一行时可能互相锁等待导致死锁 |
| 事务和其他切面的执行顺序怎么控制? | @Transactional 对应的 TransactionInterceptor 默认 order 是 Integer.MAX_VALUE(最内层)。想让自定义切面在事务外层就给它一个更小的 @Order;比如分布式锁切面必须在事务外,否则锁释放早于事务提交,会出现并发问题 |
只读事务 readOnly = true 有什么用? | 给数据库和 ORM 优化提示:MySQL 可走只读副本、Hibernate 会跳过脏检查刷新。注意它不是"禁止写",写了可能直接报错也可能静默不生效,取决于驱动 |
| 事务里发消息怎么保证一致性? | 不要在事务方法里直发 MQ(事务可能回滚但消息已发出)。用 @TransactionalEventListener(phase = AFTER_COMMIT),或本地消息表 / 事务消息 |
✅ 项目关联话术
「我们线上出过一次典型的自调用事务失效:下单方法里 this.saveOrderAndDeductStock(),扣库存失败后订单却留下来了,产生了一批脏数据。定位后我们做了三件事:一是把事务方法统一拆到独立的 XxxTxService,二是规范所有 @Transactional 必须写 rollbackFor = Exception.class,三是在 CI 里加了静态检查,禁止在同类内部调用带 @Transactional 的方法。还有一个顺序问题:我们的分布式锁切面原来 order 比事务大,变成了锁在事务内层,锁释放时事务还没提交,并发下照样超卖;把锁切面的 @Order 调成 -1 放到事务外层才彻底解决。」
题 7:Bean 有哪些作用域?单例 Bean 是线程安全的吗?
精简口述答案(90 秒版)
作用域六种:singleton(默认,容器启动创建、关闭销毁)、prototype(每次 getBean 新建,容器不管销毁)、以及 Web 环境下的 request、session、application、websocket。
单例 Bean 本身不保证线程安全,但是否出问题取决于有没有可变的实例字段:
- 只有方法局部变量 → 安全,局部变量在各线程自己的虚拟机栈上,天然隔离;
- 有可变实例字段 → 不安全,所有线程共享同一块堆内存;
- 字段不可变(
final且引用对象也不可变)→ 安全,没有写就没有竞态; - 用
ThreadLocal存状态 → 安全,但必须在 finally 里remove(),否则线程池复用会读到上一个请求的脏数据,还会内存泄漏。
所以结论是:Service / DAO 这类无状态组件用单例完全安全,前提是不要把请求级状态塞进实例字段。
连环追问链
| 追问 | 答题要点 |
|---|---|
| 为什么单例是默认作用域? | 绝大多数 Service / DAO 是无状态的,单例省去重复创建开销、省内存,还能让 AOP 代理、连接池等资源复用 |
SimpleDateFormat 为什么不能做单例字段? | 它内部用 Calendar 实例字段保存解析中间状态,多线程并发会互相覆盖,可能解析出错误日期甚至抛异常。改用 DateTimeFormatter(不可变、线程安全) |
| request 作用域的 Bean 怎么注入到单例里? | 必须加 @Scope(proxyMode = ScopedProxyMode.TARGET_CLASS)。因为单例在容器启动时创建,那一刻没有 HTTP 请求。加了作用域代理后注入的是空壳代理,方法调用时才去 RequestContextHolder 找当前线程的实例 |
ThreadLocal 不 remove 会怎样? | 两个后果:① 线程池复用线程,下个请求读到上个请求的数据(数据串了,比泄漏更可怕);② ThreadLocalMap 的 Entry key 是弱引用但 value 是强引用,key 被回收后 value 变成不可达却回收不掉,造成泄漏 |
| Spring 的单例和设计模式的单例一样吗? | 不一样。设计模式的单例是整个 JVM 一个实例(私有构造器 + 静态持有);Spring 的单例是每个容器内、每个 beanName 一个实例,同一个类注册两个不同 beanName 就有两个实例,父子容器也可能各有一份 |
✅ 项目关联话术
「我们排查过一个很隐蔽的线上问题:某个导出接口偶发导出别人的数据。最后发现是有人在单例 Service 里加了一个 private Long currentUserId 字段,在方法开头赋值然后在私有方法里读,并发时被别的线程覆盖了。修复方案是把状态改成参数传递;同时我们把用户上下文统一收口到一个 ThreadLocal 工具类,在拦截器的 afterCompletion 里强制 remove,并且在 @Async 的线程池装饰器里做了上下文透传,避免异步任务里拿不到用户信息。」
题 8:@Configuration 的 proxyBeanMethods 是什么?@Bean 方法互相调用会创建两个实例吗?
精简口述答案(90 秒版)
不会,默认情况下仍然只有一个实例。因为 @Configuration 默认 proxyBeanMethods = true(Full 模式),Spring 会用 ConfigurationClassEnhancer 给配置类生成一个 CGLIB 子类,覆写每个 @Bean 方法。拦截逻辑是:先查容器里有没有这个 beanName,有就直接返回容器里的,没有才调父类原方法创建并注册。这就保证了单例语义。
如果显式设成 proxyBeanMethods = false(Lite 模式),就不生成 CGLIB 子类,@Bean 方法退化为普通方法。这时手动调 b() 会真的 new 一个新对象,而且不进容器——这就是 Lite 模式下最容易踩的坑。
Lite 模式的好处是启动更快、少一次字节码生成,所以 Spring Boot 内部大量自动配置类都标了 proxyBeanMethods = false。
正确写法是不管哪种模式都不要在 @Bean 方法里直接调用另一个 @Bean 方法,要依赖就声明成方法参数,让容器去解析注入。
连环追问链
| 追问 | 答题要点 |
|---|---|
@Configuration 和 @Component 里的 @Bean 有区别吗? | 有。@Component 里的 @Bean 方法等价于 Lite 模式(无 CGLIB 增强),方法间互调会产生不受容器管理的新对象。所以配置类要用 @Configuration |
@Import 有哪几种用法? | ① 导入普通类注册成 Bean;② 导入 @Configuration 配置类;③ 导入 ImportSelector(返回类名数组,Spring Boot 自动配置的核心);④ 导入 ImportBeanDefinitionRegistrar(编程式注册 BeanDefinition,MyBatis @MapperScan 用的就是它) |
| Spring Boot 自动配置的原理? | @SpringBootApplication 里的 @EnableAutoConfiguration → @Import(AutoConfigurationImportSelector.class) → 读 META-INF/spring/*.AutoConfiguration.imports(2.7 之前是 spring.factories)→ 经过 @Conditional 过滤 → 注册成 BeanDefinition |
@ConditionalOnMissingBean 为什么能让用户配置优先? | 它判断的是"评估那一刻容器里有没有",所以依赖 BeanDefinition 注册顺序。自动配置类通过 @AutoConfigureAfter 保证在用户配置之后才被评估。在普通 @Component 上用它结果不可预测 |
| 为什么 AOT / 原生镜像下有些写法会失效? | AOT 把 BeanDefinition 固化到编译期,运行期不再扫描、不再动态生成代理。所以运行期动态注册 BeanDefinition、运行期反射造类这类"黑魔法"都会失效,要改用 @Conditional 条件装配 |
✅ 项目关联话术
「我们写内部 Starter 时踩过 Lite 模式的坑:为了启动性能给配置类加了 proxyBeanMethods = false,但代码里还保留着 @Bean public A a(){ return new A(b()); } 这种互调写法,结果线上出现了两个 DataSource,连接数翻倍。修复后我们把团队规范定成:@Bean 方法之间一律通过方法参数声明依赖,绝不直接互相调用——这样两种模式下行为都正确,依赖关系在方法签名上也一目了然。」
题 9:Spring 的配置是怎么注入进来的?@Value 和 @ConfigurationProperties 怎么选?
基础简答
Spring 把所有配置来源抽象成 PropertySource,按顺序放在 Environment 的 MutablePropertySources 里,查找时从前往后第一个命中即返回,这就是「命令行参数 > 环境变量 > application-{profile}.yml > application.yml > 默认值」优先级的由来。
@Value 的注入链路是:PropertySourcesPlaceholderConfigurer(一个 BFPP)向容器注册 embeddedValueResolver,AutowiredAnnotationBeanPostProcessor 在属性填充阶段拿到 @Value 的字符串,先做 ${} 占位符替换,若含 #{} 再交给 SpEL 求值,最后用 ConversionService 转成字段类型。
选型上:零散开关用 @Value,一个模块的配置集合一律用 @ConfigurationProperties。后者支持松散绑定、复杂类型、@Validated 启动期校验和 IDE 元数据提示,@Value 三样都没有。
连环追问链
| 追问 | 答题要点 |
|---|---|
${} 和 #{} 有什么区别? | ${} 是占位符文本替换,由 Environment 完成;#{} 是 SpEL 表达式求值,能调方法、引用 Bean。混用时顺序是先 ${} 后 #{},所以要写成 #{'${a.b}'.split(',')} |
@Value 注入 static 字段为什么是 null? | 依赖注入只作用于实例字段,AutowiredAnnotationBeanPostProcessor 会跳过 static。要么改成实例字段,要么在 @PostConstruct 里手动赋给静态变量 |
| 配置项不存在会怎样? | @Value("${a.b}") 直接抛 IllegalArgumentException 导致启动失败。应写 ${a.b:默认值},或注入 @Value("${a.b:#{null}}") |
@ConfigurationProperties 为什么支持松散绑定? | 它走 Binder + ConfigurationPropertyName 规范化,会把 db-url、db_url、DB_URL、dbUrl 统一成同一个 name。这正是 K8s 环境变量能映射到 yml 配置的原因 |
@ConfigurationProperties 的类需要加 @Component 吗? | 不一定。三种注册方式:① 类上加 @Component;② 配置类上 @EnableConfigurationProperties(X.class);③ 启动类上 @ConfigurationPropertiesScan。只加注解不注册等于没写 |
| 它是在 Bean 生命周期哪一步绑定的? | ConfigurationPropertiesBindingPostProcessor(一个 BPP)在 postProcessBeforeInitialization 里绑定,所以 @PostConstruct 里能拿到完整配置,但构造器里拿不到(除非用构造器绑定) |
| 怎么让配置写错在启动期就暴露? | @ConfigurationProperties + @Validated + JSR-303 注解,绑定失败直接 BindValidationException 启动失败。这比运行时 NPE 便宜得多 |
| 配置能热更新吗? | 原生不能。@Value 注入后就是个普通字段。要热更新得靠 Spring Cloud 的 @RefreshScope(本质是作用域代理 + 销毁重建),或自己监听配置中心事件后手动 set |
@PropertySource 能读 yml 吗? | 默认只支持 properties,读 yml 要自己写 PropertySourceFactory。这也是推荐用 application.yml 而非 @PropertySource 的原因 |
✅ 项目关联话术
「我们线上出过一次事故:@Value("${pay.timeout}") 注入到了一个工具类的 static 字段上,本地测试因为有默认值没暴露,上线后超时时间一直是 0,导致支付大面积超时。复盘后定了两条规范:一是配置一律收口到 @ConfigurationProperties 配置类,配 @Validated 让非法配置在启动期失败;二是禁止给 static 字段做注入,并加了 ArchUnit 规则在 CI 里卡住。改完之后这类问题再没出现过。」
题 10:@ComponentScan 是怎么扫描到 @Component 的?自定义组合注解为什么能生效?
基础简答
@ComponentScan 由 ConfigurationClassPostProcessor 解析,真正干活的是 ClassPathBeanDefinitionScanner:把包名转成 classpath*:com/x/**/*.class 资源,用 ASM 直接读字节码拿注解元数据(只读元数据、不加载类,所以快且无副作用),再经过 excludeFilters / includeFilters 过滤,判断是独立具体类后生成 ScannedGenericBeanDefinition 注册进 registry。
组合注解能生效,是因为 Spring 不用 JDK 原生的 getAnnotation,而是用 MergedAnnotations 递归向上搜索元注解。@Service 本身只是「一个标了 @Component 的注解」,所以我自定义一个标了 @Service + @Transactional 的 @BizService,一样会被识别成组件并带上事务语义,@AliasFor 还能让属性互相映射。
连环追问链
| 追问 | 答题要点 |
|---|---|
| 扫描阶段会加载类吗? | 不会。走 ASM 读字节码(MetadataReader),避免触发静态初始化和大量类加载。只有真正要实例化时才 Class.forName |
父类标了 @Component,子类会被注册吗? | 不会。@Component 没有 @Inherited,类注解默认不继承;而且抽象类会被 isCandidateComponent 直接排除 |
| 两个不同包下的同名类都被扫到会怎样? | 默认 beanName 都是类名首字母小写,发生冲突直接抛 ConflictingBeanDefinitionException。解法是自定义 BeanNameGenerator 或显式指定 @Component("xxx") |
| 怎么批量把某个接口的所有实现纳入容器? | @ComponentScan(includeFilters = @Filter(type = ASSIGNABLE_TYPE, classes = BaseHandler.class)),或用 ImportBeanDefinitionRegistrar 自己注册(MyBatis @MapperScan 就是这么做的) |
@SpringBootApplication 默认扫哪些包? | @ComponentScan 不写 basePackages 时扫当前配置类所在包及其子包。所以启动类必须放在最外层包,否则扫不到兄弟包 |
为什么 @Transactional 标在接口上有时失效? | 方法注解不继承,CGLIB 代理的是类,拿不到接口方法上的注解。规范是一律标在实现类的方法上 |
| 扫描慢怎么优化? | 缩小 basePackages、减少 ** 通配、用 @Import 显式导入代替大面积扫描;终极手段是 Spring 6 AOT——把扫描结果固化到编译期 |
✅ 项目关联话术
「我们内部 Starter 里做了一个 @BizService 组合注解,把 @Service、@Transactional(rollbackFor = Exception.class)、@Validated 合三为一,靠的就是 MergedAnnotations 递归搜元注解。好处是把团队规范编码进注解,新同学不会再漏写 rollbackFor。另外我们排查过启动慢的问题,发现有人把 @ComponentScan 写到了根包 com,连第三方依赖的包都被扫了一遍,收窄包路径后启动时间从 18 秒降到 9 秒。」
✍️ 手撕代码【核心实现 + 边界 Case 自测清单 + 变种拓展题】
题 1:手写迷你 IOC 容器(注解扫描 + 实例化 + 字段注入 + 三级缓存解环)
考察点:能不能把"容器"这个黑盒拆成四个动作——扫描、注册 BeanDefinition、实例化、注入;以及三级缓存到底怎么落地。
java
// ========== 1. 注解定义 ==========
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface MyComponent {
String value() default "";
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface MyAutowired {
}
// ========== 2. BeanDefinition:Bean 的"身份证" ==========
public class BeanDefinition {
private final String beanName;
private final Class<?> beanClass;
private String scope = "singleton";
public BeanDefinition(String beanName, Class<?> beanClass) {
this.beanName = beanName;
this.beanClass = beanClass;
}
public String getBeanName() { return beanName; }
public Class<?> getBeanClass() { return beanClass; }
public String getScope() { return scope; }
public void setScope(String scope) { this.scope = scope; }
}
// ========== 3. 容器核心 ==========
public class MiniApplicationContext {
/** 一级缓存:完整可用的成品单例 */
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(64);
/** 二级缓存:已曝光但未注入完成的半成品(可能是代理对象) */
private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16);
/** 三级缓存:对象工厂,延迟决定"暴露原始对象还是代理对象" */
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);
/** 正在创建中的 Bean,用于检测构造器循环依赖 */
private final Set<String> singletonsCurrentlyInCreation =
Collections.synchronizedSet(new HashSet<>());
private final Map<String, BeanDefinition> beanDefinitionMap = new LinkedHashMap<>();
/** 模拟 BeanPostProcessor 链 */
private final List<MyBeanPostProcessor> processors = new ArrayList<>();
public MiniApplicationContext(String basePackage) {
scan(basePackage); // ① 扫描并注册 BeanDefinition
registerBeanPostProcessors(); // ② 先把后置处理器自己创建好
preInstantiateSingletons(); // ③ 预实例化所有非懒加载单例
}
// ---------- ① 扫描 ----------
private void scan(String basePackage) {
for (Class<?> clazz : ClassScanner.scan(basePackage)) {
MyComponent ann = clazz.getAnnotation(MyComponent.class);
if (ann == null) continue;
String beanName = ann.value().isEmpty()
? Introspector.decapitalize(clazz.getSimpleName())
: ann.value();
// ⚠️ 同名冲突必须快速失败,否则后注册的会静默覆盖前者
if (beanDefinitionMap.containsKey(beanName)) {
throw new IllegalStateException("重复的 beanName: " + beanName);
}
beanDefinitionMap.put(beanName, new BeanDefinition(beanName, clazz));
}
}
private void registerBeanPostProcessors() {
for (BeanDefinition bd : new ArrayList<>(beanDefinitionMap.values())) {
if (MyBeanPostProcessor.class.isAssignableFrom(bd.getBeanClass())) {
processors.add((MyBeanPostProcessor) getBean(bd.getBeanName()));
}
}
}
private void preInstantiateSingletons() {
for (BeanDefinition bd : new ArrayList<>(beanDefinitionMap.values())) {
if ("singleton".equals(bd.getScope())) {
getBean(bd.getBeanName());
}
}
}
// ---------- ② 获取 Bean:三级缓存查找 ----------
@SuppressWarnings("unchecked")
public <T> T getBean(String beanName) {
Object singleton = getSingleton(beanName, true);
if (singleton != null) {
return (T) singleton;
}
BeanDefinition bd = beanDefinitionMap.get(beanName);
if (bd == null) {
throw new NoSuchElementException("No bean named '" + beanName + "'");
}
if ("prototype".equals(bd.getScope())) {
return (T) createBean(bd); // prototype 不进缓存,也不解环
}
synchronized (singletonObjects) {
singleton = getSingleton(beanName, true);
if (singleton != null) return (T) singleton;
if (!singletonsCurrentlyInCreation.add(beanName)) {
throw new IllegalStateException("无法解决的循环依赖(构造器注入): " + beanName);
}
try {
Object bean = createBean(bd);
singletonObjects.put(beanName, bean);
earlySingletonObjects.remove(beanName);
singletonFactories.remove(beanName);
return (T) bean;
} finally {
singletonsCurrentlyInCreation.remove(beanName);
}
}
}
/** 一级 → 二级 → 三级 依次查找,这就是 Spring 的 getSingleton(name, true) */
private Object getSingleton(String beanName, boolean allowEarlyReference) {
Object bean = singletonObjects.get(beanName);
if (bean == null && singletonsCurrentlyInCreation.contains(beanName)) {
bean = earlySingletonObjects.get(beanName);
if (bean == null && allowEarlyReference) {
ObjectFactory<?> factory = singletonFactories.get(beanName);
if (factory != null) {
bean = factory.getObject(); // 这里才决定代理还是原始对象
earlySingletonObjects.put(beanName, bean); // 升级到二级,保证唯一
singletonFactories.remove(beanName);
}
}
}
return bean;
}
}创建流程:实例化 → 提前曝光 → 注入 → 初始化
java
private Object createBean(BeanDefinition bd) {
String beanName = bd.getBeanName();
Object raw;
try {
Constructor<?> ctor = bd.getBeanClass().getDeclaredConstructor();
ctor.setAccessible(true);
raw = ctor.newInstance(); // ③ 实例化(空壳)
} catch (Exception e) {
throw new IllegalStateException("实例化失败: " + beanName, e);
}
// ④ 提前曝光:放入三级缓存。注意放的是 lambda,不是对象本身
final Object exposed = raw;
if ("singleton".equals(bd.getScope())) {
singletonFactories.put(beanName, () -> getEarlyBeanReference(beanName, exposed));
}
populateBean(beanName, raw); // ⑤ 字段注入
return initializeBean(beanName, raw); // ⑥ 初始化 + 后置处理
}
/** 模拟 SmartInstantiationAwareBeanPostProcessor:决定提前曝光的是原始对象还是代理 */
private Object getEarlyBeanReference(String beanName, Object bean) {
Object result = bean;
for (MyBeanPostProcessor p : processors) {
result = p.getEarlyBeanReference(beanName, result);
}
return result;
}
private void populateBean(String beanName, Object bean) {
for (Field field : bean.getClass().getDeclaredFields()) {
if (!field.isAnnotationPresent(MyAutowired.class)) continue;
// 简化版:先按名字找,找不到按类型找
String dependName = field.getName();
Object dependency = beanDefinitionMap.containsKey(dependName)
? getBean(dependName)
: getBeanByType(field.getType());
field.setAccessible(true);
try {
field.set(bean, dependency);
} catch (IllegalAccessException e) {
throw new IllegalStateException("注入失败: " + beanName + "." + field.getName(), e);
}
}
}
private Object getBeanByType(Class<?> type) {
List<String> candidates = beanDefinitionMap.values().stream()
.filter(bd -> type.isAssignableFrom(bd.getBeanClass()))
.map(BeanDefinition::getBeanName)
.collect(Collectors.toList());
if (candidates.isEmpty()) {
throw new NoSuchElementException("找不到类型为 " + type.getName() + " 的 Bean");
}
if (candidates.size() > 1) { // 对应 Spring 的 NoUniqueBeanDefinitionException
throw new IllegalStateException("存在多个候选 Bean: " + candidates);
}
return getBean(candidates.get(0));
}
private Object initializeBean(String beanName, Object bean) {
// Aware 回调
if (bean instanceof MyBeanNameAware) {
((MyBeanNameAware) bean).setBeanName(beanName);
}
// BPP before
Object result = bean;
for (MyBeanPostProcessor p : processors) {
result = p.postProcessBeforeInitialization(beanName, result);
}
// 初始化方法
if (result instanceof MyInitializingBean) {
((MyInitializingBean) result).afterPropertiesSet();
}
// BPP after:AOP 代理通常在这里产生
for (MyBeanPostProcessor p : processors) {
result = p.postProcessAfterInitialization(beanName, result);
}
return result;
}🔍 边界 Case 自测清单
| # | Case | 期望行为 |
|---|---|---|
| 1 | A 字段注入 B,B 字段注入 A | 两边都能拿到对方,启动成功 |
| 2 | A 构造器注入 B,B 构造器注入 A | 抛出"无法解决的循环依赖",而不是 StackOverflowError |
| 3 | A 自己注入自己 | 同样走三级缓存,能成功 |
| 4 | 循环依赖中的 A 被 AOP 代理 | B 持有的必须是代理对象,不是原始对象(用 b.getA() == context.getBean("a") 断言) |
| 5 | 两个类同名 beanName | 启动期快速失败,不静默覆盖 |
| 6 | 一个接口两个实现,按类型注入 | 抛"存在多个候选 Bean",提示而不是随机挑一个 |
| 7 | 注入字段类型找不到任何 Bean | 抛 NoSuchElementException,信息里带字段名 |
| 8 | prototype Bean 参与循环依赖 | 直接报错(不进缓存无法解环),与 Spring 行为一致 |
| 9 | 并发 getBean 同一个单例 | 只实例化一次,双检锁生效 |
| 10 | getBean 一个不存在的名字 | 抛异常而不是返回 null |
🚀 变种拓展题
- 加上
@MyValue("${xxx}")属性注入,并支持占位符解析(模拟PropertySourcesPlaceholderConfigurer)。 - 支持 构造器注入:解析参数类型递归
getBean,并保留对循环依赖的快速失败。 - 支持
@MyLazy:注入时先放一个 JDK 代理,首次调用方法时才真正getBean。 - 实现
destroy回调:容器close()时逆序调用DisposableBean,并解释为什么 prototype 不会被调用。 - 把二级缓存删掉,跑一遍 Case 4,观察"同一个 Bean 被代理两次"的现象,用它反向证明二级缓存的必要性。
题 2:手写 JDK 动态代理与 CGLIB 代理,实现简易 AOP 链
考察点:两种代理的 API 差异、代理失效的本质、以及"多个通知如何串成责任链"。
java
// ========== 目标对象 ==========
public interface OrderService {
String createOrder(String sku);
}
public class OrderServiceImpl implements OrderService {
@Override
public String createOrder(String sku) {
System.out.println(" [target] 真正下单: " + sku);
return "ORDER-" + sku;
}
}
// ========== 1. JDK 动态代理:基于接口 ==========
public class JdkLogProxy implements InvocationHandler {
private final Object target;
private JdkLogProxy(Object target) { this.target = target; }
@SuppressWarnings("unchecked")
public static <T> T create(T target) {
return (T) Proxy.newProxyInstance(
target.getClass().getClassLoader(),
target.getClass().getInterfaces(), // ⚠️ 只代理接口里的方法
new JdkLogProxy(target));
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// Object 自带方法不要加增强,否则 equals/hashCode 行为会被污染
if (method.getDeclaringClass() == Object.class) {
return method.invoke(target, args);
}
long start = System.nanoTime();
try {
Object result = method.invoke(target, args); // 环绕通知的"前后夹"
System.out.println(" [jdk] " + method.getName() + " 返回: " + result);
return result;
} catch (InvocationTargetException e) {
// ⚠️ 必须拆包,否则调用方看到的是 InvocationTargetException 而非业务异常
System.out.println(" [jdk] 异常通知: " + e.getTargetException());
throw e.getTargetException();
} finally {
System.out.println(" [jdk] 耗时 " + (System.nanoTime() - start) / 1000 + "us");
}
}
}
// ========== 2. CGLIB 代理:基于子类 ==========
public class CglibLogProxy implements MethodInterceptor {
@SuppressWarnings("unchecked")
public static <T> T create(Class<T> targetClass) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(targetClass); // 生成目标类的子类
enhancer.setCallback(new CglibLogProxy());
return (T) enhancer.create(); // 会调用一次父类构造器
}
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy)
throws Throwable {
if (Modifier.isFinal(method.getModifiers())) { // final 方法根本不会走到这里
return proxy.invokeSuper(obj, args);
}
System.out.println(" [cglib] before " + method.getName());
try {
// invokeSuper 走 FastClass,不走反射;invoke 会导致递归 StackOverflow
return proxy.invokeSuper(obj, args);
} finally {
System.out.println(" [cglib] after " + method.getName());
}
}
}把多个通知串成责任链(Spring 的 ReflectiveMethodInvocation 简化版)
java
public interface MyInterceptor {
Object intercept(MyInvocation invocation) throws Throwable;
}
/** 维护一个游标,每次 proceed() 往后走一个拦截器,走完才调目标方法 */
public class MyInvocation {
private final Object target;
private final Method method;
private final Object[] args;
private final List<MyInterceptor> chain;
private int cursor = -1;
public MyInvocation(Object target, Method method, Object[] args,
List<MyInterceptor> chain) {
this.target = target; this.method = method;
this.args = args; this.chain = chain;
}
public Object proceed() throws Throwable {
if (++cursor == chain.size()) {
return method.invoke(target, args); // 链条走到底,执行真正业务
}
return chain.get(cursor).intercept(this);
}
public Method getMethod() { return method; }
public Object[] getArgs() { return args; }
}
// 三个拦截器,注册顺序即执行顺序
List<MyInterceptor> chain = Arrays.asList(
inv -> { // 事务(最外层)
System.out.println("tx begin");
try {
Object r = inv.proceed();
System.out.println("tx commit");
return r;
} catch (Throwable t) {
System.out.println("tx rollback");
throw t;
}
},
inv -> { // 日志
System.out.println("log before " + inv.getMethod().getName());
try { return inv.proceed(); }
finally { System.out.println("log after"); }
},
inv -> { // 参数校验(最内层)
if (inv.getArgs()[0] == null) throw new IllegalArgumentException("sku 不能为空");
return inv.proceed();
}
);执行顺序与"洋葱模型":
text
tx begin
log before createOrder
validate
[target] 真正下单
(返回)
log after
tx commit📌 单独澄清:为什么 proceed() 必须是实例方法而不是静态递归
游标 cursor 是 MyInvocation 的实例字段,一次方法调用对应一个 MyInvocation 实例。这也解释了 Spring 里为什么 MethodInvocation 不能复用、不能在多线程间共享——它是一次调用的上下文,不是单例组件。
🔍 边界 Case 自测清单
| # | Case | 期望行为 |
|---|---|---|
| 1 | JDK 代理一个没有接口的类 | 抛异常/无法代理,验证"JDK 代理必须有接口" |
| 2 | CGLIB 代理 final 类 | Enhancer.create() 直接报 Cannot subclass final class |
| 3 | CGLIB 代理里存在 final 方法 | 该方法不被增强,直接走原逻辑 |
| 4 | CGLIB 代理 private 方法 | 不被增强(子类看不见父类私有方法) |
| 5 | 目标方法抛业务异常 | 调用方捕获到的是业务异常本身,不是 InvocationTargetException |
| 6 | 目标类没有无参构造器 | CGLIB 用 Enhancer 创建会失败,验证"CGLIB 要调父类构造器" |
| 7 | 代理对象调 toString()/equals() | 不被日志增强污染 |
| 8 | 链上某个拦截器不调 proceed() | 短路返回,目标方法根本不执行(权限拦截就是这么做的) |
| 9 | 同一个 MyInvocation 调两次 proceed() | 游标继续前进,验证"不可复用" |
| 10 | CGLIB 中误用 proxy.invoke 代替 invokeSuper | 出现无限递归 StackOverflowError |
🚀 变种拓展题
- 加上 切点匹配:只对带
@MyLog注解的方法生效,注意注解在接口上还是实现类上的差异(AnnotationUtils.findAnnotation)。 - 实现 代理选型逻辑:有接口用 JDK、无接口用 CGLIB,再加一个
proxyTargetClass开关强制 CGLIB。 - 把拦截器链接入题 1 的
MiniApplicationContext,在postProcessAfterInitialization里返回代理对象。 - 实现
exposeProxy:用ThreadLocal保存当前代理,提供AopContext.currentProxy(),解决自调用失效。 - 用
ByteBuddy或ASM重写一遍,对比启动耗时与首次调用耗时。
题 3:自定义注解 + 切面实战(耗时监控 / 审计日志 / 分布式幂等)
考察点:真实项目里 AOP 的落地写法,尤其是 @Around 的参数获取、SpEL 解析 key、以及切面之间的顺序。
java
// ========== 1. 幂等注解 ==========
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Idempotent {
/** 支持 SpEL,如 "#order.orderNo" 或 "#userId + ':' + #sku" */
String key();
long expire() default 5;
TimeUnit unit() default TimeUnit.SECONDS;
String message() default "操作正在处理中,请勿重复提交";
}
// ========== 2. 切面实现 ==========
@Aspect
@Component
@Order(20) // 数字越小越外层;要在事务(默认 Ordered.LOWEST_PRECEDENCE)外面
public class IdempotentAspect {
private final StringRedisTemplate redisTemplate;
private final ExpressionParser parser = new SpelExpressionParser();
private final ParameterNameDiscoverer discoverer =
new DefaultParameterNameDiscoverer();
public IdempotentAspect(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint pjp, Idempotent idempotent) throws Throwable {
String key = "idem:" + resolveKey(pjp, idempotent.key());
String token = UUID.randomUUID().toString();
Boolean ok = redisTemplate.opsForValue()
.setIfAbsent(key, token, idempotent.expire(), idempotent.unit());
if (!Boolean.TRUE.equals(ok)) {
throw new BizException(idempotent.message());
}
try {
return pjp.proceed();
} catch (Throwable t) {
// 业务失败要释放锁,允许重试;成功则让 key 自然过期防重放
releaseIfOwner(key, token);
throw t;
}
}
/** ⚠️ 删锁必须校验持有者,否则会删掉别人的锁 */
private void releaseIfOwner(String key, String token) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] "
+ "then return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(key), token);
}
private String resolveKey(ProceedingJoinPoint pjp, String spel) {
MethodSignature signature = (MethodSignature) pjp.getSignature();
Method method = signature.getMethod();
String[] names = discoverer.getParameterNames(method);
Object[] args = pjp.getArgs();
EvaluationContext ctx = new StandardEvaluationContext();
if (names != null) {
for (int i = 0; i < names.length; i++) {
ctx.setVariable(names[i], args[i]);
}
}
Object value = parser.parseExpression(spel).getValue(ctx);
if (value == null) {
throw new IllegalArgumentException("幂等 key 解析为空: " + spel);
}
return String.valueOf(value);
}
}耗时监控 + 审计日志切面
java
@Aspect
@Component
@Order(10) // 最外层:无论幂等拦截还是事务回滚,耗时都要记到
public class MonitorAspect {
private static final Logger log = LoggerFactory.getLogger(MonitorAspect.class);
/** 切点复用:定义一次,到处引用 */
@Pointcut("execution(public * com.example.*.controller..*.*(..))")
public void controllerLayer() {}
@Around("controllerLayer()")
public Object monitor(ProceedingJoinPoint pjp) throws Throwable {
MethodSignature sig = (MethodSignature) pjp.getSignature();
String name = sig.getDeclaringType().getSimpleName() + "#" + sig.getName();
long start = System.currentTimeMillis();
String result = "success";
try {
return pjp.proceed();
} catch (Throwable t) {
result = "fail:" + t.getClass().getSimpleName();
throw t; // ⚠️ 必须原样抛出,不能吞异常
} finally {
long cost = System.currentTimeMillis() - start;
// 慢接口单独告警,正常接口只打 debug,避免日志爆量
if (cost > 500) {
log.warn("[slow] {} cost={}ms result={}", name, cost, result);
} else if (log.isDebugEnabled()) {
log.debug("[monitor] {} cost={}ms", name, cost);
}
Metrics.timer("api.cost", "api", name, "result", result)
.record(cost, TimeUnit.MILLISECONDS);
}
}
}
@Aspect
@Component
public class AuditAspect {
/** 事务提交后再落审计,避免"审计有记录、业务已回滚" */
@AfterReturning(pointcut = "@annotation(audit)", returning = "ret")
public void afterReturning(JoinPoint jp, Audit audit, Object ret) {
AuditEvent event = new AuditEvent(audit.action(), currentUserId(),
JSON.toJSONString(jp.getArgs()), String.valueOf(ret));
// 发布事件 + @TransactionalEventListener(AFTER_COMMIT) 消费
publisher.publishEvent(event);
}
}📌 单独澄清:幂等切面必须在事务切面的"外面"
| 顺序 | 结果 |
|---|---|
| ❌ 事务在外、幂等在内 | 事务先开启 → 加锁 → 业务失败回滚 → 但锁的释放在事务提交之前/之后不可控,容易出现"事务已回滚、锁还在",用户重试被拦 |
| ✅ 幂等在外、事务在内 | 先抢锁 → 再开事务 → 失败时事务已回滚完毕,切面才释放锁,状态一致 |
同样的道理适用于分布式锁切面:@Order 必须小于事务切面(事务默认 Ordered.LOWEST_PRECEDENCE,即最内层),否则就是经典的"锁在事务内部,锁释放早于事务提交"导致并发穿透。
🔍 边界 Case 自测清单
| # | Case | 期望行为 |
|---|---|---|
| 1 | 同一 key 并发两次请求 | 只有一个进入业务,另一个抛"请勿重复提交" |
| 2 | 业务抛异常 | 锁被释放,允许立即重试 |
| 3 | 业务成功 | 锁不删除,靠过期时间防重放 |
| 4 | SpEL 引用了不存在的参数名 | 启动/运行时明确报错,而不是把 key 变成 null |
| 5 | 编译未加 -parameters | getParameterNames 返回 null,需降级为 #p0 索引写法 |
| 6 | 切面方法内部调同类另一个带注解的方法 | 不生效(自调用),验证代理边界 |
| 7 | 目标方法是 private / final | 切面不生效 |
| 8 | 释放锁时锁已过期并被他人持有 | Lua 脚本校验失败,不误删 |
| 9 | 幂等切面与事务切面顺序颠倒 | 复现"事务回滚但锁未释放"的问题 |
| 10 | 监控切面中目标方法抛异常 | 异常原样向上抛,同时耗时指标照常上报 |
🚀 变种拓展题
- 把幂等改成支持返回上次结果:Redis 里存结果快照,重复请求直接返回首次结果(真正的幂等语义)。
- 加
@RateLimit限流切面,用 Redis + Lua 实现滑动窗口,并与幂等切面排好@Order。 - 用
@Around+TransactionSynchronizationManager实现"事务提交后才释放锁"。 - 把审计从
@AfterReturning改成@TransactionalEventListener(phase = AFTER_COMMIT),对比两者在回滚场景下的差异。 - 用
@DeclareParents(Introduction)给一批 Service 动态"植入"一个新接口,体会 AOP 的引介增强。
题 4:自定义扩展点实战(BFPP 改定义 + BPP 包装对象 + 自定义注解注入)
考察点:两类后置处理器的作用阶段差异,以及"改 BeanDefinition"与"换 Bean 实例"分别该用谁。
java
// ========== 1. BeanFactoryPostProcessor:批量改"图纸" ==========
@Component
public class ScopeAndLazyBfpp implements BeanFactoryPostProcessor {
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) {
for (String name : beanFactory.getBeanDefinitionNames()) {
BeanDefinition bd = beanFactory.getBeanDefinition(name);
String className = bd.getBeanClassName();
if (className == null) continue;
// 把所有 XxxTask 结尾的 Bean 改成懒加载,缩短启动时间
if (className.endsWith("Task")) {
bd.setLazyInit(true);
}
// 给带 @Prototype 标记的类改作用域(注意此时类还没实例化)
if (className.endsWith("Context")) {
bd.setScope(BeanDefinition.SCOPE_PROTOTYPE);
}
}
}
}
// ========== 2. BeanDefinitionRegistryPostProcessor:编程式注册新定义 ==========
@Component
public class MapperRegistrar implements BeanDefinitionRegistryPostProcessor {
@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) {
// 模拟 MyBatis:扫描接口,为每个接口注册一个 FactoryBean
for (Class<?> mapper : ClassScanner.scanInterfaces("com.example.mapper")) {
BeanDefinitionBuilder builder = BeanDefinitionBuilder
.genericBeanDefinition(MapperFactoryBean.class);
builder.addConstructorArgValue(mapper); // 把接口类型传给 FactoryBean
builder.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE);
registry.registerBeanDefinition(
Introspector.decapitalize(mapper.getSimpleName()),
builder.getBeanDefinition());
}
}
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) {
// 这一步在上面之后执行,此时已能看到新注册的定义
}
}
/** FactoryBean:getObject() 返回的才是容器里的 Bean */
public class MapperFactoryBean<T> implements FactoryBean<T> {
private final Class<T> mapperInterface;
public MapperFactoryBean(Class<T> mapperInterface) {
this.mapperInterface = mapperInterface;
}
@Override
@SuppressWarnings("unchecked")
public T getObject() {
return (T) Proxy.newProxyInstance(
mapperInterface.getClassLoader(),
new Class[]{mapperInterface},
(proxy, method, args) -> {
if (method.isDefault()) { // 别漏了接口默认方法
return InvocationHandler.invokeDefault(proxy, method, args);
}
return SqlExecutor.execute(mapperInterface, method, args);
});
}
@Override
public Class<?> getObjectType() { return mapperInterface; } // ⚠️ 必须返回真实类型
@Override
public boolean isSingleton() { return true; }
}BeanPostProcessor:自定义注解注入 + 条件包装
java
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface MyRedisKey {
String prefix();
}
@Component
public class MyRedisKeyBpp implements BeanPostProcessor {
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) {
// 属性注入类型的扩展放在 before(与 @Autowired 的处理阶段一致)
ReflectionUtils.doWithFields(bean.getClass(), field -> {
MyRedisKey ann = field.getAnnotation(MyRedisKey.class);
if (ann == null) return;
if (field.getType() != String.class) {
throw new BeanCreationException(beanName,
"@MyRedisKey 只能标注在 String 字段上: " + field.getName());
}
ReflectionUtils.makeAccessible(field);
ReflectionUtils.setField(field, bean, ann.prefix() + ":" + appEnv());
});
return bean;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
// 对象替换/代理放在 after(AOP 也在这一阶段)
if (!(bean instanceof OrderService)) {
return bean;
}
return Proxy.newProxyInstance(bean.getClass().getClassLoader(),
bean.getClass().getInterfaces(),
(proxy, method, args) -> {
System.out.println("[bpp-proxy] " + method.getName());
try {
return method.invoke(bean, args);
} catch (InvocationTargetException e) {
throw e.getTargetException();
}
});
}
}📌 单独澄清:为什么 BPP 里不能随便 @Autowired 业务 Bean
BPP 自身必须早于普通 Bean 被创建。如果在 BPP 里用 @Autowired 注入一个业务 Bean,就会把那个业务 Bean 的创建时机提前到"所有 BPP 都还没注册完"的时刻,导致它错过后续的 BPP 增强(典型症状:事务注解、AOP 切面对它全部失效,日志里出现 is not eligible for getting processed by all BeanPostProcessors)。
✅ 正确做法:BPP 里依赖的东西改成延迟获取。
java
// ❌ 错误:把 DataSource 的创建提前,它可能错过 AOP/事务增强
@Component
public class BadBpp implements BeanPostProcessor {
@Autowired private DataSource dataSource;
}
// ✅ 正确:持有 BeanFactory,用的时候再取
@Component
public class GoodBpp implements BeanPostProcessor, BeanFactoryAware {
private BeanFactory beanFactory;
@Override
public void setBeanFactory(BeanFactory beanFactory) { this.beanFactory = beanFactory; }
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (bean instanceof ReportService) {
DataSource ds = beanFactory.getBean(DataSource.class); // 用时才取
// ...
}
return bean;
}
}🔍 边界 Case 自测清单
| # | Case | 期望行为 |
|---|---|---|
| 1 | BFPP 里调用 beanFactory.getBean() | 会提前实例化该 Bean,启动日志出现"not eligible"警告 |
| 2 | BFPP 里改作用域为 prototype | 生效(此时还没实例化) |
| 3 | BPP 里改 BeanDefinition | 无效,因为实例已创建,验证"阶段不可逆" |
| 4 | MapperFactoryBean.getObjectType() 返回 null | 按类型注入失败 / 提前实例化,验证它必须返回真实类型 |
| 5 | 用 getBean("mapperFactoryBean") 与 getBean("&mapperFactoryBean") | 前者拿产品,后者拿工厂本身 |
| 6 | @MyRedisKey 标在 int 字段上 | 启动期快速失败并提示字段名 |
| 7 | @MyRedisKey 标在 static final 字段上 | 明确拒绝(final 字段反射赋值行为不可靠) |
| 8 | postProcessAfterInitialization 返回 null | 容器里拿到的 Bean 变成 null,验证"必须返回 bean" |
| 9 | BPP 返回 JDK 代理,但目标类无接口 | 注入按具体类型时失败,验证代理类型必须兼容 |
| 10 | 两个 BPP 都包装同一个 Bean | 形成嵌套代理,确认执行顺序由 @Order 决定 |
🚀 变种拓展题
- 实现一个
@MyValue+ 配置中心热更新:BPP 记录字段位置,收到配置变更事件后反射刷新。 - 实现 接口默认实现兜底:扫描到接口无实现类时,自动注册一个返回默认值的代理 Bean。
- 用
InstantiationAwareBeanPostProcessor.postProcessBeforeInstantiation返回对象,验证它会直接跳过容器后续的实例化与注入流程。 - 用
MergedBeanDefinitionPostProcessor预先缓存需要注入的字段元数据,对比每次反射扫描的性能差异。 - 写一个 BPP 统计每个 Bean 的初始化耗时,输出启动耗时 Top 10(排查慢启动的常用手段)。
题 5:复现并修复三类经典故障(自调用失效 / 循环依赖 / 单例状态污染)
考察点:能不能写出可运行的最小复现,并给出两种以上修复方案,这是面试里最能体现"真踩过坑"的部分。
java
// ========== 故障一:@Transactional 自调用失效 ==========
@Service
public class OrderServiceImpl implements OrderService {
@Autowired private OrderMapper orderMapper;
/** ✅ 修复方案 A:注入自己的代理(Spring 会用三级缓存解掉这个自引用) */
@Autowired @Lazy private OrderService self;
@Override
public void createBatchBad(List<Order> orders) {
for (Order o : orders) {
saveOne(o); // ❌ this.saveOne(),绕过代理,@Transactional 完全失效
}
}
@Override
public void createBatchGoodA(List<Order> orders) {
for (Order o : orders) {
self.saveOne(o); // ✅ 走代理,事务生效
}
}
@Override
public void createBatchGoodB(List<Order> orders) {
for (Order o : orders) {
// ✅ 修复方案 B:AopContext(需 @EnableAspectJAutoProxy(exposeProxy = true))
((OrderService) AopContext.currentProxy()).saveOne(o);
}
}
@Transactional(rollbackFor = Exception.class,
propagation = Propagation.REQUIRES_NEW)
@Override
public void saveOne(Order o) {
orderMapper.insert(o);
if (o.getAmount() < 0) {
throw new IllegalStateException("金额非法");
}
}
}
/** ✅ 修复方案 C(最推荐):把事务方法挪到另一个 Bean,彻底消除自调用 */
@Service
public class OrderWriter {
@Autowired private OrderMapper orderMapper;
@Transactional(rollbackFor = Exception.class)
public void saveOne(Order o) { orderMapper.insert(o); }
}验证事务是否真的生效(不要靠"看起来对")
java
@SpringBootTest
class TxProxyTest {
@Autowired private OrderService orderService;
@Test
void 事务管理器应被真正激活() {
// 方式一:断言当前是代理对象
assertTrue(AopUtils.isAopProxy(orderService));
// 方式二:在方法内部断言事务同步器已激活(最可靠)
// TransactionSynchronizationManager.isActualTransactionActive()
// 自调用场景下这里会返回 false,一眼看出失效
}
@Test
void 自调用场景下第二条应已落库未回滚() {
List<Order> orders = List.of(new Order(10), new Order(-1));
assertThrows(IllegalStateException.class,
() -> orderService.createBatchBad(orders));
// ❌ 失效的证据:第一条数据留在库里
assertEquals(1, orderMapper.count());
}
}故障二:循环依赖的三种结局
java
// ① 字段注入 + 单例 → Spring 能解(三级缓存)
@Service class A1 { @Autowired B1 b; }
@Service class B1 { @Autowired A1 a; }
// ② 构造器注入 → 无法解,启动直接失败
@Service class A2 { A2(B2 b) {} }
@Service class B2 { B2(A2 a) {} }
// BeanCurrentlyInCreationException: Requested bean is currently in creation
// ③ 构造器注入 + @Lazy → 能解(注入的是延迟代理)
@Service class A3 { A3(@Lazy B3 b) {} }
@Service class B3 { B3(A3 a) {} }
// ④ 字段注入 + 但 A 被 AOP 代理且用了 @Async → 可能失败
// BeanCurrentlyInCreationException / 或拿到的是未增强的原始对象
@Service class A4 { @Autowired B4 b; @Async public void run() {} }
@Service class B4 { @Autowired A4 a; }四种破环手段(按推荐度排序):
text
1. 重构:抽出第三个 Bean 承载公共逻辑 ← 根治,面试首选答案
2. 改成 setter/字段注入 ← 能跑但掩盖了设计问题
3. @Lazy 注入延迟代理 ← 局部缓解
4. ObjectProvider / ApplicationContextAware 运行期取 ← 彻底打破创建期依赖
(spring.main.allow-circular-references=true 只是放开校验,不是修复)故障三:单例里塞请求级状态
java
// ❌ 错误:成员变量在多线程下互相覆盖
@Service
public class BadContextHolder {
private String currentUserId; // 所有请求共享这一个字段
public void set(String id) { this.currentUserId = id; }
public String get() { return currentUserId; }
}
// ✅ 方案 A:ThreadLocal,注意 finally 一定要 remove
@Service
public class GoodContextHolder {
private static final ThreadLocal<String> HOLDER = new ThreadLocal<>();
public void set(String id) { HOLDER.set(id); }
public String get() { return HOLDER.get(); }
public void clear() { HOLDER.remove(); } // 不 remove → 线程池下串号 + 内存泄漏
}
// ✅ 方案 B:request 作用域 + 作用域代理
@Component
@Scope(value = WebApplicationContext.SCOPE_REQUEST,
proxyMode = ScopedProxyMode.TARGET_CLASS) // ⚠️ 少了它注入到单例里会报错
public class RequestContext {
private String userId;
public void setUserId(String userId) { this.userId = userId; }
public String getUserId() { return userId; }
}🔍 边界 Case 自测清单
| # | Case | 期望行为 |
|---|---|---|
| 1 | createBatchBad 中途抛异常 | 已插入的数据不会回滚,复现失效 |
| 2 | createBatchGoodA/B/C 中途抛异常 | 该条回滚,前面成功的保留(REQUIRES_NEW 语义) |
| 3 | 在方法里打印 isActualTransactionActive() | 自调用场景为 false,走代理为 true |
| 4 | AopContext.currentProxy() 未开 exposeProxy | 抛 IllegalStateException,提示没有暴露代理 |
| 5 | @Autowired private OrderService self 不加 @Lazy | Boot 2.6+ 默认报循环依赖错误 |
| 6 | 构造器循环依赖 | 启动失败,错误信息里能看到完整依赖环 |
| 7 | 构造器 + @Lazy | 启动成功,首次调用方法时才真正初始化对方 |
| 8 | 字段循环依赖 + 其中一个加 @Async | 复现"拿到未增强对象"或启动报错 |
| 9 | ThreadLocal 不 remove + 线程池复用 | 第二个请求读到上一个请求的 userId |
| 10 | request 作用域 Bean 去掉 proxyMode | 启动即报 No thread-bound request found |
| 11 | 在 @Async 线程里读 ThreadLocal | 读不到(需 TaskDecorator 传递上下文) |
| 12 | 事务方法抛受检异常且未配 rollbackFor | 不回滚,复现默认只回滚 RuntimeException |
🚀 变种拓展题
- 写一个启动期自检组件:实现
SmartInitializingSingleton,遍历所有 Bean,检查"@Transactional方法是否被同类其它方法直接调用",发现即启动失败(可用 ASM 分析字节码)。 - 实现
TaskDecorator,把ThreadLocal/MDC 的 traceId 透传到@Async线程池,并处理"任务执行完要清理"。 - 用
@TransactionalEventListener把"事务提交后发 MQ"落地,复现并修复"事务未提交就发消息导致消费者查不到数据"。 - 把三个故障各写成一个失败的单元测试,再逐个修复成绿色,形成自己的回归用例集。
- 打开
spring.main.allow-circular-references=true跑用例,解释为什么"能启动"不等于"没问题"(启动顺序敏感、代理对象可能不一致)。
⚠️ 当日踩坑实录
| ❌ 坑 | ✅ 解决方案 |
|---|---|
@Transactional 方法被同类其它方法 this.xxx() 调用,事务完全失效,日志里看不到任何提示 | 本质是没走代理。三种修复:① 把事务方法挪到另一个 Bean(推荐);② @Autowired @Lazy 注入自己;③ @EnableAspectJAutoProxy(exposeProxy = true) + AopContext.currentProxy()。验证手段用 TransactionSynchronizationManager.isActualTransactionActive(),不要靠肉眼 |
事务方法抛了受检异常(如 IOException),数据却没回滚 | 默认只对 RuntimeException 和 Error 回滚。统一加 @Transactional(rollbackFor = Exception.class),并写进团队规范/代码检查 |
事务方法里 try-catch 吞掉异常,以为"catch 了就回滚" | 异常被吞,代理感知不到失败 → 照常提交。要么原样抛出,要么在 catch 里手动 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() |
@Transactional 加在 private / final / static 方法上,没有任何报错也没有效果 | CGLIB 靠生成子类覆写方法,这三类方法无法被覆写。改成 public 非 final 实例方法 |
把 @Transactional 加在 @Async 方法或新建线程里的逻辑上,事务莫名失效 | 事务上下文绑定在 ThreadLocal 上,换线程即丢失。异步任务内部要自己开事务,且外层事务不能指望它一起回滚 |
Spring Boot 2.6 升级后启动直接失败:Circular references are not allowed | 2.6 起默认禁止循环依赖。正确做法是重构解环(抽第三个 Bean / 改成运行期 ObjectProvider 获取);spring.main.allow-circular-references=true 只是临时放开校验,不是修复 |
构造器注入出现循环依赖,报 BeanCurrentlyInCreationException,以为是 Spring 的 bug | 构造器注入发生在实例化阶段,连"半成品"都造不出来,三级缓存无从下手。这其实是优点——把设计问题暴露在启动期。临时可用 @Lazy,根治靠重构 |
在构造器里使用 @Autowired 字段,必 NPE | 字段注入发生在实例化之后。依赖在构造期就要用 → 改构造器注入;只是初始化逻辑 → 挪到 @PostConstruct |
| 循环依赖 + AOP 场景下,B 持有的 A 竟然是没有被切面增强的原始对象 | 说明提前曝光时没有走 getEarlyBeanReference。排查是否自定义了 BPP 却没实现 SmartInstantiationAwareBeanPostProcessor;用 b.getA() == context.getBean("a") 断言同一性 |
以为 @Order 能控制 Bean 的创建顺序,加上之后依然报依赖未就绪 | @Order 只影响集合注入的排序和切面/处理器的执行顺序,与实例化顺序无关。要控创建顺序用 @DependsOn,或直接声明依赖关系 |
给配置类加 proxyBeanMethods = false 后,线上出现两个 DataSource,连接池翻倍 | Lite 模式下 @Bean 方法互调就是普通 Java 调用,每次 new 一个新对象。规范:@Bean 之间一律通过方法参数声明依赖,不直接互相调用 |
| 单例 Service 里用成员变量存当前用户/当前请求参数,压测时数据串号 | 单例的实例变量被所有线程共享。改用 ThreadLocal,或 @Scope(value = "request", proxyMode = TARGET_CLASS) |
ThreadLocal 用完没 remove(),线程池复用后读到上一个请求的数据,还伴随内存泄漏 | finally 里必须 remove()。Web 层统一用拦截器的 afterCompletion 兜底清理 |
@Scope("request") 的 Bean 注入到单例里,启动就报 No thread-bound request found | 必须加 proxyMode = ScopedProxyMode.TARGET_CLASS,注入的是作用域代理,每次方法调用时才去当前请求找真实对象 |
prototype Bean 的 @PreDestroy / destroy-method 从来不执行 | 容器不管理 prototype 的销毁,交出去就不再持有引用。需要清理资源的自己 try-finally,或注册 DisposableBeanAdapter |
把 prototype Bean 用 @Autowired 注入单例,结果每次拿到的都是同一个实例 | 注入只发生一次。改用 ObjectProvider<T> / @Lookup / proxyMode 三种方式之一,在每次使用时向容器申请新实例 |
在 BeanPostProcessor 里 @Autowired 了一个业务 Bean,结果那个 Bean 的事务和切面全部失效 | 启动日志里会有 is not eligible for getting processed by all BeanPostProcessors。BPP 里改成实现 BeanFactoryAware,用的时候再 getBean,不要在字段上注入 |
自定义 BeanPostProcessor 的 postProcessAfterInitialization 忘记 return bean,返回了 null,容器里 Bean 全空 | 两个回调都必须返回对象;不做处理时原样返回入参 |
自定义 FactoryBean 的 getObjectType() 返回 null,按类型注入失败或 Bean 被提前实例化 | 必须返回真实产品类型,容器靠它做类型匹配而不实例化。想拿工厂本身用 &beanName |
在 BeanFactoryPostProcessor 里调 getBean(),启动日志出现一堆 "not eligible" 警告 | BFPP 阶段只应该改 BeanDefinition,不要触发实例化。确实要拿实例请挪到 ApplicationRunner / SmartInitializingSingleton |
@PostConstruct 里调用其它 Bean 的方法报错或拿到半成品 | @PostConstruct 时自己虽已注入完成,但依赖方可能仍在初始化中。需要"全部 Bean 就绪"才执行的逻辑,用 ApplicationRunner 或 ApplicationReadyEvent |
@Async 方法被同类调用不生效 | 与事务自调用完全同源——都依赖代理。修复方式一致:挪到另一个 Bean |
@Async 没配线程池,并发一上来就创建海量线程 | 默认是 SimpleAsyncTaskExecutor,每次新建线程不复用。必须自定义 ThreadPoolTaskExecutor 并实现 AsyncConfigurer |
@Async 返回 void 时异常被静默吞掉,线上问题完全无感 | 实现 AsyncUncaughtExceptionHandler 统一兜底,或把返回值改成 CompletableFuture 让调用方感知 |
| 分布式锁/幂等切面加在事务切面内层,并发依然穿透 | 锁的释放早于事务提交,形成"锁已放、数据未可见"的窗口。给锁切面设更小的 @Order(事务默认是最低优先级即最内层) |
ApplicationEvent 发布后以为是异步的,结果接口响应变慢 | Spring 事件默认同步,在同一线程同一事务内执行。要异步请配 SimpleApplicationEventMulticaster 的 taskExecutor 或给监听器加 @Async |
| 事务里发布事件通知下游,下游查不到数据 | 监听器在事务提交前执行。改用 @TransactionalEventListener(phase = AFTER_COMMIT) |
一个接口两个实现,@Autowired 启动报 NoUniqueBeanDefinitionException | 五种手段任选:@Primary、@Qualifier、字段名与 beanName 一致、注入 Map<String, T> 做策略路由、@Order + List<T> |
@ConditionalOnMissingBean 加在普通 @Component 上,行为随机 | 它判断的是"评估那一刻容器里有没有",强依赖注册顺序。只应该用在自动配置类里,并配合 @AutoConfigureAfter |
Spring MVC 下 @Service 在 Controller 里注入不到,或事务不生效 | 父子容器问题:子容器(DispatcherServlet)能看父容器,反之不行。确保扫描范围不重叠——父容器扫 Service/Dao,子容器只扫 Controller |
升级 Spring Boot 3 后大量编译错误:找不到 javax.servlet | Jakarta EE 命名空间迁移,javax.* 全部变成 jakarta.*。同时注意 JDK 17 起步、三方库需同步升版本 |
| 打成 GraalVM 原生镜像后,运行期动态注册 BeanDefinition / 反射造类的代码全部失效 | AOT 把 BeanDefinition 固化到编译期,运行期不再扫描。把动态逻辑改成 @Conditional 条件装配,或补 reflect-config.json 提示文件 |
@Value 注入 static 字段,值永远是 null / 0 | 依赖注入只作用于实例字段,static 会被直接跳过且不报错,最隐蔽的一类事故。改成实例字段,或在 @PostConstruct 里手动赋值给静态变量 |
@Value("${a.b}") 对应的配置项不存在,服务启动直接失败 | 占位符解析不到就抛 IllegalArgumentException。所有 @Value 都应写默认值:${a.b:3000};要注入 null 写 ${a.b:#{null}} |
用 @Value 注入 List<String>,拿到的是一个带逗号的整串 | @Value 不会自动切割。写成 #{'${a.hosts}'.split(',')}(单引号不能省),或直接改用 @ConfigurationProperties |
yml 里写 db-url,@Value("${dbUrl}") 取不到 | @Value 不支持松散绑定,名字必须一字不差。松散绑定是 @ConfigurationProperties 的能力 |
@ConfigurationProperties 类加了注解但字段全是默认值 | 忘了注册。三种方式之一:类上 @Component、配置类 @EnableConfigurationProperties(X.class)、启动类 @ConfigurationPropertiesScan |
在 @ConfigurationProperties 类的构造器里读配置,拿到的是 null | 绑定由 ConfigurationPropertiesBindingPostProcessor 在初始化前完成,比构造器晚。要在构造器里用就改成构造器绑定(record / @ConstructorBinding) |
配置中心改了值,@Value 字段却不变 | 注入后就是普通字段,原生不支持热更新。需要 @RefreshScope(作用域代理 + 销毁重建)或监听配置事件手动 set |
@PropertySource("classpath:x.yml") 读不到任何值 | 它默认只支持 properties 格式,yml 需自定义 PropertySourceFactory。常规配置请放 application.yml 走 Boot 的加载链 |
把 @Component 加在抽象基类上,以为所有子类都会被注册 | 类注解默认不继承(@Component 无 @Inherited),抽象类还会被扫描器排除。要批量纳入请用 ASSIGNABLE_TYPE 过滤器或在每个实现类上标注 |
@Transactional 标在接口方法上,CGLIB 代理下时而生效时而不生效 | 方法注解不继承,CGLIB 代理的是类。规范:事务注解一律标在实现类的方法上 |
| 启动类放在了子包里,Service 一个都扫不到 | 不写 basePackages 时只扫启动类所在包及子包。启动类必须放在最外层包 |
@ComponentScan 图省事写到根包 com,启动变慢十几秒 | 第三方依赖的包也被逐个 ASM 扫了一遍。收窄 basePackages,或用 @Import 显式导入 |
两个不同包下的同名类都被扫到,启动报 ConflictingBeanDefinitionException | 默认 beanName 都是类名首字母小写导致冲突。显式写 @Component("xxx") 或自定义 BeanNameGenerator |
缓存了 ReflectiveMethodInvocation 对象想复用,并发下执行顺序错乱 | 它用 currentInterceptorIndex 记录推进位置,有状态、天生不可复用,每次调用必须新建 |
📌 当日复盘任务
IOC 与容器
- [ ] 用一句话说清 IOC 反转的是控制权而不是创建动作,并写出 ✅/❌ 对照代码
- [ ] 画出
BeanFactory→ApplicationContext的继承关系,列出三条核心差异 - [ ] 解释
FactoryBean与BeanFactory的区别,并说出&beanName的作用 - [ ] 说明
BeanDefinition里至少 6 个关键属性,以及它从哪三类配置来源解析而来 - [ ] 写出
@ConfigurationFull / Lite 两种模式的差异,解释proxyBeanMethods = false的风险 - [ ] 背出
refresh()的十二步骨架,并指出其中三个最关键的步骤
依赖注入
- [ ] 对比构造器 / setter / 字段注入的四个维度,说明为什么官方推荐构造器
- [ ] 解释"构造器注入解决不了循环依赖"为什么是优点
- [ ] 列出一接口多实现的五种消歧手段,并各写一段代码
- [ ] 对比
@Autowired与@Resource的来源、匹配顺序、必填性差异 - [ ] 说明
@Autowired注入List<T>/Map<String, T>的典型用途(策略模式)
AOP 与代理
- [ ] 背出七大术语与五种通知,画出单切面与多切面的执行顺序
- [ ] 写出 JDK 动态代理与 CGLIB 的代码骨架,说出各自的三个限制
- [ ] 解释 Spring AOP 与 AspectJ 的根本差异(运行期织入 vs 编译期织入)
- [ ] 列出 AOP 失效的全部场景:自调用、
private、final、static、非 Spring 管理对象 - [ ] 解释
@Order在切面上的含义,并说明锁/幂等切面为什么要在事务外层 - [ ] 手写一遍
MethodInvocation责任链,解释为什么它不能复用
Bean 生命周期
- [ ] 完整背出 12 步生命周期链路,标出每一步可插入的扩展点
- [ ] 对比 BFPP 与 BPP 的执行阶段、能改什么、不能改什么
- [ ] 排出
@PostConstruct、InitializingBean、init-method三者的执行顺序 - [ ] 解释为什么构造器里拿不到
@Autowired字段 - [ ] 列出 Aware 家族至少 6 个接口及其用途,写一个
SpringUtil工具类 - [ ] 说明
SmartInitializingSingleton、ApplicationRunner、ApplicationReadyEvent的触发时机差异
作用域与线程安全
- [ ] 列出六种作用域,说明
singleton与prototype的管理边界差异 - [ ] 解释"单例线程不安全的根源是实例变量",写出 ✅/❌ 对照代码
- [ ] 说出 prototype 的两个经典陷阱与三种获取新实例的手段
- [ ] 实现 request 作用域 Bean,解释
proxyMode缺失会报什么错 - [ ] 写出
ThreadLocal的正确使用模板,说明不remove的两个后果
循环依赖
- [ ] 画出 A⇄B 字段注入的三级缓存完整推演流程
- [ ] 回答"为什么必须三级缓存、两级不够",并说出二级缓存的两个作用
- [ ] 列出 Spring 能解 / 不能解循环依赖的全部场景
- [ ] 说明 Spring Boot 2.6+ 默认禁止循环依赖的原因与正确应对姿势
- [ ] 写出
getEarlyBeanReference的调用链,解释代理对象如何被提前曝光
事务与事件
- [ ] 列出
@Transactional失效的七种场景,每种写出 ❌/✅ 成对代码 - [ ] 背出七种传播行为,重点说清
REQUIRES_NEW与NESTED的差异 - [ ] 说明事务回滚的默认规则与
rollbackFor的必要性 - [ ] 解释 Spring 事件默认同步的事实,写出异步化与事务后置消费两种改法
配置注入与组件扫描
- [ ] 画出
Environment的属性源顺序,说明「命令行 > 环境变量 > profile 配置 > 主配置 > 默认值」的由来 - [ ] 说清
@Value的完整注入链路:BFPP 注册解析器 →${}替换 →#{}SpEL →ConversionService转换 - [ ] 对比
${}与#{},写出#{'${a.hosts}'.split(',')}并解释单引号为什么不能省 - [ ] 列出
@Value与@ConfigurationProperties的六个维度差异,说出各自适用场景 - [ ] 写一个
@ConfigurationProperties+@Validated+ 构造器绑定的配置类,验证非法配置启动期失败 - [ ] 复现
@Value注入static字段为 null 的事故,解释为什么它不报错 - [ ] 背出
ClassPathBeanDefinitionScanner#doScan七步,重点说清「ASM 读元数据不加载类」 - [ ] 自定义一个
@BizService组合注解(@Service+@Transactional+@Validated),验证其生效 - [ ] 解释类注解为什么默认不继承,以及批量纳入某接口全部实现的两种做法
设计模式
- [ ] 把 11 个设计模式各指到一个 Spring 具体类,并说出一句关键细节
- [ ] 用「模板方法定骨架 → 工厂产 Bean → 注册表存单例 → 装饰扩展 → 代理增强 → 责任链串联 → 适配器统一」一句话串起整个容器
动手任务
- [ ] 手写迷你 IOC 容器,跑通字段循环依赖并断言代理对象同一性
- [ ] 手写 JDK + CGLIB 两种代理,并实现拦截器责任链
- [ ] 写一个幂等切面(Redis + Lua + SpEL key),排好与事务的
@Order - [ ] 写一个 BPP 统计所有 Bean 的初始化耗时,输出启动慢 Top 10
- [ ] 把"自调用事务失效""构造器循环依赖""单例状态污染"各写成一个失败用例并修复
- [ ] 跑一遍把二级缓存删掉的实验,用现象反向证明它的必要性
面试表达
- [ ] 10 道高频题的 90 秒口述答案各复述一遍,录音回听卡顿处
- [ ] 每道题准备一段真实项目关联话术,确保"原理 + 踩坑 + 修复 + 规范"四段式完整
- [ ] 准备 Spring 6 / Boot 3(AOT、
jakarta迁移)的加分表述,控制在 30 秒内