资讯详情

资讯详情

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→PayConfigOrderServiceImpl→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是nullboolean是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先调DestructionAwareBeanPostProcessorPreDestroy就是由它处理的再调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是5PostConstructCommonAnnotationBeanPostProcessor是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注入业务 BeanSpring 会给你一个“提前初始化”的提示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的执行依赖DestructionAwareBeanPostProcessorCommonAnnotationBeanPostProcessor实现了它。这里有个坑如果你的项目是纯 Spring 环境没引入jakarta.annotation-apiPreDestroy会静默不执行连报错都没有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 的构造器需要 BB 的构造器需要 A谁都造不出来。Spring 用三级缓存解决这个问题具体位置就在生命周期的属性填充之后、初始化之前。三级缓存分别是singletonObjects成品、earlySingletonObjects半成品、singletonFactories对象工厂。流程是A 实例化完成后先把一个能返回 A 早期引用的工厂放进三级缓存然后去填充属性发现需要 BB 实例化后填充属性发现需要 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或BeanafterPropertiesSet不执行类未被容器管理检查是否被扫描到、是否在proxyBeanMethods false里被手动调用销毁回调完全不打印容器未关闭 / prototype 作用域用 try-with-resources确认 scopeBPP 里注入的 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% 的突破口就是“到底是谁的日志”。我在所有回调的第一行都固定打成[阶段] 类名.方法名 beanNamexxx的格式出问题时 grep 一下就能还原整条链路。第三条写BeanPostProcessor时用static日志对象别用Autowired的成员。BPP 是容器里最早被创建的一批对象那时候大部分基础设施还没就绪能少依赖就少依赖。第四条看到“顺序相关”的 bug先怀疑自己的设计别急着加Order、DependsOn。这些注解能救急但每加一个都在给未来的自己挖坑。真正稳定的设计是“不依赖顺序”用事件、用接口、用显式的编排。第五条也是最实在的一条把十步日志跑一遍比看十篇文章管用。你可以在自己的项目里随便挑一个核心 Bean临时加上BeanNameAware、ApplicationContextAware、PostConstruct、InitializingBean再配一个打印类名的BeanPostProcessor跑一次启动加关闭把日志从头看到尾。看完之后五步、七步、十步这些说法对你来说就不再是需要背的东西而是一条你能在脑子里“播放”的流水线——哪一步能干什么、哪一步不能干什么一眼就清楚。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →