ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring6 Bean生命周期五步七步十步:回调顺序与代理避坑实战

Spring6 Bean生命周期五步七步十步:回调顺序与代理避坑实战

Spring6 Bean 生命周期的五步、七步、十步法,我第一次被问住是在一次代码评审上。A同学在白板上把十个步骤背得一字不差,我顺手问了两个问题:“@PostConstruct和afterPropertiesSet谁先跑?”“我们下单服务上的那个事务代理,是在哪一步被织进去的?”他卡了将近十秒,然后说“反正都在初始化里”。这件事让我意识到,很多关于 Spring6 Bean 生命周期的所谓“记忆口诀”,本质上只是把AbstractAutowireCapableBeanFactory的源码顺序抄了一遍,一旦脱离背诵场景问“为什么是这个顺序”,立刻就露馅。

我自己也走过一样的弯路。早年做订单系统的时候,我在afterPropertiesSet()里发起了一次远程库存预热调用,结果本地启动直接卡了四十多秒,查了半天以为是数据库连接池的问题;还有一次在BeanPostProcessor里用@Autowired注入了一个OrderRepository,启动时一直提示某个 Bean “not eligible for getting processed by all BeanPostProcessors”,日志刷了几十行,最后发现是后置处理器自身的注入时机和普通 Bean 完全不同。这些坑,光靠背“五步、七步、十步”是绝对躲不过去的。

所以这篇内容我不打算再给你列一遍口诀。我会用一个完整的“用户下单”场景,把 Spring6 Bean 生命周期从五步法(宏观骨架)、七步法(加入后置处理器)、十步法(完整回调链)三层拆开,每一层告诉你这一步到底在解决什么问题、日志长什么样、能在这里干什么、绝对不能在这里干什么。适合谁看:写过 Spring Boot 但对生命周期只有模糊印象的同学,面试前想彻底搞清楚顺序的开发者,以及线上遇到过“Bean 初始化顺序诡异”问题的排查者。看完你至少能自己写一个 Demo 把十步日志跑出来,而不是继续背十条文字。

1. 别急着背步骤,先搞清楚三套说法各自解决什么问题

“五步、七步、十步”听起来像是三种不同的理论,实际上它们描述的是同一个流程,区别只在于颗粒度和你站在哪一层看。不理解这一点,你会觉得它们互相矛盾:五步里没有BeanPostProcessor,七步里没有@PostConstruct,十步里又多出好几个 Aware 接口。搞清楚它们的切分逻辑,比记住顺序重要得多。

1.1 五步、七步、十步的对照与适用场合

五步法是最粗的骨架,只关心“一个对象从生到死”的五个大阶段:实例化、属性填充、初始化、使用、销毁。它的价值在于建立心智模型:Spring 管的对象和你new出来的对象,差别就在中间那三段被容器塞了私货。七步法把“初始化”这一段拆开了,塞进了BeanPostProcessor的前置和后置调用,这是 Spring 一切“增强能力”的入口,AOP、事务、异步、@Async、@Scheduled全都挂在这两刀上。十步法则进一步把 Aware 回调、三种初始化方式、三种销毁方式全部展开,是排查线上问题时的“精确坐标”。

层次步骤划分回答的问题什么时候用
五步法实例化、属性填充、初始化、使用、销毁这个对象归谁管,容器在中间做了什么建立整体认知,给新人讲清概念
七步法五步基础上,把初始化拆成 BPP 前置、初始化、BPP 后置代理对象在哪生出来的,增强逻辑挂在哪写 BeanPostProcessor、排查 AOP 失效
十步法实例化、属性填充、Aware、BPP 前置、三种初始化、BPP 后置、使用、三种销毁具体某个回调为什么没执行、执行顺序为什么反了线上排查、写生命周期相关的代码

我一般建议按这个顺序学:先用五步法把骨架记住(只需要记一句话:“先造壳,再灌料,再调初始化,然后干活,最后拆”),然后用七步法理解 Spring 的扩展点,最后用十步法去对日志。反过来先背十步,很容易背成一段没有语义的字符串,过一周就忘。

1.2 把下单链路翻译成 Bean 关系图

为了让后面所有讨论有具体的落点,我先把场景定下来。一个最简化的下单链路,包含五个参与者和一个配置类:

  • PayConfig:读支付渠道的配置参数,比如超时时间、重试次数。
  • PaymentGateway:支付网关客户端,依赖PayConfig,用 setter 注入(故意这么设计,后面解释原因)。
  • InventoryService:库存服务,负责锁定库存。
  • OrderRepository:订单持久化,依赖一个模拟的数据源。
  • OrderServiceImpl:下单核心,依赖上面三个,用构造器注入。

依赖关系是:PaymentGateway→PayConfig,OrderServiceImpl→InventoryService/PaymentGateway/OrderRepository。容器启动时会先创建PayConfig,再创建PaymentGateway,然后InventoryService、OrderRepository,最后才是OrderServiceImpl。关闭容器时顺序反过来:先拆OrderServiceImpl,再拆它依赖的东西。

这里有个容易被忽略的点:创建顺序不完全等于声明顺序,它由 BeanDefinition 的注册顺序加上依赖关系共同决定。Spring 在preInstantiateSingletons()里遍历beanDefinitionNames,遇到依赖未创建的就先去递归创建依赖。所以你在配置类里把orderService写在最前面,它依然会最后被实例化。这个特性救过我很多次,但也害过我一次:有个项目里两个 Bean 互相@Autowired字段注入,靠注册顺序“碰巧”跑通了,换了个 JDK 版本重新编译,顺序一变直接启动失败。所以别赌顺序,该用@DependsOn就用。

1.3 为什么必须先建立“容器视角”

在动手写代码之前,还有一个观念必须先转过来:Bean 生命周期里的每一个回调,本质都是容器在问“你有没有想在此时插一脚”。构造器是容器在造壳的时候被顺带调用的,@PostConstruct是容器在初始化阶段主动去找并调用的,InitializingBean是容器判断“你是不是实现了这个接口”然后强转调用的。三者看起来都是“方法被执行了”,但触发机制完全不同。

这个区别在排查问题时特别关键。比如@PostConstruct不执行,可能是注解包引错了(Spring 6 之后必须是jakarta.annotation,不是javax.annotation),也可能是这个类压根没被容器管理(比如你在@Configuration(proxyBeanMethods = false)的类里手动new了一个)。而afterPropertiesSet不执行,只可能是这个类没被容器管理,因为它是接口方法,只要进了容器就一定会被识别。看到区别了吗?同样的“没执行”,排查方向完全相反。

2. 五步法:先把最小可用的骨架立起来

五步法是整个生命周期的主干,后面所有的扩展点都是挂在这根主干上的枝杈。我把这五步拆成三小节来讲,重点不是“有哪五步”,而是每一步背后的意图:容器为什么要在这一步做这件事,如果换成你自己写框架,你会不会也这么设计。

2.1 第一步实例化:构造器被调用的那一刻到底发生了什么

实例化对应的源码位置是AbstractAutowireCapableBeanFactory#createBeanInstance,做的事情简单说就是:根据 BeanDefinition 里的信息(类名、构造器参数、工厂方法等),反射或者直接调用构造器,在堆上分配出一个对象。注意此时这个对象的字段全是默认值,Integer是null,boolean是false,依赖一个都没注入。

这里有个很多人踩过的细节:如果你用的是构造器注入,那么“实例化”和“属性填充”在日志上会合并成一步。因为构造器参数必须在调用构造器之前解析好,容器会先getBean把依赖创建出来,再反射调用构造器。所以你在构造器里打印日志,看到的顺序会是“依赖先打印,我再打印”,完全看不出两步的界限。

想亲眼看到两步分离,就得用 setter 注入或者字段注入。我在 Demo 里特意把PaymentGateway写成 setter 注入:

@Component public class PaymentGateway { private static final Logger log = LoggerFactory.getLogger(PaymentGateway.class); private PayConfig payConfig; public PaymentGateway() { log.info("[1-实例化] PaymentGateway 无参构造器执行,此时 payConfig = {}", payConfig); } @Autowired public void setPayConfig(PayConfig payConfig) { this.payConfig = payConfig; log.info("[2-属性填充] PaymentGateway.setPayConfig 注入完成"); } }

跑起来日志一定是先打印构造器那一行,而且payConfig是null,然后才打印注入那一行。这就直观地证明了“先造空壳,再灌料”。生产代码我还是推荐构造器注入,因为它能保证对象创建出来就是完整可用的,不会出现“半成品对象被人拿去用”的情况;但你要理解生命周期,就必须知道构造器注入把两步压缩了。

注意:在构造器里做业务动作是极其危险的。此时依赖还没注入完,容器也没准备好,你在这里发 MQ、写数据库,一旦后面某个依赖创建失败,容器回滚,你已经把脏数据写出去了。构造器里最多打日志、做参数校验。

2.2 第二、三步属性填充与初始化:注入和自定义装配的边界在哪

属性填充对应populateBean,核心是遍历InstantiationAwareBeanPostProcessor的postProcessProperties,把@Autowired、@Resource、@Value标注的字段和方法填进去。这一步结束之后,对象的依赖关系才算完整,@Autowired的字段不再是null。

初始化对应initializeBean,这一步才是重头戏,也是后面七步法和十步法反复展开的地方。它内部依次做四件事:调用 Aware 接口方法、执行所有BeanPostProcessor的前置处理、执行初始化方法、执行所有BeanPostProcessor的后置处理。五步法里把它笼统叫做“初始化”,实际上这一小段里藏了六到七个回调。

为什么初始化要放在属性填充之后?道理很朴素:你的初始化逻辑大概率要用到依赖。OrderServiceImpl初始化时想去预热库存,那就必须先拿到InventoryService的引用。如果先初始化再注入,你在初始化方法里拿到的全是null,这套设计就没法用了。

这里我见过一个很典型的误用:有人在@PostConstruct里给final字段赋值,编译不通过;改成非final之后又发现赋值被后续的注入覆盖了。原因是字段注入的赋值发生在populateBean,也就是初始化之前;但如果某个BeanPostProcessor在postProcessBeforeInitialization里又改了一次,你的@PostConstruct赋值就可能被覆盖。这种依赖“谁后写谁生效”的代码非常脆弱,能不在回调里改状态就别改。

2.3 第四、五步使用与销毁:容器关掉的时候谁先走

“使用”阶段没什么好说的,就是 Bean 被业务代码调用,placeOrder()被执行。真正值得讲的是销毁,因为这里的坑最多。

销毁的触发点是ApplicationContext#close(),内部走doClose()→destroyBeans()→destroySingletons()。destroySingletons遍历一个LinkedHashMap结构里登记的可销毁 Bean,倒序逐个销毁。倒序的意义在于:后创建的一般是依赖别人的,先销毁它们,被依赖的还能正常使用。同时 Spring 还会用dependentBeanMap做一次校正,确保“被依赖者一定在依赖者之后销毁”。

三层销毁回调的顺序是:@PreDestroy→DisposableBean#destroy→ 自定义destroyMethod。对应源码里DisposableBeanAdapter#destroy先调DestructionAwareBeanPostProcessor(@PreDestroy就是由它处理的),再调DisposableBean,最后反射调用destroyMethod。

这里有两个必须记住的例外:

  • prototype 作用域的 Bean,容器不管理销毁。destroySingletons只处理单例,原型 Bean 交出去之后容器就不再持有引用,销毁回调永远不会触发。如果你的原型 Bean 持有连接、文件句柄,必须由调用方自己释放。
  • 手动new出来的对象,任何回调都不会触发。这个不用说,但每年都有人困惑。

提示:ApplicationContext用 try-with-resources 包起来最省事,AnnotationConfigApplicationContext实现了Closeable,退出时自动close(),销毁日志就能完整跑出来。很多人只跑启动不跑关闭,所以永远只看到前八步。

3. 七步法:插入 BeanPostProcessor 之后一切都不一样了

如果说五步法是骨架,BeanPostProcessor就是 Spring 的“神经系统”。AOP、@Transactional、@Async、@Validated、@Scheduled、@PostConstruct(它本身也是靠CommonAnnotationBeanPostProcessor处理的)全部建立在这两个回调上。七步法的价值,就是让你知道“增强”发生在初始化的哪一刀上。

3.1 前置和后置处理器夹在哪两刀之间

七步法的划分是:实例化 → 属性填充 → BPP 前置 → 初始化方法 → BPP 后置 → 使用 → 销毁。注意它把五步法里的“初始化”拆成了三块,中间的“初始化方法”指的是afterPropertiesSet和自定义init-method,而@PostConstruct严格来说是在 BPP 前置处理里被调用的(由CommonAnnotationBeanPostProcessor在postProcessBeforeInitialization中反射执行)。

这段顺序我实测过很多次,日志是这样的:setBeanName→setApplicationContext→@PostConstruct→afterPropertiesSet→init-method→BPP 后置。注意ApplicationContextAware也在@PostConstruct之前,因为它是被ApplicationContextAwareProcessor这个 BPP 在处理前置阶段调用的,而它注册在处理器链的最前面。

为什么@PostConstruct要放在afterPropertiesSet前面?我的理解是设计上的“先约定后框架”:@PostConstruct是 JSR 标准注解,属于业务方视角的约定;InitializingBean是 Spring 自己的接口,属于框架视角。让标准先执行,业务方就有机会在框架逻辑之前做准备。实际用的时候不用纠结,反正两者都在 BPP 前置之后、BPP 后置之前。

具体顺序如下表,这张表建议直接收藏:

顺序回调触发机制能否拿到完整依赖
1构造器容器反射调用否
2setter / 字段注入populateBean部分(取决于注入顺序)
3BeanNameAware/BeanClassLoaderAware/BeanFactoryAwareinvokeAwareMethods是
4ApplicationContextAware/EnvironmentAware等ApplicationContextAwareProcessor是
5@PostConstructCommonAnnotationBeanPostProcessor是
6afterPropertiesSetInitializingBean强转调用是
7init-method反射调用是
8BeanPostProcessor#postProcessAfterInitialization处理器链是

3.2 代理对象到底在哪个回调里生成

这是七步法最核心的一个问题:AOP 代理不是在“使用”阶段动态生成的,而是在postProcessAfterInitialization里就生成好并替换掉了原对象。具体是AbstractAutoProxyCreator这个 BPP 干的活:它判断当前 Bean 是否匹配某个切点,匹配就用 CGLIB 或 JDK 动态代理生成一个新的包装对象,然后把容器里的引用替换成代理对象。

这意味着什么呢?意味着你原始对象上的@PostConstruct已经执行过了,afterPropertiesSet也执行过了,代理是最后套上去的一层壳。所以在@PostConstruct里调用本类的其他方法,走的是原始对象,不经过代理,事务、异步统统不生效——这就是著名的“同类方法调用事务失效”问题的根源。

我在 Demo 里加了一个切面,统计placeOrder的耗时。跑起来日志里[8-BPP后置]那一行打印的类名是com.demo.order.OrderServiceImpl$$SpringCGLIB$$0,说明代理已经生成;而[5-初始化] @PostConstruct那一行打印的还是原始类。这个对比特别直观,比讲十遍原理都有用。

@Aspect @Component public class OrderMetricsAspect { private static final Logger log = LoggerFactory.getLogger(OrderMetricsAspect.class); @Around("execution(* com.demo.order.OrderServiceImpl.placeOrder(..))") public Object around(ProceedingJoinPoint pjp) throws Throwable { long start = System.currentTimeMillis(); try { return pjp.proceed(); } finally { log.info("[AOP] placeOrder 耗时 {} ms", System.currentTimeMillis() - start); } } }

顺带说一句,如果你不想引 AOP 依赖,也可以自己写一个BeanPostProcessor,在postProcessAfterInitialization里判断beanName.equals("orderService")然后打印bean.getClass().getName(),同样能看到代理类名。但要注意,自己写的 BPP 如果也匹配到了自己,会造成无限递归,记得加instanceof判断。

注意:BeanPostProcessor本身的创建时机非常早,早到容器里大部分 Bean 还没初始化。所以你在 BPP 里用@Autowired注入业务 Bean,Spring 会给你一个“提前初始化”的提示(is not eligible for getting processed by all BeanPostProcessors),而且被注入的那个 Bean 后续的 BPP 后置处理可能会被跳过。稳妥做法是:要么用static字段延迟获取,要么实现ApplicationContextAware拿容器,使用时再getBean。

3.3 Aware 回调为什么不能和初始化方法混着记

Aware 系列接口有好几个,BeanNameAware、BeanClassLoaderAware、BeanFactoryAware是容器直接调用的,而ApplicationContextAware、EnvironmentAware、ResourceLoaderAware、ApplicationEventPublisherAware、MessageSourceAware是通过ApplicationContextAwareProcessor这个 BPP 间接调用的。这两批的执行位置不同:前者在invokeAwareMethods里,早于所有 BPP;后者在 BPP 前置阶段,晚于前者。

为什么不全放在一起?我的理解是历史演进的结果。早期的BeanFactory体系只支持前三个,后来ApplicationContext加进来,为了不破坏BeanFactory的兼容性,就用 BPP 的方式扩展。所以你在纯BeanFactory环境里用ApplicationContextAware,是拿不到回调的——这个坑在写通用组件、要在非 Web 环境复用时特别容易遇到。

实际写业务代码,BeanNameAware基本用不上,ApplicationContextAware偶尔用于“需要动态拿 Bean”的场景(比如根据订单类型选择不同的处理器实现)。但我要提醒一句:能通过构造器注入拿到的,就别用ApplicationContext#getBean。后者把依赖关系藏进了运行时,编译期看不出来,测试也不好写。我见过一个服务在@PostConstruct里用getBean拿自己依赖的 Bean,结果因为循环依赖直接启动失败,排查了一下午。

4. 十步法:下单链路的完整时间线与实测日志

到这一步,该把前面所有的碎片拼成一张完整的图了。十步法不是又一个新理论,它是把五步法的“初始化”和“销毁”两段全部展开,形成一条可以对着日志逐行核对的坐标轴。

4.1 补齐三连初始化:@PostConstruct、afterPropertiesSet、init-method

三种初始化方式的并存经常让人困惑:既然有@PostConstruct了,为什么还要InitializingBean?既然有InitializingBean,为什么还要init-method?答案是它们面向不同的使用场景。

@PostConstruct是 JSR 标准,写在业务类里最自然,缺点是硬编码了注解,类离开了容器就没意义。InitializingBean是 Spring 接口,缺点更明显:你的业务类被迫依赖 Spring 的 API,而且在 Spring 6 之前它的方法签名是void afterPropertiesSet() throws Exception,实现起来有点丑。init-method是最解耦的,第三方类库(比如某个连接池客户端)你没法改它的源码,但可以在@Bean注解上指定initMethod = "start",让它感知不到 Spring 的存在。

三者执行顺序是固定的:@PostConstruct→afterPropertiesSet→init-method。所以如果你把一个校验逻辑放在init-method里,又在afterPropertiesSet里依赖了它校验的结果,顺序就错了。我的经验是:一个 Bean 上只保留一种初始化方式,除非有明确的分层理由(比如框架层用InitializingBean,业务层用@PostConstruct)。

销毁同理,@PreDestroy→DisposableBean#destroy→destroy-method。

@Component public class OrderServiceImpl implements OrderService, BeanNameAware, ApplicationContextAware, InitializingBean, DisposableBean { private static final Logger log = LoggerFactory.getLogger(OrderServiceImpl.class); private final OrderRepository orderRepository; private final PaymentGateway paymentGateway; private final InventoryService inventoryService; private String beanName; private ApplicationContext context; public OrderServiceImpl(OrderRepository orderRepository, PaymentGateway paymentGateway, InventoryService inventoryService) { this.orderRepository = orderRepository; this.paymentGateway = paymentGateway; this.inventoryService = inventoryService; log.info("[1-实例化] OrderServiceImpl 构造器执行,依赖已就绪"); } @Override public void setBeanName(String name) { this.beanName = name; log.info("[3-Aware] BeanNameAware -> {}", name); } @Override public void setApplicationContext(ApplicationContext ctx) { this.context = ctx; log.info("[3-Aware] ApplicationContextAware"); } @PostConstruct public void postConstruct() { log.info("[5-初始化] @PostConstruct 执行,beanName = {}", beanName); } @Override public void afterPropertiesSet() { log.info("[6-初始化] InitializingBean.afterPropertiesSet"); } public void customInit() { log.info("[7-初始化] init-method customInit"); } @Override public String placeOrder(OrderCommand cmd) { log.info("[9-使用] 处理下单,sku = {}, qty = {}", cmd.skuId(), cmd.quantity()); inventoryService.lock(cmd.skuId(), cmd.quantity()); String payNo = paymentGateway.charge(cmd.userId(), cmd.amount()); Order order = orderRepository.save(new Order(cmd, payNo)); context.publishEvent(new OrderCreatedEvent(order)); return order.getId(); } @PreDestroy public void preDestroy() { log.info("[10-销毁] @PreDestroy"); } @Override public void destroy() { log.info("[10-销毁] DisposableBean.destroy"); } public void customDestroy() { log.info("[10-销毁] destroy-method customDestroy"); } }

对应的配置类:

@Configuration public class OrderConfig { @Bean(initMethod = "customInit", destroyMethod = "customDestroy") public OrderServiceImpl orderService(OrderRepository repository, PaymentGateway gateway, InventoryService inventoryService) { return new OrderServiceImpl(repository, gateway, inventoryService); } }

注意这里有个细节:用@Bean方法创建 Bean 时,@PostConstruct依然有效(由CommonAnnotationBeanPostProcessor统一处理),InitializingBean也依然有效。所以三种方式可以叠在同一个 Bean 上,只是顺序固定。

4.2 一次下单请求的日志实测与逐行解读

把上面这些跑起来,启动加一次下单再加关闭,日志大致是这样的(省略了框架无关的噪声):

[1-实例化] PayConfig 构造器执行 [2-属性填充] PayConfig 配置注入完成 [5-初始化] PayConfig @PostConstruct 执行 [8-BPP后置] payConfig -> PayConfig [1-实例化] PaymentGateway 无参构造器执行,此时 payConfig = null [2-属性填充] PaymentGateway.setPayConfig 注入完成 [8-BPP后置] paymentGateway -> PaymentGateway ... [1-实例化] OrderServiceImpl 构造器执行,依赖已就绪 [3-Aware] BeanNameAware -> orderService [3-Aware] ApplicationContextAware [4-BPP前置] orderService [5-初始化] @PostConstruct 执行,beanName = orderService [6-初始化] InitializingBean.afterPropertiesSet [7-初始化] init-method customInit [8-BPP后置] orderService -> com.demo.order.OrderServiceImpl$$SpringCGLIB$$0 ... [9-使用] 处理下单,sku = S1001, qty = 2 [AOP] placeOrder 耗时 3 ms ... [10-销毁] @PreDestroy [10-销毁] DisposableBean.destroy [10-销毁] destroy-method customDestroy

逐行看几个关键信息。

第一,PaymentGateway的两行日志证明了“先造壳再灌料”:构造器执行时payConfig是null,到setPayConfig才有值。而OrderServiceImpl因为用构造器注入,实例化那一行就已经能说“依赖已就绪”,两步被压缩了。这个差异经常让人误以为“构造器注入不走属性填充”,其实走了,只是在构造器调用之前就解析完了参数。

第二,[8-BPP后置]里打印的类名是 CGLIB 代理类,说明代理是在初始化之后才套上去的。如果你的 Bean 没加任何切面,这里打印的就是原类名。

第三,销毁日志的顺序是@PreDestroy→DisposableBean.destroy→destroy-method,和初始化顺序一一对应。注意customDestroy是最后执行的,如果你的资源释放有依赖关系,要按这个顺序设计。

第四,也是容易忽略的一点:PayConfig的销毁日志会出现在OrderServiceImpl之后。因为PaymentGateway依赖它、OrderServiceImpl依赖PaymentGateway,容器会按依赖关系倒着销毁。你可以把这个特性利用起来:把“必须先关的”放在依赖链的上游,容器会帮你排序。

提示:如果你在日志里看不到[10-销毁]那几行,九成是因为你没有关闭容器,或者用的是prototype作用域。用try (var ctx = new AnnotationConfigApplicationContext(OrderConfig.class)) { ... }就能完整跑出来。

4.3 销毁链路的三层回调与 prototype 的例外

销毁这块我想单独强调,因为它比初始化更容易出问题。容器关闭的顺序是:先发ContextClosedEvent,再销毁所有单例,最后销毁 BeanFactory 本身。所以如果你想在所有 Bean 销毁之前做点事(比如停止接收新请求),监听ContextClosedEvent比@PreDestroy更合适,前者更早。

@PreDestroy的执行依赖DestructionAwareBeanPostProcessor,CommonAnnotationBeanPostProcessor实现了它。这里有个坑:如果你的项目是纯 Spring 环境,没引入jakarta.annotation-api,@PreDestroy会静默不执行,连报错都没有(Spring 6 会在启动时打一条 warn,但很容易被日志淹没)。所以看到@PreDestroy不生效,第一反应应该是检查依赖包里有没有jakarta.annotation-api。

prototype的例外前面提过了,这里补充一个更隐蔽的场景:@Scope("prototype")的 Bean 里注入了单例 Bean,单例被销毁时,原型 Bean 依然持有一个失效的引用,后续调用直接抛异常。这种问题只在“原型对象被缓存起来复用”的时候才会暴露,非常难查。我的建议是:原型 Bean 里不要持有需要销毁的资源,真要持有,就在业务层显式管理生命周期。

还有一个经常被问到的点:@Bean的destroyMethod默认值。Spring 有个“推断销毁方法”的机制,会自动去找close()和shutdown()方法并调用。这个特性很方便(比如你返回一个连接池对象,不用显式指定),但也很危险——如果你的类恰好有个叫close()的业务方法,它会在容器关闭时被意外调用。我在一个项目里遇到过,一个领域对象有个close()方法用于关闭某个业务状态,结果容器关闭时被调了一次,日志里莫名其妙多出一条状态变更。后来在@Bean上显式写了destroyMethod = ""才关掉推断。

5. 生命周期上的坑:循环依赖、线程安全与作用域

理解了顺序只是第一步,真正决定代码质量的是“知道哪些地方不能写逻辑”。这一章我挑三个最容易出事的点展开,都是我在实际项目里踩过或者帮别人排查过的。

5.1 三级缓存与提前引用是怎么插进生命周期的

循环依赖的本质是:A 的构造器需要 B,B 的构造器需要 A,谁都造不出来。Spring 用三级缓存解决这个问题,具体位置就在生命周期的属性填充之后、初始化之前。

三级缓存分别是:singletonObjects(成品)、earlySingletonObjects(半成品)、singletonFactories(对象工厂)。流程是:A 实例化完成后,先把一个能返回 A 早期引用的工厂放进三级缓存,然后去填充属性发现需要 B;B 实例化后填充属性发现需要 A,此时从三级缓存里拿到工厂,调用getObject()得到一个早期引用,放进二级缓存并返回;B 完成初始化进入一级缓存;A 拿到 B 继续初始化,最后进入一级缓存。

这个机制带来两个关键结论。第一,只有 setter/字段注入能解决循环依赖,构造器注入不行,因为构造器注入要求在实例化阶段就拿到完整依赖,那时候连早期引用都还没有。第二,被提前暴露的 Bean,它的 AOP 代理可能和最终对象不一致。如果 A 需要被代理,早期引用和最终引用的代理对象在极端情况下可能不是同一个,这也是 Spring 官方一直不推荐依赖循环依赖的原因。

我的态度很明确:把它当作“兜底机制”而不是“设计手段”。看到循环依赖,优先改设计(抽出第三方的协调者、用事件解耦),实在改不动再用@Lazy打破。@Lazy注入的是一个代理,真正调用时才去容器取,能绕过循环依赖检查,但代价是启动期发现不了问题,运行时才报错。

5.2 在初始化方法里做重活为什么会把启动拖死

@PostConstruct和afterPropertiesSet都是同步执行的,在容器启动的主线程上跑。你在这里发一次 HTTP 请求、查一次数据库,启动时间就直接加上对应的耗时。单机可能只是慢几秒,上了 K8s 滚动发布,几秒乘以实例数就是分钟级。

更麻烦的是失败处理。初始化方法抛异常,容器会直接启动失败,而且因为是在 Bean 创建过程中抛的,异常栈里会包一层BeanCreationException,真正的根因埋得很深。我遇到过最离谱的一次,是某个 Bean 在afterPropertiesSet里调远程配置中心,配置中心超时 30 秒,导致整个应用启动失败,而错误信息只写了“Bean 创建失败”,压根没提超时。

替代方案有几个,按推荐程度排:

方案执行时机适用场景
实现SmartInitializingSingleton所有单例创建完成后需要遍历所有 Bean 做一次性预热
监听ApplicationReadyEvent上下文刷新完成、端口已监听需要确认自己已经能对外服务
实现ApplicationRunner/CommandLineRunner应用启动后需要按顺序执行多个启动任务
异步化 + 健康检查自己控制预热耗时长、失败可降级

SmartInitializingSingleton#afterSingletonsInstantiated这个回调知道的人不多,它在preInstantiateSingletons的最后阶段被调用,此时所有单例都已创建完毕,非常适合做“批量预热缓存”这类需要全量视图的事情。我在一个商品服务里用它预加载了全部类目树,比在单个 Bean 的@PostConstruct里做要安全得多。

5.3 Spring 6 与 Boot 3 下的新变化:jakarta 包、AOT 与原生镜像

用 Spring 6 有一个绕不开的变化:javax.annotation全部换成了jakarta.annotation。@PostConstruct、@PreDestroy、@Resource的包名都变了。如果你是从 Spring 5 升上来的,最容易出现的症状就是“注解不报错,但回调不执行”——因为代码里还是javax的导入,编译能过(如果依赖还在),运行时就是匹配不上。

第二个变化是 AOT 和原生镜像。Spring 6 引入了 AOT 处理,可以在构建期分析出 Bean 的定义和生命周期回调,生成直接调用的字节码,从而在原生镜像里不依赖反射。这对生命周期有两个影响:一是启动更快,二是如果某个回调没法在构建期被分析到,运行时可能静默不执行。比如你在运行时动态注册的 Bean,或者用反射调用的初始化方法,在原生镜像下就要额外注册提示。这块我踩过一次,某个通过BeanDefinitionRegistryPostProcessor动态注册的 Bean,在 JVM 模式下一切正常,打成原生镜像后@PostConstruct死活不执行,最后是靠@ImportRuntimeHints补的。

还有一个和生命周期间接相关的变化:Spring 6 大力推广@Configuration(proxyBeanMethods = false)。这个配置的本意是省掉 CGLIB 代理、加快启动,但它带来一个副作用:在proxyBeanMethods = false的配置类里,你直接调用@Bean方法,拿到的就是普通new出来的对象,不走生命周期,不进容器。我见过有人在配置类里写orderService().placeOrder(...),跑了半天发现@PostConstruct没执行,还以为是注解失效。

@Configuration(proxyBeanMethods = false) public class BadConfig { @Bean public OrderServiceImpl orderService() { return new OrderServiceImpl(null, null, null); } @Bean public String trigger() { // 这里是普通方法调用,返回的是新对象,不是容器里的那个单例 orderService().placeOrder(null); return "done"; } }

正确做法是通过方法参数注入,让容器把 Bean 传进来。这个细节在升级 Spring 6 时特别容易被忽略,因为代码不报错,只是行为不对。

6. 常见问题速查与回调选型建议

最后这部分是我这些年攒下来的“病历本”,按症状查原因,比从头推导快得多。

6.1 问题速查表

症状最可能的原因排查动作
@PostConstruct不执行用了javax包 / 类未被容器管理 / 配置类里手动 new检查 import 是否为jakarta.annotation;确认类上有@Component或@Bean
afterPropertiesSet不执行类未被容器管理检查是否被扫描到、是否在proxyBeanMethods = false里被手动调用
销毁回调完全不打印容器未关闭 / prototype 作用域用 try-with-resources;确认 scope
BPP 里注入的 Bean 为 nullBPP 创建时机早于普通 Bean改用ApplicationContextAware延迟获取,或加@Lazy
启动报循环依赖构造器注入形成环改 setter 注入,或抽第三方协调者,或@Lazy
事务/异步在同类调用中失效代理在 BPP 后置才生成,内部调用走原始对象注入自身代理或拆分到另一个 Bean
启动耗时异常长初始化方法里有远程调用看线程栈,把重活挪到ApplicationReadyEvent
原型 Bean 持有失效引用单例销毁后原型仍在缓存检查缓存逻辑,改为每次获取

这张表里我最想强调的是“事务/异步在同类调用中失效”这一条。它的根因和生命周期强相关:代理是在postProcessAfterInitialization生成的,而@PostConstruct里的this.method()调用发生在代理生成之前,this指向的是原始对象。理解了这个,你就能理解为什么“把方法抽到另一个 Bean 里注入进来调用”是有效的——因为注入进来的是代理对象。

6.2 什么场景该用哪个回调

这些回调不是越多越好,选错了会带来维护成本。我自己的选型习惯是这样的:

需求首选方案理由
校验必填配置构造器 +@Value参数校验失败最早,报错最清晰
初始化本地缓存@PostConstruct语义清晰,标准注解,不绑框架
初始化第三方客户端@Bean(initMethod = ...)不侵入第三方类
需要 BeanName 等容器信息XxxAware唯一的正规入口
所有 Bean 就绪后做批量操作SmartInitializingSingleton时机准确,只执行一次
对外服务就绪后做预热ApplicationReadyEvent此时端口已监听,健康检查通过
释放连接、线程池@PreDestroy或destroyMethod顺序稳定,容器保证执行
全局停止接收请求ContextClosedEvent比@PreDestroy更早,能拦住新流量

有一个原则贯穿始终:能在构造器里做完的事,不要拖到回调里。构造器里做的事失败得最早、报错最清晰、也最容易测试。回调是给“必须等容器准备好”的场景留的口子,不是给你堆业务逻辑的地方。

6.3 我在实际项目里的几条经验

第一条,别在回调里写“有副作用且不可重入”的逻辑。容器的初始化在某些场景下可能被触发多次(比如父子容器、测试上下文缓存),@PostConstruct被执行两次并不罕见。写之前先问自己:这段逻辑跑两遍会出事吗?会,就加幂等保护。

第二条,日志一定要打类名和 beanName。生命周期相关的排查,90% 的突破口就是“到底是谁的日志”。我在所有回调的第一行都固定打成[阶段] 类名.方法名 beanName=xxx的格式,出问题时 grep 一下就能还原整条链路。

第三条,写BeanPostProcessor时用static日志对象,别用@Autowired的成员。BPP 是容器里最早被创建的一批对象,那时候大部分基础设施还没就绪,能少依赖就少依赖。

第四条,看到“顺序相关”的 bug,先怀疑自己的设计,别急着加@Order、@DependsOn。这些注解能救急,但每加一个都在给未来的自己挖坑。真正稳定的设计是“不依赖顺序”,用事件、用接口、用显式的编排。

第五条,也是最实在的一条:把十步日志跑一遍,比看十篇文章管用。你可以在自己的项目里随便挑一个核心 Bean,临时加上BeanNameAware、ApplicationContextAware、@PostConstruct、InitializingBean,再配一个打印类名的BeanPostProcessor,跑一次启动加关闭,把日志从头看到尾。看完之后,五步、七步、十步这些说法对你来说就不再是需要背的东西,而是一条你能在脑子里“播放”的流水线——哪一步能干什么、哪一步不能干什么,一眼就清楚。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进