Spring生命周期面试,我是这样回答的

70 阅读11分钟

关键词: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注解?
  • @ComponentScanexcludeFilters排除了吗?

第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


什么情况下这么答会加分

如果你能:

  1. 画出流程图 - 让面试官看到你脑子里有画面(3关流程)
  2. 用大白话解释 - 不背英文名词,说"注册后置处理器"、"工厂后置处理器"
  3. 举出真实场景 - "我在多租户项目里遇到过..."
  4. 说出排查思路 - "我会先用BeanDefinitionRegistryPostProcessor打印所有Bean名称,看看是不是真的没注册"

面试官会觉得:这人不是背的,是真的懂。


第二问:扩展点优先级(原理层)

面试官的问题

项目中同时存在ApplicationContextInitializerEnvironmentPostProcessor修改同一个配置项(如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加载配置就像叠衣服

  1. 环境后置处理器先放一件(底层)
  2. 上下文初始化器再放一件(上层)
  3. 穿的时候,上面的衣服遮住下面的

但是,如果环境后置处理器用了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数据库。


什么情况下这么答会加分

如果你能:

  1. 画出时间线 - 让面试官看到执行顺序
  2. 用生活类比 - "像叠衣服一样",不说"PropertySource优先级"
  3. 举出坑 - "我在配置中心迁移时踩过这个坑..."
  4. 说出解法 - "排查时要看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的隔离方案会有什么性能问题?

  1. Bean缓存膨胀 - 每个租户的Bean都要缓存,1000个租户 × 100个Bean = 10万个Bean实例
  2. 内存占用高 - 每个Bean实例占内存,还有对应的BeanDefinition元数据
  3. 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

用大白话说

多租户隔离要三层防护

  1. 第一层:定义隔离 - 注册Bean的时候就分开

    • 租户A的Bean叫dataSource_tenant_a
    • 租户B的Bean叫dataSource_tenant_b
  2. 第二层:属性隔离 - 注入属性的时候看租户ID

    • 租户A的请求,注入tenant-a的配置
    • 租户B的请求,注入tenant-b的配置
  3. 第三层:对象隔离 - 创建对象的时候走工厂

    • 工厂根据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已经注入了,切不了
    }
}

结果

  • 启动时随机注入了某个租户的数据源
  • 运行时切换租户,数据源还是那个
  • 所有租户共用一个数据库

什么情况下这么答会加分

如果你能:

  1. 画出隔离层次 - 让面试官看到你考虑了全流程
  2. 用类比解释 - "像酒店分房间",不说"Bean Scope隔离"
  3. 对应真实项目 - "我在多租户SaaS系统里用过这4层防护"
  4. 说出常见坑 - "只做运行时隔离是不够的,初始化阶段就要隔离"

面试官会觉得:这人不仅懂原理,还踩过坑,有实战经验。


第四问:源码追踪(底层层)

面试官的问题

BeanFactoryPostProcessorBeanPostProcessor的执行入口分别在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实例,在实例化过程中调用。

追问杀招

为什么BeanPostProcessorpostProcessBeforeInitialization方法返回的对象可能与入参对象不同?

:因为这里可能生成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:逐个处理,灵活,但可能重复判断

什么情况下这么答会加分

如果你能:

  1. 画出时间线 - 让面试官看到两者的执行时机差异
  2. 用类比解释 - "一个改户口本,一个是整容医生"
  3. 说出源码入口 - "BeanFactoryPostProcessor在refresh()的invokeBeanFactoryPostProcessors()调用"
  4. 举出实际应用 - "我在做统一日志切面时,用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个条件同时满足

  1. JVM正常关闭 - kill -15,不是kill -9
  2. Spring容器关闭 - 调用了context.close()
  3. 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)

什么情况下这么答会加分

如果你能:

  1. 画出销毁流程 - 让面试官看到哪些环节可能出问题
  2. 用类比解释 - "像下班关电脑,正常关机 vs 拔电源"
  3. 列举3个坑 - "kill -9、prototype、容器没关"
  4. 给出三层保险 - "不能只依赖@PreDestroy,要有兜底方案"

面试官会觉得:这人考虑周全,有生产环境的经验。


面试技巧总结

看完这5道连环问,你应该发现一个规律:

面试官的追问套路

现象(报错了)
  ↓
原理(为什么报错)
  ↓
场景(真实项目怎么办)
  ↓
源码(底层怎么实现)
  ↓
工程(生产怎么兜底)

每一层都要能答,才算真正掌握。

如何准备

  1. 先理解现象 - 看上一篇文章,知道15个扩展点是什么
  2. 再看源码 - 不要求全部看懂,但要知道入口在哪
  3. 多做实验 - 自己写个Demo,打断点看调用栈
  4. 收集案例 - 遇到问题记下来,下次面试能举例

回答模板

遇到生命周期的问题,可以用这个模板:

第一步:定位阶段
"这个问题出在[准备/实例化/初始化/启动/关闭]阶段"

第二步:列举扩展点
"这个阶段有[xxx][yyy][zzz]三个扩展点可能影响"

第三步:举例说明
"比如在多租户场景中,我遇到过..."

第四步:看源码验证
"我看了AbstractApplicationContext的refresh()方法,发现..."

第五步:给兜底方案
"生产环境可以通过[监听事件/注册Hook]来兜底"

最后

这5道题,能全部答好的,至少P6+

如果你能:

  • 说出源码入口
  • 举出真实案例
  • 给出兜底方案

那就是P7的水平了。

当然,面试不是背题,关键是理解原理 + 实战经验

上一篇文章的多租户项目,建议你真的clone下来跑一遍,对着日志看执行顺序。

只有自己踩过坑,面试才能答得从容。


🎁 彩蛋:15个扩展点速查表

面试前10分钟过一遍这个表,基本就稳了:

扩展点执行阶段面试高频问题典型应用场景
ApplicationContextInitializer容器刷新前如何在启动前加载外部配置?从Nacos加载配置
EnvironmentPostProcessor环境准备如何修改Environment配置?动态添加PropertySource
BeanDefinitionRegistryPostProcessorBean定义加载后如何动态注册Bean?MyBatis的Mapper扫描
BeanFactoryPostProcessorBean实例化前如何修改Bean元数据?属性占位符解析、密码解密
InstantiationAwareBeanPostProcessorBean实例化过程@Autowired的原理是什么?依赖注入、属性填充
SmartInstantiationAwareBeanPostProcessorBean实例化循环依赖怎么解决?三级缓存、提前暴露Bean
MergedBeanDefinitionPostProcessorBean定义合并如何处理@Autowired元数据?收集注入点信息
FactoryBean创建复杂对象FactoryBean和BeanFactory区别?数据源、SqlSessionFactory
BeanPostProcessorBean初始化前后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种场景。