关键词:Spring生命周期、面试题、扩展点、故障排查
适合人群:准备面试的Java开发、想提升Spring功底的工程师
开篇先说
上一篇写了Spring生命周期的15个扩展点,有朋友私信我:"面试怎么问?怎么答?"
今天就来聊聊真实面试中的连环追问。
这不是那种"Bean生命周期有几个阶段"的背诵题,而是从一个生产故障开始,层层深挖,直到你答不上来。
看完这篇,你能做到:
- 知道面试官的追问套路
- 每个问题都能答3层(现象→原理→源码)
- 遇到故障知道往哪个扩展点去查
💡 提示:这5道题环环相扣,建议按顺序看。每道题后面都有"追问杀招",那是面试官用来区分P6和P7的。
🎁 彩蛋:文章末尾有15个扩展点速查表,面试前10分钟过一遍就稳了!
第一问:启动故障排查(现象层)
面试官的问题
项目启动时报
NoSuchBeanDefinitionException,但明明加了@Component注解。可能是哪些生命周期环节出了问题?至少说3个原因。
大部分人的回答
"包扫描路径配置错了,@ComponentScan的basePackages没包含这个类。"
面试官心里OS:这谁不知道啊,下一个...
先画个图,理清流程
Bean注册要经过3关,任何一关出问题都会报错:
graph LR
A[应用启动] --> B[扫描Component注解]
B --> C[生成BeanDefinition]
C --> D[扩展点1:注册后置处理器]
D -->|可能误删| E[Bean注册失败]
D -->|正常| F[扩展点2:工厂后置处理器]
F -->|改成延迟加载| E
F -->|正常| G[Bean注册成功]
style D fill:#ffe6f0
style F fill:#fff9e6
style E fill:#ffe6e6
style G fill:#e6ffe6
用大白话说
Bean要注册到Spring容器,要经过3关:
第1关:扫描关
- 包路径对不对?
- 有没有
@Component注解? - 被
@ComponentScan的excludeFilters排除了吗?
第2关:注册关
BeanDefinitionRegistryPostProcessor(注册后置处理器)会不会把它删了?- 多租户场景中,按租户过滤Bean时逻辑写错了吗?
第3关:配置关
BeanFactoryPostProcessor(工厂后置处理器)会不会改它的属性?- 比如改成延迟加载
lazyInit=true,但依赖方是非延迟的?
任何一关出问题,都会报NoSuchBeanDefinitionException。
你应该怎么答(带代码)
这个问题其实在考察对Bean注册阶段的理解。除了包扫描,还有很多扩展点会影响Bean的注册:
原因1:BeanDefinitionRegistryPostProcessor误删了Bean定义
// 多租户场景中,按租户过滤Bean的逻辑写错了
@Component
public class TenantBeanFilter implements BeanDefinitionRegistryPostProcessor {
@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) {
String tenantId = getCurrentTenantId();
// 逻辑写反了!把当前租户的Bean都删了
registry.getBeanDefinitionNames()
.filter(name -> name.contains(tenantId))
.forEach(registry::removeBeanDefinition); // Bug在这
}
}
原因2:BeanFactoryPostProcessor改了lazyInit属性
// 把某些Bean改成延迟加载,但依赖方是非延迟加载
@Component
public class LazyInitProcessor implements BeanFactoryPostProcessor {
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory factory) {
BeanDefinition bd = factory.getBeanDefinition("userService");
bd.setLazyInit(true); // 改成延迟加载
// 但启动时有个@Autowired UserService的类,直接报错
}
}
原因3:@Conditional条件不满足
@Component
@ConditionalOnMissingBean(DataSource.class) // 条件反了
public class UserService {
// 当有DataSource时,这个Bean不会注册
}
追问杀招
如何通过
BeanDefinitionRegistryPostProcessor打印所有注册的Bean名称?
@Component
public class BeanNamePrinter implements BeanDefinitionRegistryPostProcessor {
@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) {
String[] names = registry.getBeanDefinitionNames();
System.out.println("已注册的Bean: " + Arrays.toString(names));
// 还可以打印每个Bean的详细信息
for (String name : names) {
BeanDefinition bd = registry.getBeanDefinition(name);
System.out.println(name + " -> " + bd.getBeanClassName());
}
}
}
为什么这么问:面试官想知道你能不能实际操作BeanDefinitionRegistry,不是只会背概念。
对应上一篇的场景
还记得上一篇文章里,我们用TenantBeanRegistrar动态注册每个租户的数据源Bean吗?
// 上一篇的代码
@Component
public class TenantBeanRegistrar implements BeanDefinitionRegistryPostProcessor {
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) {
// 为tenant-a、tenant-b分别注册数据源
for (String tenant : tenants) {
registry.registerBeanDefinition("dataSource_" + tenant, ...);
}
}
}
如果这里的逻辑写错了,比如:
- 循环变量写错了 → 某些租户的Bean没注册
- Bean名称冲突了 → 后注册的覆盖了先注册的
- 过滤条件写反了 → 把需要的Bean删了
启动就会报错:No qualifying bean of type 'DataSource' available
什么情况下这么答会加分
如果你能:
- 画出流程图 - 让面试官看到你脑子里有画面(3关流程)
- 用大白话解释 - 不背英文名词,说"注册后置处理器"、"工厂后置处理器"
- 举出真实场景 - "我在多租户项目里遇到过..."
- 说出排查思路 - "我会先用
BeanDefinitionRegistryPostProcessor打印所有Bean名称,看看是不是真的没注册"
面试官会觉得:这人不是背的,是真的懂。
第二问:扩展点优先级(原理层)
面试官的问题
项目中同时存在
ApplicationContextInitializer和EnvironmentPostProcessor修改同一个配置项(如spring.datasource.url),谁会最终生效?为什么?
大部分人的回答
"嗯...后执行的会覆盖先执行的吧?"
面试官心里OS:模棱两可,继续问...
先画个图,看配置加载顺序
graph TD
A[应用启动] --> B[EnvironmentPostProcessor 环境后置处理器]
B --> C[创建Spring上下文]
C --> D[ApplicationContextInitializer 上下文初始化器]
D --> E[配置最终生效]
B -.->|addFirst 放到最前面| F[高优先级配置]
D -.->|普通添加| G[普通优先级配置]
F --> H{谁生效?}
G --> H
H -->|高优先级胜出| E
style B fill:#fff9e6
style D fill:#ffe6f0
style F fill:#e6ffe6
style G fill:#ffcccc
用大白话说
Spring加载配置就像叠衣服:
- 环境后置处理器先放一件(底层)
- 上下文初始化器再放一件(上层)
- 穿的时候,上面的衣服遮住下面的
但是,如果环境后置处理器用了addFirst(),就相当于把衣服放到最上面,上下文初始化器再怎么放也遮不住它。
简单说:
- 正常情况:后执行的覆盖先执行的
- 用
addFirst():配置优先级锁死,后面改不了
你应该怎么答(带代码)
这题在考察对配置加载顺序和PropertySource优先级的理解。
第一层:执行顺序
应用启动
↓
EnvironmentPostProcessor执行(早)
↓
上下文创建
↓
ApplicationContextInitializer执行(晚)
↓
Bean实例化
所以按执行顺序,ApplicationContextInitializer在后面,它的配置会覆盖EnvironmentPostProcessor。
第二层:PropertySource优先级
但有个反常识的情况:
public class MyEnvPostProcessor implements EnvironmentPostProcessor {
@Override
public void postProcessEnvironment(ConfigurableEnvironment env,
SpringApplication app) {
Map<String, Object> props = new HashMap<>();
props.put("spring.datasource.url", "jdbc:mysql://env-host");
PropertySource<?> ps = new MapPropertySource("myEnv", props);
// 注意这里!用addFirst()把配置放到最高优先级
env.getPropertySources().addFirst(ps);
// 后续任何扩展点都改不了这个配置了
}
}
结论:
- 如果用
addLast()添加配置,后执行的扩展点会覆盖 - 如果用
addFirst()添加配置,配置就"锁死"了,后续改不了
追问杀招
如何让
EnvironmentPostProcessor的配置无法被后续扩展点修改?
// 方法1:addFirst()提升优先级
env.getPropertySources().addFirst(propertySource);
// 方法2:用System Property(优先级最高)
System.setProperty("spring.datasource.url", "jdbc:mysql://fixed-host");
// 方法3:自定义PropertySource,重写getProperty()禁止修改
public class ImmutablePropertySource extends PropertySource<Map<String, Object>> {
@Override
public Object getProperty(String name) {
// 直接返回,不允许外部修改
return source.get(name);
}
}
对应实际工作场景
假设你在做配置中心改造,需要从Apollo迁移到Nacos:
// 老的Apollo配置(环境后置处理器)
public class ApolloConfigLoader implements EnvironmentPostProcessor {
public void postProcessEnvironment(ConfigurableEnvironment env, ...) {
Map<String, Object> apolloConfig = loadFromApollo();
env.getPropertySources().addLast(
new MapPropertySource("apollo", apolloConfig)
);
}
}
// 新的Nacos配置(上下文初始化器)
public class NacosConfigLoader implements ApplicationContextInitializer {
public void initialize(ConfigurableApplicationContext ctx) {
Map<String, Object> nacosConfig = loadFromNacos();
ctx.getEnvironment().getPropertySources().addLast(
new MapPropertySource("nacos", nacosConfig)
);
}
}
如果两个配置中心都有spring.datasource.url:
- 正常情况:Nacos生效(后执行,覆盖Apollo)
- 如果Apollo用了
addFirst():Apollo生效(优先级锁死了)
迁移时会踩坑:以为Nacos生效了,结果还是连的Apollo数据库。
什么情况下这么答会加分
如果你能:
- 画出时间线 - 让面试官看到执行顺序
- 用生活类比 - "像叠衣服一样",不说"PropertySource优先级"
- 举出坑 - "我在配置中心迁移时踩过这个坑..."
- 说出解法 - "排查时要看PropertySources的顺序:
env.getPropertySources().stream().forEach(ps -> log.info(ps.getName()))"
面试官会觉得:这人有实战经验,不是纸上谈兵。
第三问:多租户隔离(场景层)
面试官的问题
在多租户系统中,如何保证租户A的
UserService不会注入租户B的DataSource?请结合3个以上生命周期扩展点设计方案。
大部分人的回答
"用ThreadLocal存租户ID,运行时根据ID切换数据源。"
面试官心里OS:这是运行时隔离,初始化阶段怎么办?
你应该怎么答
多租户隔离要在Bean定义阶段就开始做,不能等到运行时。
方案1:Bean定义隔离(BeanDefinitionRegistryPostProcessor)
@Component
public class TenantBeanRegistrar implements BeanDefinitionRegistryPostProcessor {
@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) {
// 为每个租户注册独立的DataSource Bean
List<String> tenants = Arrays.asList("tenant-a", "tenant-b");
for (String tenant : tenants) {
BeanDefinitionBuilder builder = BeanDefinitionBuilder
.genericBeanDefinition(DataSource.class);
builder.addPropertyValue("url", "jdbc:mysql://" + tenant + "-db");
builder.addPropertyValue("username", tenant + "-user");
builder.setScope("tenant"); // 自定义Scope
registry.registerBeanDefinition(
"dataSource_" + tenant,
builder.getBeanDefinition()
);
}
}
}
方案2:属性注入隔离(BeanPostProcessor)
@Component
public class TenantPropertyInjector implements BeanPostProcessor {
@Override
public Object postProcessBeforeInitialization(Object bean, String name) {
String tenantId = TenantContext.getCurrentTenant();
// 动态替换@Value注解的值
ReflectionUtils.doWithFields(bean.getClass(), field -> {
Value annotation = field.getAnnotation(Value.class);
if (annotation != null) {
String value = annotation.value();
// ${datasource.url} 替换成租户专属值
String tenantValue = resolveTenantProperty(tenantId, value);
field.setAccessible(true);
field.set(bean, tenantValue);
}
});
return bean;
}
}
方案3:工厂Bean隔离(FactoryBean)
@Component
public class TenantDataSourceFactory implements FactoryBean<DataSource> {
@Override
public DataSource getObject() {
String tenantId = TenantContext.getCurrentTenant();
// 根据租户ID返回对应数据源
return dataSourceMap.get(tenantId);
}
@Override
public Class<?> getObjectType() {
return DataSource.class;
}
}
方案4:配置隔离(EnvironmentPostProcessor)
public class TenantConfigLoader implements EnvironmentPostProcessor {
@Override
public void postProcessEnvironment(ConfigurableEnvironment env,
SpringApplication app) {
// 为每个租户创建独立的PropertySource
Map<String, Object> tenantAProps = new HashMap<>();
tenantAProps.put("tenantA.datasource.url", "jdbc:mysql://db-a");
env.getPropertySources().addLast(
new MapPropertySource("tenantA", tenantAProps)
);
Map<String, Object> tenantBProps = new HashMap<>();
tenantBProps.put("tenantB.datasource.url", "jdbc:mysql://db-b");
env.getPropertySources().addLast(
new MapPropertySource("tenantB", tenantBProps)
);
}
}
追问杀招
当租户数量超过1000时,这种基于Scope的隔离方案会有什么性能问题?
答:
- Bean缓存膨胀 - 每个租户的Bean都要缓存,1000个租户 × 100个Bean = 10万个Bean实例
- 内存占用高 - 每个Bean实例占内存,还有对应的BeanDefinition元数据
- GC压力大 - 租户切换频繁时,大量Bean实例进出内存
优化方案:
- 改用原型Bean + 对象池 - 不缓存,用完就回收
- 用Flyweight模式 - 共享不可变部分,只隔离可变部分
- 上Redis - 把租户数据源连接信息放Redis,动态创建
第三问小结:多租户隔离到底隔什么?
画个图看多租户的Bean隔离:
graph TD
A[租户A请求] --> B{ThreadLocal 获取租户ID}
C[租户B请求] --> B
B -->|tenant-a| D[Bean定义阶段隔离]
B -->|tenant-b| E[Bean定义阶段隔离]
D --> F[注册后置处理器 注册tenant-a的Bean]
E --> G[注册后置处理器 注册tenant-b的Bean]
F --> H[属性注入阶段隔离]
G --> I[属性注入阶段隔离]
H --> J[Bean后置处理器 注入tenant-a的属性]
I --> K[Bean后置处理器 注入tenant-b的属性]
J --> L[工厂Bean隔离]
K --> M[工厂Bean隔离]
L --> N[返回tenant-a的DataSource]
M --> O[返回tenant-b的DataSource]
style D fill:#ffe6f0
style E fill:#ffe6f0
style J fill:#fff9e6
style K fill:#fff9e6
style L fill:#e6ffe6
style M fill:#e6ffe6
用大白话说:
多租户隔离要三层防护:
-
第一层:定义隔离 - 注册Bean的时候就分开
- 租户A的Bean叫
dataSource_tenant_a - 租户B的Bean叫
dataSource_tenant_b
- 租户A的Bean叫
-
第二层:属性隔离 - 注入属性的时候看租户ID
- 租户A的请求,注入
tenant-a的配置 - 租户B的请求,注入
tenant-b的配置
- 租户A的请求,注入
-
第三层:对象隔离 - 创建对象的时候走工厂
- 工厂根据ThreadLocal里的租户ID
- 返回对应租户的对象
就像酒店分房间:
- 定义隔离:提前分配好房间号(101给A,102给B)
- 属性隔离:房间里的设施按租客需求配(A要大床,B要双床)
- 对象隔离:发房卡时根据身份证给对应房间的卡
对应上一篇的多租户场景:
还记得上一篇文章里,我们用4个扩展点实现多租户隔离吗?
// 第一层:定义隔离
public class TenantBeanRegistrar implements BeanDefinitionRegistryPostProcessor {
// 为每个租户注册独立的DataSource Bean
}
// 第二层:属性隔离
public class TenantPropertyInjector implements BeanPostProcessor {
// 根据租户ID注入不同的配置
}
// 第三层:对象隔离
public class TenantDataSourceFactory implements FactoryBean<DataSource> {
// 根据ThreadLocal返回对应租户的数据源
}
// 第零层:配置隔离(最早)
public class TenantConfigLoader implements EnvironmentPostProcessor {
// 为每个租户加载独立的配置
}
实际工作中的坑:
很多人只做了第三层(运行时切换),没做前两层(初始化隔离):
// 只做了运行时切换(错误示范)
@Component
public class UserService {
@Autowired
private DataSource dataSource; // 启动时注入哪个租户的?
public void query() {
String tenantId = TenantContext.get();
// 想运行时切换,但dataSource已经注入了,切不了
}
}
结果:
- 启动时随机注入了某个租户的数据源
- 运行时切换租户,数据源还是那个
- 所有租户共用一个数据库
什么情况下这么答会加分:
如果你能:
- 画出隔离层次 - 让面试官看到你考虑了全流程
- 用类比解释 - "像酒店分房间",不说"Bean Scope隔离"
- 对应真实项目 - "我在多租户SaaS系统里用过这4层防护"
- 说出常见坑 - "只做运行时隔离是不够的,初始化阶段就要隔离"
面试官会觉得:这人不仅懂原理,还踩过坑,有实战经验。
第四问:源码追踪(底层层)
面试官的问题
BeanFactoryPostProcessor和BeanPostProcessor的执行入口分别在AbstractApplicationContext的哪个方法?两者的调用栈有何本质区别?
大部分人的回答
"一个处理Bean定义,一个处理Bean实例。"
面试官心里OS:这是结果,问的是过程...
你应该怎么答
这题考察是否看过Spring源码。
执行入口
public abstract class AbstractApplicationContext {
@Override
public void refresh() throws BeansException {
// ... 省略其他代码
// 1. BeanFactoryPostProcessor入口
invokeBeanFactoryPostProcessors(beanFactory); // ← 在这
// 2. BeanPostProcessor注册入口
registerBeanPostProcessors(beanFactory); // ← 在这
// 3. 实例化所有单例Bean
finishBeanFactoryInitialization(beanFactory);
// ... 省略其他代码
}
}
调用栈区别
BeanFactoryPostProcessor的调用栈:
refresh()
→ invokeBeanFactoryPostProcessors()
→ PostProcessorRegistrationDelegate.invokeBeanFactoryPostProcessors()
→ processor.postProcessBeanFactory(beanFactory)
特点:直接操作ConfigurableListableBeanFactory,影响所有Bean定义。
BeanPostProcessor的调用栈:
refresh()
→ registerBeanPostProcessors() // 先注册
→ finishBeanFactoryInitialization() // 实例化Bean时才调用
→ beanFactory.preInstantiateSingletons()
→ getBean()
→ doGetBean()
→ createBean()
→ initializeBean()
→ applyBeanPostProcessorsBeforeInitialization() // ← 在这
→ invokeInitMethods()
→ applyBeanPostProcessorsAfterInitialization() // ← 在这
特点:针对单个Bean实例,在实例化过程中调用。
追问杀招
为什么
BeanPostProcessor的postProcessBeforeInitialization方法返回的对象可能与入参对象不同?
答:因为这里可能生成AOP代理!
public class AopProxyCreator implements BeanPostProcessor {
@Override
public Object postProcessAfterInitialization(Object bean, String name) {
// 如果Bean有@Transactional、@Async等注解
// 这里会返回一个代理对象,不是原始Bean
if (needsProxy(bean)) {
return createProxy(bean); // 返回代理对象
}
return bean; // 返回原始Bean
}
}
这就是为什么:
this.methodB()调用时,@Transactional不生效(this是原始对象)beanA.methodB()调用时,@Transactional生效(beanA是代理对象)
第四问小结:两个后置处理器有什么区别?
画个图看它们在Spring启动流程中的位置:
graph TD
A[应用启动] --> B[refresh方法开始]
B --> C[invokeBeanFactoryPostProcessors 调用工厂后置处理器]
C --> D{修改Bean定义}
D --> E[所有BeanDefinition都可能被修改]
B --> F[registerBeanPostProcessors 注册Bean后置处理器]
F --> G[只是注册,还没调用]
E --> H[finishBeanFactoryInitialization 实例化所有单例Bean]
G --> H
H --> I[getBean循环]
I --> J[createBean创建单个Bean]
J --> K{调用Bean后置处理器}
K --> L[postProcessBeforeInitialization 初始化前]
L --> M[invokeInitMethods 调用初始化方法]
M --> N[postProcessAfterInitialization 初始化后,生成代理]
style C fill:#fff9e6
style D fill:#fff9e6
style F fill:#ffe6f0
style K fill:#ffe6f0
style N fill:#e6ffe6
用大白话说:
工厂后置处理器(BeanFactoryPostProcessor):
- 什么时候干活:容器刷新时,Bean还没创建
- 能干什么:修改Bean的"出生证明"(BeanDefinition)
- 影响范围:一次修改,影响所有这个类型的Bean
- 举例:把所有带
@Transactional的Bean改成需要代理
Bean后置处理器(BeanPostProcessor):
- 什么时候干活:每个Bean创建时
- 能干什么:修改Bean的"本人"(实例对象)
- 影响范围:一次只处理一个Bean
- 举例:给带
@Transactional的Bean生成代理对象
用生活类比:
工厂后置处理器 = 改户口本
- 孩子还没出生
- 去派出所改户口本的规定:"凡是姓张的,都要加个中间名"
- 以后所有姓张的孩子出生,户口本上都有中间名
Bean后置处理器 = 整容医生
- 孩子已经出生了
- 每个孩子来做整容,医生看情况处理
- 可能返回原样,可能返回整容后的样子
对应实际工作场景:
假设你要给所有Service加上性能监控:
方法1:用工厂后置处理器
public class PerformanceMonitorBeanFactory implements BeanFactoryPostProcessor {
public void postProcessBeanFactory(ConfigurableListableBeanFactory factory) {
// 给所有Service的BeanDefinition加上需要代理的标记
String[] beanNames = factory.getBeanNamesForAnnotation(Service.class);
for (String name : beanNames) {
BeanDefinition bd = factory.getBeanDefinition(name);
bd.setAttribute("needsPerformanceMonitor", true);
}
}
}
方法2:用Bean后置处理器
public class PerformanceMonitorBeanPost implements BeanPostProcessor {
public Object postProcessAfterInitialization(Object bean, String name) {
// 每个Bean创建后,判断是否需要代理
if (bean.getClass().isAnnotationPresent(Service.class)) {
return createProxy(bean); // 返回代理对象
}
return bean;
}
}
区别在哪:
- 方法1:提前标记,统一处理,但不灵活
- 方法2:逐个处理,灵活,但可能重复判断
什么情况下这么答会加分:
如果你能:
- 画出时间线 - 让面试官看到两者的执行时机差异
- 用类比解释 - "一个改户口本,一个是整容医生"
- 说出源码入口 - "BeanFactoryPostProcessor在refresh()的invokeBeanFactoryPostProcessors()调用"
- 举出实际应用 - "我在做统一日志切面时,用BeanPostProcessor生成代理"
面试官会觉得:这人看过源码,理解底层原理。
第五问:故障恢复(工程层)
面试官的问题
生产环境中,某个
@PreDestroy标注的资源释放方法未执行,导致连接泄漏。可能的原因有哪些?如何通过扩展点实现"强制资源回收"兜底方案?
大部分人的回答
"可能是应用被kill -9强制终止了。"
面试官心里OS:然后呢?怎么兜底?
你应该怎么答
未执行的3个原因
原因1:应用被kill -9强制终止
# kill -9 不会触发JVM的ShutdownHook
kill -9 <pid>
# 要用kill -15才会优雅关闭
kill -15 <pid>
原因2:Spring上下文未调用close()
// 如果是手动创建的上下文,忘记关闭
ApplicationContext context = new AnnotationConfigApplicationContext();
// 用完没有调用 context.close()
原因3:Bean是prototype作用域
@Component
@Scope("prototype") // 原型Bean不会自动执行@PreDestroy
public class TempService {
@PreDestroy
public void cleanup() {
// 这个方法永远不会被调用
}
}
兜底方案
方案1:监听ContextClosedEvent
@Component
public class ResourceCleaner implements ApplicationListener<ContextClosedEvent> {
@Autowired
private List<DataSource> dataSources;
@Override
public void onApplicationEvent(ContextClosedEvent event) {
// 强制关闭所有数据源
for (DataSource ds : dataSources) {
try {
if (ds instanceof HikariDataSource) {
((HikariDataSource) ds).close();
}
} catch (Exception e) {
log.error("关闭数据源失败", e);
}
}
}
}
方案2:注册JVM ShutdownHook
@Component
public class ShutdownHookRegistrar implements ApplicationListener<ContextRefreshedEvent> {
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
// 即使Spring上下文没关闭,JVM关闭时也会执行
forceCleanupResources();
}));
}
private void forceCleanupResources() {
// 强制清理连接池、线程池、文件句柄等
}
}
追问杀招
如何监控
@PreDestroy方法的执行耗时?
答:结合BeanPostProcessor和AOP
@Component
public class PreDestroyMonitor implements DestructionAwareBeanPostProcessor {
@Override
public void postProcessBeforeDestruction(Object bean, String beanName) {
Method[] methods = bean.getClass().getDeclaredMethods();
for (Method method : methods) {
if (method.isAnnotationPresent(PreDestroy.class)) {
long start = System.currentTimeMillis();
try {
method.invoke(bean);
} catch (Exception e) {
log.error("@PreDestroy执行失败: {}", beanName, e);
} finally {
long cost = System.currentTimeMillis() - start;
log.info("@PreDestroy执行耗时: {} -> {}ms", beanName, cost);
}
}
}
}
}
第五问小结:资源为什么没释放?
画个图看Bean销毁的流程:
graph TD
A[JVM收到关闭信号] --> B{信号类型?}
B -->|kill -15正常关闭| C[触发ShutdownHook]
B -->|kill -9强制终止| D[直接杀死不执行]
C --> E[Spring容器开始关闭]
E --> F[发布ContextClosedEvent事件]
F --> G{监听器处理}
E --> H[调用DisposableBean]
H --> I{Bean作用域?}
I -->|singleton单例| J[执行destroy方法]
I -->|prototype原型| K[不执行自己管理]
J --> L[PreDestroy方法执行]
L --> M[资源释放成功]
D --> N[资源泄漏]
K --> N
style C fill:#e6ffe6
style D fill:#ffe6e6
style J fill:#fff9e6
style K fill:#ffcccc
style N fill:#ffe6e6
用大白话说:
Bean销毁要3个条件同时满足:
- JVM正常关闭 -
kill -15,不是kill -9 - Spring容器关闭 - 调用了
context.close() - Bean是单例 -
@Scope("singleton"),不是prototype
只要有一个条件不满足,@PreDestroy就不会执行。
就像下班关电脑:
- 正常关机(kill -15):会保存文件、关闭程序、断电
- 直接拔电源(kill -9):什么都不做,数据可能丢
- 笔记本合盖(prototype):看起来关了,其实还在后台跑
对应上一篇的多租户场景:
还记得上一篇文章里,我们用DisposableBean关闭数据源吗?
@Component
public class TenantDataSourceManager implements DisposableBean {
private Map<String, DataSource> dataSourceMap = new ConcurrentHashMap<>();
@Override
public void destroy() {
// 关闭所有租户的数据源
for (DataSource ds : dataSourceMap.values()) {
if (ds instanceof HikariDataSource) {
((HikariDataSource) ds).close();
}
}
}
}
实际工作中的坑:
坑1:以为关了,其实没关
# 部署脚本写错了
#!/bin/bash
# 错误:用kill -9
kill -9 $(cat app.pid)
# 正确:用kill -15
kill -15 $(cat app.pid)
# 等待5秒,如果还没关,再用kill -9
sleep 5
kill -9 $(cat app.pid)
坑2:原型Bean自己管理生命周期
@Component
@Scope("prototype") // 原型Bean
public class TempConnection {
private Connection conn;
@PreDestroy
public void close() {
conn.close(); // 永远不会执行
}
}
// 正确做法:手动关闭
try (TempConnection temp = context.getBean(TempConnection.class)) {
// 使用连接
} // try-with-resources会调用close()
坑3:容器没关就退出了
public static void main(String[] args) {
ApplicationContext ctx = SpringApplication.run(App.class, args);
// 启动后立即返回,容器还在运行
// 但如果main线程退出,非守护线程会被强制终止
}
// 正确做法:注册关闭钩子
public static void main(String[] args) {
ConfigurableApplicationContext ctx = SpringApplication.run(App.class, args);
Runtime.getRuntime().addShutdownHook(new Thread(ctx::close));
}
兜底方案怎么设计:
三层保险:
// 第一层:正常的@PreDestroy
@PreDestroy
public void cleanup() {
closeAllConnections();
}
// 第二层:监听容器关闭事件
@Component
public class ShutdownListener implements ApplicationListener<ContextClosedEvent> {
public void onApplicationEvent(ContextClosedEvent event) {
closeAllConnections(); // 再关一次
}
}
// 第三层:JVM关闭钩子(最后防线)
@Component
public class ShutdownHookRegistrar implements ApplicationListener<ContextRefreshedEvent> {
public void onApplicationEvent(ContextRefreshedEvent event) {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
closeAllConnections(); // 兜底
}));
}
}
为什么要三层:
- 第一层最优雅,但依赖Spring容器正常关闭
- 第二层更可靠,容器关闭一定会触发
- 第三层是兜底,即使Spring没关,JVM关闭时也会执行(除非kill -9)
什么情况下这么答会加分:
如果你能:
- 画出销毁流程 - 让面试官看到哪些环节可能出问题
- 用类比解释 - "像下班关电脑,正常关机 vs 拔电源"
- 列举3个坑 - "kill -9、prototype、容器没关"
- 给出三层保险 - "不能只依赖@PreDestroy,要有兜底方案"
面试官会觉得:这人考虑周全,有生产环境的经验。
面试技巧总结
看完这5道连环问,你应该发现一个规律:
面试官的追问套路:
现象(报错了)
↓
原理(为什么报错)
↓
场景(真实项目怎么办)
↓
源码(底层怎么实现)
↓
工程(生产怎么兜底)
每一层都要能答,才算真正掌握。
如何准备
- 先理解现象 - 看上一篇文章,知道15个扩展点是什么
- 再看源码 - 不要求全部看懂,但要知道入口在哪
- 多做实验 - 自己写个Demo,打断点看调用栈
- 收集案例 - 遇到问题记下来,下次面试能举例
回答模板
遇到生命周期的问题,可以用这个模板:
第一步:定位阶段
"这个问题出在[准备/实例化/初始化/启动/关闭]阶段"
第二步:列举扩展点
"这个阶段有[xxx]、[yyy]、[zzz]三个扩展点可能影响"
第三步:举例说明
"比如在多租户场景中,我遇到过..."
第四步:看源码验证
"我看了AbstractApplicationContext的refresh()方法,发现..."
第五步:给兜底方案
"生产环境可以通过[监听事件/注册Hook]来兜底"
最后
这5道题,能全部答好的,至少P6+。
如果你能:
- 说出源码入口
- 举出真实案例
- 给出兜底方案
那就是P7的水平了。
当然,面试不是背题,关键是理解原理 + 实战经验。
上一篇文章的多租户项目,建议你真的clone下来跑一遍,对着日志看执行顺序。
只有自己踩过坑,面试才能答得从容。
🎁 彩蛋:15个扩展点速查表
面试前10分钟过一遍这个表,基本就稳了:
| 扩展点 | 执行阶段 | 面试高频问题 | 典型应用场景 |
|---|---|---|---|
| ApplicationContextInitializer | 容器刷新前 | 如何在启动前加载外部配置? | 从Nacos加载配置 |
| EnvironmentPostProcessor | 环境准备 | 如何修改Environment配置? | 动态添加PropertySource |
| BeanDefinitionRegistryPostProcessor | Bean定义加载后 | 如何动态注册Bean? | MyBatis的Mapper扫描 |
| BeanFactoryPostProcessor | Bean实例化前 | 如何修改Bean元数据? | 属性占位符解析、密码解密 |
| InstantiationAwareBeanPostProcessor | Bean实例化过程 | @Autowired的原理是什么? | 依赖注入、属性填充 |
| SmartInstantiationAwareBeanPostProcessor | Bean实例化 | 循环依赖怎么解决? | 三级缓存、提前暴露Bean |
| MergedBeanDefinitionPostProcessor | Bean定义合并 | 如何处理@Autowired元数据? | 收集注入点信息 |
| FactoryBean | 创建复杂对象 | FactoryBean和BeanFactory区别? | 数据源、SqlSessionFactory |
| BeanPostProcessor | Bean初始化前后 | AOP代理什么时候生成? | @Transactional、@Async |
| InitializingBean | 属性注入后 | @PostConstruct和InitializingBean顺序? | 配置校验、资源初始化 |
| Aware接口 | Bean初始化阶段 | 如何在Bean中获取ApplicationContext? | 动态获取Bean、发布事件 |
| SmartInitializingSingleton | 所有Bean完成后 | 如何在启动后做缓存预热? | 字典数据加载、建立连接 |
| ApplicationListener | 容器启动完成 | 如何监听容器启动完成事件? | 发送启动通知、记录日志 |
| ApplicationRunner/CommandLineRunner | 应用启动后 | 两者有什么区别? | 数据预加载、启动任务 |
| DisposableBean | 容器关闭前 | 如何保证资源正确释放? | 关闭数据源、清空缓存 |
使用技巧:
- 建议截图保存,面试前快速过一遍
- 面试时能快速定位"这个问题属于哪个阶段"
- 答题时按"阶段→扩展点→场景→源码"的顺序说
赠人玫瑰,手留余香。
都看到这了,如果觉得有帮助,点个赞吧 👍
你的支持是我创作的最大动力。
Gitee项目:gitee.com/sh_wangwanb…
上一篇文章:一个多租户项目轻松实战SpringBoot15个扩展
持续分享硬核干货,关注我,下一篇讲Spring事务失效的8种场景。