作为一个常年跟Spring容器打交道的人我对 Lazy 注解的感情一直比较复杂。很多开发同学对它的全部认知就是“给Bean做延迟初始化”可真要在项目里用起来会发现它既能帮你解决循环依赖、启动缓慢这些老大难问题又会引入代理对象、首次调用变慢等新的烦恼。尤其是我在某个库存系统里亲眼看到同事靠一个 Lazy 解掉构造器注入的循环依赖死结之后我就觉得这个注解值得专门写一篇超详细的内容来拆解。这篇文章我会从“它到底改变了Spring的什么行为”讲起接着深入到 BeanDefinition 和代理生成的源码链路再把我实际用过的几种标注方式、循环依赖破解方法、以及踩过的失效场景一一拆开。无论你是刚接触 Spring 的初学者还是用过一段时间想补全细节的进阶开发按顺序读下来都能把 Lazy 的前因后果彻底吃透。1. Lazy到底在解决什么问题先搞懂默认实例化的“饿汉式”机制1.1 默认策略为什么让项目启动越来越慢Spring 容器的核心是 ApplicationContext而 ApplicationContext 在启动完毕之后对默认注册进来的单例 Bean 几乎是“一次性全部实例化”的。这个行为由 AbstractApplicationContext 的 refresh 方法触发最终会落到 DefaultListableBeanFactory 的 preInstantiateSingletons 方法上。简单说容器启动时会把所有非懒加载的单例 Bean 都 new 出来完成属性填充、初始化回调等整套流程。这意味着一个很现实的问题一个中等规模的微服务随便拆分一下就有几百上千个 Spring Bean。很多 Bean 并不是系统启动后就立刻要用的比如某个定时任务处理器、某个远程调用客户端、某个消息队列消费者它们可能要到特定时刻才派上用场但默认机制照样会去创建它们。于是启动时间就这样一分一秒地堆上去了。Spring 默认的这种做法业界通常叫做“饿汉式”。它的设计初衷其实有道理启动阶段就把该创建的 Bean 全部创建好运行时就不必再承担创建 Bean 的开销也能尽早暴露配置错误。但对体量越来越大的互联网应用来说饿汉式启动带来的问题越来越突出冷启动时间长、资源占用高、机器扩容效率低。尤其在云原生环境下实例调度和弹性伸缩对启动时间的要求非常苛刻这时候“能少创建一点就少创建一点”的想法自然就浮出水面了。1.2 Lazy的核心能力以及它真正适合的三种场景Lazy 注解就是为了改变这种默认行为而存在的。它的核心语义是让 Spring 在容器启动阶段跳过这个 Bean 的实例化等到第一次被使用的时候才真正创建对象。这个“第一次被使用”包括首次注入、首次调用 getBean、首次调用代理对象的方法等。从使用场景来看Lazy 主要解决三类问题。第一类是启动提速这是最直观的收益。把非核心的 Bean 设置为懒加载之后容器的预实例化名单大幅缩小启动时间往往能有肉眼可见的下降。第二类是循环依赖。在 Spring 原生机制解决不了的构造器循环依赖里用 Lazy 在某一侧的注入点生成代理对象可以打断依赖环。这一点我会在后面单独开一章细讲。第三类是“按需加载”的业务需求。比如某个重量级客户端对象、算法模型、本地缓存它们初始化成本高但并不是每次请求都会用到懒加载可以避免白白占用内存和连接资源。但这里必须强调Lazy 不是银弹。它把“启动时的成本”转移到了“第一次使用时的成本”严格来说是延迟了问题而不是消除了问题。所以选不选它、用在哪些 Bean 上需要结合具体业务来判断而不是一上来就全局开启。2. 源码层面的真相从BeanDefinition到代理对象Lazy在哪一步改了容器的行为2.1 BeanDefinition上的lazyInit标记是整个机制的钥匙在 Spring 里一个 Bean 的所有元数据信息都保存在 BeanDefinition 里。Lazy 注解最终影响的就是 BeanDefinition 上名为 lazyInit 的布尔属性。这个属性在默认情况下是 false也就是不懒加载。当 lazyInit 为 true 时Spring 在启动阶段会把这个 Bean 从“预实例化扫描名单”中排除掉。关键的判断点在 DefaultListableBeanFactory 的 preInstantiateSingletons 方法内部。容器拿到所有单例 Bean 的名字后会逐一检查对应的 BeanDefinition.isLazyInit()如果返回 true这个 Bean 就会被跳过不会调用 getBean 去触发实例化。所以从源码根上去理解Lazy 影响的不是 Spring 创建 Bean 的能力而是启动阶段容器是否“主动”去创建 Bean。这个“主动”很关键。它意味着如果你在某个类上标了 Lazy但另一个非懒加载的 Bean 在注入点没有加 Lazy 并直接依赖了它那么容器创建那个非懒加载 Bean 时依然会把这个懒加载 Bean 拉起来。也就是说懒加载只影响预实例化扫描和显式标记的注入点解析不影响普通依赖链上的被动创建。2.2 注入点上的 Lazy 并不是延迟注入而是注入一个代理对象这里有个非常容易混淆的细节。在依赖注入的位置标注 Lazy比如在 Autowired 字段旁加上它Spring 解析这个注入点时不会老老实实去获取真实 Bean而是会调用 ContextAnnotationAutowireCandidateResolver 的 getLazyResolutionProxy 方法为这个依赖生成一个代理对象。这个代理对象的生成方式很讲究。Spring 会先判断目标 Bean 的类型如果目标类型是接口就使用 JDK 动态代理如果不是接口而是具体类就用 CGLIB 生成子类代理。代理内部封装了一个核心逻辑当你第一次调用代理对象的方法时它才会真正去调用 BeanFactory.getBean(beanName)把真实对象创建出来并转发这次调用。这其实是一个非常精妙的设计。注入点上加了 LazySpring 并没有在注入那一刻真的“不注入”也没有在字段里放一个 null而是放了一个智能代理。这个代理平时看起来就像目标 Bean 一样可以被调用、被传递、被切面拦截但它真正干活的时候才去创建背后的真实对象。所以你在调试时看到字段里是一个代理对象这完全正常不要以为是 Lazy 失效了。2.3 类级别和全局配置的链路最终也汇合到同一个标记上除了标注在注入点Lazy 也可以标注在类上、Bean 方法上。如果是 Component 类Spring 在组件扫描的时候会读取注解的 value 值把它写入 BeanDefinition 的 lazyInit 属性。如果是 Bean 方法ConfigurationClassPostProcessor 处理配置类时同样会识别 Lazy 并设置到 BeanDefinition 上。如果你是在 Spring Boot 项目里还可以通过配置文件把所有 Bean 都变成懒加载spring.main.lazy-initializationtrue。设置的原理是把容器的默认 lazyInit 规则改为 true这样凡是没有显式声明 Lazy(false) 的 Bean都会按懒加载处理。这种全局开关用起来方便但风险也大因为它影响的是容器里所有 Bean我会在后面的最佳实践里详细说。总的来说Lazy 的生效核心并不是什么黑魔法而是 BeanDefinition 上那个布尔标记。理解了这个关键点很多表面上看似奇怪的失效问题都能迎刃而解。3. 五种使用姿势与实操细节别再“注了但没完全注”3.1 标注在配置类上整批Bean集体“转懒”类级别标注的典型写法是Configuration Lazy public class DataCenterConfig { // 这里定义的Bean都会变成懒加载 }需要特别留意的是Lazy 标注在 Configuration 类上时作用于的是这个配置类中声明的所有 Bean 方法但它不会影响被组件扫描进来的其他类。这种用法适合把某一个模块的依赖统一改为懒加载比如某些集成配置类只有调用方真的用到对应服务时才需要初始化。不过这里有个小陷阱。如果你在配置类上标注了 Lazy类里面的 Bean 方法又不希望懒加载那就需要在这个 Bean 方法上显式标注 Lazy(false)。否则你很难通过代码直观判断每个 Bean 的最终加载行为排查问题时会多花不少时间。3.2 标注在Bean方法和组件类上控制单个Bean的加载时机单个 Bean 方法上的写法很简单Configuration public class DataSourceConfig { Bean Lazy public DataSource dataSource() { return dataSourceFactory.create(); } }对于被 Component 扫描进来的类也可以直接在类上标注Component Lazy public class MessageQueueConsumer { // 这个消费者直到第一次被注入或调用时才初始化 }这种单点标注是我在项目里最常用的方式。它不会影响全局行为只会让某一个具体 Bean 的加载时机发生变化心智负担小出了问题也容易定位。有一点要记住Bean 方法上标注 Lazy并不会阻止其他 Bean 在创建时依赖它。如果某个非懒加载 Bean 在注入点没有加 Lazy那么创建那个非懒加载 Bean 时仍然会去创建这个懒加载 Bean。3.3 在注入点上使用Lazy这才是解决循环依赖的关键姿势最常见的写法是字段注入Component public class OrderServiceImpl { Lazy Autowired private PaymentClient paymentClient; }更严谨的写法是在构造器参数上标注Component public class RefundServiceImpl { private final PaymentClient paymentClient; public RefundServiceImpl(Lazy PaymentClient paymentClient) { this.paymentClient paymentClient; } }构造器参数上的 Lazy 会在 Spring 解析构造器依赖时生效为参数生成代理对象。这样既能保持 final 字段的不可变性又能避免构造器注入造成的循环依赖问题。另外在 Configuration 配置类中Bean 方法参数上也可以使用 LazyBean public ReportFacade reportFacade(Lazy ReportEngine engine) { return new ReportFacade(engine); }这个用法在一些第三方集成场景里很常见目的是避免配置类初始化时就把重量级组件拉起来。3.4 注入点延迟解析与延迟初始化本质上是两回事我见过不少同学把“注入点加 Lazy”理解成“这个字段以后再去注入”。严格来说这并不是事实。字段上加了 Lazy 后依赖注入本身仍然是立即发生的只是注入的值是代理对象不是真实 Bean。真实 Bean 的创建动作被推迟到了第一次调用代理对象方法的时候。这个区别在调试时特别重要。如果你在注入点打断点看到的值是一个代理对象这是正常现象。如果你想确认真实对象何时创建可以在目标 Bean 的构造方法里加日志这样很容易观察它到底是启动时创建还是第一次调用时创建。4. 用Lazy解循环依赖代理对象如何绕开构造器死循环4.1 先搞清Spring原生循环依赖的解决边界Spring 对循环依赖的默认处理方式是“三级缓存”也就是用三个 Map 来缓存不同阶段的 Bean。在创建 A 的过程中提前暴露 A 的早期引用让 B 可以完成注入最终再回填 A。但这套机制有一个前提条件循环依赖中的 Bean 必须是单例且不能通过构造器注入形成死循环。具体来说如果你是构造器注入 A 依赖 B、B 依赖 A那么在 A 的构造器还没执行完时容器根本拿不到 A 的完整实例三级缓存里也不会有可以提前暴露的对象于是直接抛出 BeanCurrentlyInCreationException。这个异常在大型项目里并不少见而且它经常不会在开发环境里出现往往要等到某个 Bean 被切面拦截或者新加了一个中间依赖后才突然爆出来。4.2 Lazy如何用代理对象绕开构造器死循环在构造器注入循环依赖的某一侧注入点加上 Lazy 后Spring 解析 A 的构造器参数时发现 B 参数上标注了 Lazy于是不再尝试立刻获取完整的 B 对象而是生成一个 B 的代理对象传给 A 的构造器。A 成功实例化构造器持有的是 B 的代理。接下来B 在创建过程中需要注入 A。这时候 A 已经作为预实例 Bean 存在于容器中B 可以顺利注入 A。所以整个循环依赖在构造阶段就被打断了不再需要三级缓存帮忙。当业务代码真正调用 A 里的 B 代理方法时代理会触发 getBean(b)。此时 B 多半已经完成了创建和注入真实调用自然畅通。如果 B 还没到创建的时机容器也会立刻把它创建出来整个过程对调用方完全透明。4.3 用Lazy解循环依赖的三个注意点第一注入的是代理对象不是真实对象。如果在初始化逻辑里对依赖做类名判断、反射读取字段、AOP 切点匹配等操作可能会因为拿到的是 CGLIB 子类或 JDK 代理类而产生细微差异。第二不要在项目里用 Lazy 掩盖设计问题。循环依赖往往是设计上的信号A 和 B 职责耦合太紧、边界不清晰。利用 Lazy 可以快速解决技术问题但会把架构问题埋得更深。如果我在 Code Review 里看到某个模块频繁用 Lazy 解循环依赖我一般会建议团队重新梳理依赖方向必要时用事件机制或者中间层抽象来切断硬依赖。第三Lazy 解循环依赖的适用范围大于三级缓存。三级缓存只对单例 Bean 的 setter 注入或字段注入生效构造器注入无能为力而 Lazy 因为生成代理对象天然适合构造器注入场景。这也是它在某些存量项目里被当作“救火队员”使用的原因。5. Lazy失效场景与排查记录踩过的坑一次说清5.1 Lazy没生效的几类典型情况第一种情况是标错了位置。Lazy 放在 private 字段上可以生效但如果放在 static 字段上Spring 根本无法注入更谈不上懒加载。final 字段也是类似的问题字段注入本来就不支持 final 字段这和 Lazy 无关但很多人会把两者混在一起排查。第二种情况是“间接依赖导致失效”。前面反复提过如果某个非懒加载 Bean 在注入点没有加 Lazy那么创建这个 Bean 时Spring 仍然会去创建那个被 Lazy 标记的 Bean。也就是说排查懒加载是否生效时不能只看目标 Bean 自身的配置还要看谁依赖了它、依赖它的那个注入点是否也加了 Lazy。第三种情况是配置类和代理机制相互影响。如果在 Configuration 类上标注了 Lazy但类中又定义了 Bean 方法并且配置类没有走默认的 CGLIB 增强逻辑比如配置了 proxyBeanMethodsfalse整个懒加载的语义边界会变得模糊让人误以为注解失效。第四种情况是 Spring Boot 的全局配置覆盖。如果你在配置文件里设置了 spring.main.lazy-initializationtrue但又在某个 Bean 上显式标注了 Lazy(false)优先看这个显式标注的结果。排查时最好先确认当前环境到底加载的是哪份配置很多表面上的“Lazy 失效”其实是配置不一致。5.2 启动变快了但发版后的第一次调用“卡”住了这是使用 Lazy 最常见的连锁反应。大量 Bean 懒加载后启动时间确实降下来了但第一个用户请求进来时需要触发大量 Bean 创建接口耗时可能从几十毫秒飙升到几秒。在流量敏感的场景下这可能直接影响可用性甚至引发超时告警。我的建议是不要把 Lazy 当成启动优化的唯一手段。更稳妥的做法是先统计项目里哪些 Bean 拖慢了启动再针对性地对其中确实不需要启动阶段实例化的 Bean 标注 Lazy。同时可以预留一个预热接口或者用后台预热线程在容器启动完成后主动调用关键入口让真正核心的 Bean 提前完成实例化。这样既能享受启动提速又能避免首次请求变慢。5.3 与AOP、事务等切面机制配合时的坑Lazy 配合 Transactional 时有个很容易被忽略的细节。如果你通过 Lazy 注入的是一个代理对象那么从这个代理对象发起的调用通常能正常走事务增强但如果业务代码里绕过代理对象直接访问目标 Bean比如同一个类内部的 this 调用事务仍然不会生效。这个问题的本质是 Spring AOP 的代理自调用陷阱Lazy 只是改变了注入对象并没有改变 Spring AOP 的基本规则。之前在处理某个订单状态变更问题时就遇到过类似情况某个服务里注入了另一个服务的代理事务一直正常后来重构代码时把注入对象改成了真实对象部分事务悄悄失效了。排查到最后发现就是因为 Lazy 代理被替换掉了。所以遇到“事务没生效”“切面没执行”这类问题除了检查常规的切面配置也要看看目标依赖上是否加了 Lazy代理对象到底有没有被真实使用。6. 影响范围与最佳实践什么时候该用什么时候该收起6.1 从启动性能到云原生部署影响比想象中远在微服务架构里启动时间是直接影响扩容效率的关键指标。实例数量多、配置项多、依赖服务多每多一秒启动时间在大规模发布和弹性伸缩时都会被放大。Lazy 可以在不动业务代码、不动服务架构的前提下显著缩短启动时间。对云原生环境来说这种冷启动优化的意义尤其明显。但也正因为如此系统性地使用 Lazy 会引入新的复杂性Bean 创建时机变得不可预测、首次调用延迟、潜在问题更晚暴露。所以在一个正式项目里如果要成规模地使用 Lazy必须提前配套监控和预热设计否则你省下来的启动时间可能会在故障恢复时加倍还回去。6.2 按场景整理的建议清单场景建议单例Bean数量多、启动慢有选择性地懒加载非核心Bean构造器注入循环依赖在某一侧注入点使用Lazy代理启动时必须校验连接/配置保持默认饿汉式不要懒加载首次请求可用性敏感配合预热机制避免首次调用超时整个项目想全局懒加载谨慎评估问题发现滞后风险较高表格里的判断标准核心就一条这个 Bean 是否真的需要在系统启动时立即可用。如果是基础配置、连接池、注册中心客户端建议保留启动阶段初始化如果是低频业务组件、定时任务、批量处理客户端懒加载的收益会更明显。6.3 个人实操体会我在实际项目里最常用 Lazy 的地方其实不在业务代码里而在一部分“基建类”Bean 上。比如某个文件存储客户端它的初始化需要做一次连通性校验而项目启动阶段并没有业务会立刻用到它我就会把它标注为 Lazy同时提供一个显式的健康检查接口在容器完全就绪后由探活请求触发初始化。这样做的好处很直接既保证了启动效率也没有丢失发现问题的最佳时机。最后再分享一个小细节。如果你正在排查一个“明明配置了全局懒加载启动却没有变快”的问题可以先看一下是不是把 spring.main.lazy-initializationtrue 配在了测试环境配置里而生产环境配置缺失。这类多环境配置不一致的问题我在几个线上项目里都真实遇到过排查起来确实比较费眼神但一旦理清环境配置的优先级问题往往很快就解决了。
阅读完成 · 觉得有帮助?