Spring @Lazy注解深度解析:从源码到实战,避开延迟初始化的那些坑 1. 一个所有Spring开发者都容易用错的注解Lazy的诊断笔记有次在内部技术群里看到一位同事贴了一段代码他在某个服务类上加了Lazy注解自信地以为这个Bean会在第一次用到时才创建。结果项目启动日志里明明白白地打印了该Bean的构造方法执行记录他当场愣住我不是标了Lazy吗怎么还是启动时就实例化了这个问题特别典型也是我决定把Lazy彻底讲透的原因。很多人对Lazy的理解停留在延迟初始化四个字上但Spring里的延迟在不同的使用位置上语义完全不同。放在类上是一种效果放在依赖注入点上又是一种效果放在Bean方法上又是另外一种效果。如果不理解背后的代理机制踩坑是必然的。这篇博文我不打算只讲注解怎么用我会结合Spring源码把Lazy的完整工作过程拆开来看容器启动时它到底改了什么、注入点上的代理对象是怎么生成的、为什么它能解决某些循环依赖、以及实际项目中什么场景才真正需要它。无论你是刚接触Spring源码的读者还是写了好几年业务代码的开发应该都能从里面得到一些新的东西。1.1 Spring默认的立即初始化到底有多立即要理解Lazy先要理解没有它的时候Spring是怎么工作的。在大多数Spring项目中容器启动时执行refresh()其中有一个步骤叫finishBeanFactoryInitialization()这个阶段会调用DefaultListableBeanFactory.preInstantiateSingletons()把所有非懒加载的单例Bean提前创建好。这段逻辑在源码里很直白遍历所有注册的beanDefinitionNames跳过抽象定义、跳过懒加载定义、跳过原型作用域的Bean其余的全部走getBean()实例化。也就是说一个普通的单例Bean即使没有任何人引用它容器启动的时候也会被创建出来。比如你写了一个UserService里面有一个构造方法启动日志里能看到构造方法被调用就是这个原因。这里有个很多新手没意识到的问题所谓的容器启动完成其实意味着大量Bean已经在内存里了。如果项目里有几百个Service、几千个组件启动时全部实例化启动时间自然被拉长。Lazy最直接的动机就来自这里能不能让某些Bean不要那么积极等真正有人用的时候再创建1.2 Lazy注解在BeanDefinition上动了什么手脚Lazy注解本身的定义很简单源码就几行Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.FIELD}) Retention(RetentionPolicy.RUNTIME) Documented public interface Lazy { boolean value() default true; }注意几个关键信息它可以标注在类型上、方法上、构造器上、参数上、字段上。value默认是true也就是说Lazy和Lazy(true)等价如果你传Lazy(false)就相当于明确要求不要延迟这在某些全局配置或父类标记的场景下可以用来覆盖默认行为。Spring扫描注解的时候会通过AnnotationConfigUtils.processCommonDefinitionAnnotations()把Lazy的值读取出来设置到BeanDefinition的lazyInit属性上。这一步非常关键——lazyInit被标记为true之后前面说的preInstantiateSingletons()就会跳过这个Bean的提前实例化。但要记住跳过提前实例化不等于不创建只是把创建动作往后挪到第一次getBean()的时候。容器启动时它确实不在内存里但第一次有人调用它时该创建还是会创建中间没有任何偷懒的魔法。2. 源码视角注入点上看到的根本不是一个真实对象很多文章讲到Lazy就停在延迟到第一次使用时创建这个层面这是不够的。真正的关键在依赖注入环节当一个Bean A 依赖一个标记了Lazy的Bean B 时Spring注入给A的到底是个什么东西答案是一个代理对象不是B本身。这个代理在Spring源码里叫懒加载解析代理整个生成过程发生在DefaultListableBeanFactory处理依赖的时候。2.1 从DependencyDescriptor到LazyInitTargetSourceSpring在doResolveDependency()里解析一个依赖时实际上会先把当前依赖封装成一个DependencyDescriptor里面带有目标字段或参数的元信息包括有没有标Lazy注解。然后它会调用ContextAnnotationAutowireCandidateResolver.getLazyResolutionProxyIfNecessary()做判断。这个方法的核心逻辑可以简化成三步第一步检查DependencyDescriptor上是否存在Lazy注解存在才继续第二步通过buildLazyResolutionProxy()创建一个代理工厂第三步把代理对象作为最终的依赖注入结果返回。代理的TargetSource是LazyInitTargetSource它内部保存了目标Bean的名字和BeanFactory引用。我们调用代理的任何方法时LazyInitTargetSource.getTarget()才会真正去getBean()获取真实对象然后由AOP拦截器把方法调用转发给这个真实对象。用一句话概括Lazy注入点的本质是先发一个替身给你等你要用真身的时候替身再去背后把人叫出来。这个替身就是AOP代理所以Lazy依赖Spring的AOP基础设施天生支持JDK动态代理和CGLIB代理两种情况。2.2 为什么类级别的Lazy和注入点级别的Lazy效果完全不同这是本篇想强调的第一个核心区别也是最容易被误解的地方。类级别的Lazy比如你在UserService类上标注Lazy影响的是这个Bean本身的创建时机——容器启动时不主动实例化它第一次getBean(userService)时才创建。这种模式下其他Bean注入UserService时拿到的仍然是真实对象只不过注入动作发生在UserService真正创建之后。注入点级别的Lazy比如你在A类里有一个Autowired的UserService字段字段上标了Lazy影响的是这次注入的处理方式——Spring直接给你一个代理对象塞进去。哪怕UserService在容器启动时已经被创建了A持有的依然是代理不是真实对象。大多数人在业务代码里用的是第二种但头脑里想象的却是第一种。这两种方式重叠时情况更微妙后面第5部分会专门讲。3. 从写法到效果五种最常见的Lazy使用姿势既然Lazy可以标注在不同位置我们就按位置把用法拆开。每个位置解决的实际问题不同混用的时候要格外小心。3.1 放在Component类上启动提速的第一板斧Service Lazy public class ReportService { public ReportService() { // 这个构造方法的执行会推迟到第一次getBean时 } }这种写法最直观效果也最符合直觉ReportService不会在容器启动时实例化。适合什么场景适合那些定义了但不一定马上用的重型Bean比如定时任务调度器、报表生成引擎、复杂的监控管理器。项目里如果有一批这样的Bean全部加上Lazy确实能缩短启动时间。但我必须提醒这只是启动提速的低成本手段不是银弹。Lazy只是把创建时间往后挪并没有减少创建本身的开销。如果你的目标是把启动时间从60秒降到20秒靠它确实能立竿见影但如果你的目标是压榨出极致的性能应该去排查哪些Bean是启动时根本不需要的而不是一刀切全标记懒加载。3.2 放在Bean方法上精确到单个Bean的延迟控制在Configuration配置类里定义一个Bean方法时可以单独给这个方法加LazyConfiguration public class AppConfig { Bean Lazy public DataSource dataSource() { // 只有当第一次真正需要DataSource时才会执行这里 return new DataSource(); } }这个用法比类级别更精细它只影响当前Bean方法返回的那个Bean定义。ConfigurationClassBeanDefinitionReader在解析Bean方法时会读取方法上的Lazy注解并设置对应的lazyInit标记。注意如果你只是给AppConfig类整体加了Lazy类里的Bean方法并不会自动带上懒加载属性——配置类懒加载和配置类里的Bean定义懒加载是两回事。这个场景的真正价值在于有些对象的创建成本非常高比如建立数据库连接池、初始化RPC客户端、加载本地模型文件放在启动阶段会让容器迟迟无法就绪。用Lazy标记这些Bean方法可以保证容器迅速启动真正调用的时候才去初始化那些昂贵的资源。但谨慎一点数据源这类基础设施Bean被懒加载后如果有多个Bean同时依赖它第一次并发调用可能同时触发创建逻辑虽然Spring的getBean会做单例合并不会创建两份但初始化的等待时间会转移到第一个调用者身上。3.3 放在字段或Autowired注入点上拿到一个延迟代理这是最常用也是误解最深的写法Component public class OrderService { Autowired Lazy private InventoryService inventoryService; }OrderService在创建时Spring并不会去创建InventoryService而是往inventoryService字段里塞一个代理对象。OrderService里调用inventoryService.deductStock()时代理内部才触发getBean(inventoryService)拿到真实对象再执行真正的方法。如果只从代码层面看你几乎感知不到代理的存在——getClass()可能返回的是一个class信息而非原来的类但正常的接口方法调用完全透明。这里需要特别说明如果我同时给InventoryService类上也标了Lazy字段上也标了Lazy那么效果是叠加的——InventoryService本身确实等到第一次代理调用时才创建而且OrderService持有的还是代理。多数情况下你需要的其实只有注入点上的Lazy类级别的标注反而容易让人混淆。3.4 放在构造器参数上并发创建问题的破局工具之一构造器注入里有种情况两个Bean在构造器阶段互相依赖Spring会直接抛BeanCurrentlyInCreationException。常规的三级缓存只能解决singleton 字段注入的循环依赖没法解决构造器循环依赖原因是构造器执行期间对象还没有实例化完成根本没有早期引用可以暴露。Lazy在这里可以作为破局方案Component public class OrderService { private final PaymentService paymentService; public OrderService(Lazy PaymentService paymentService) { this.paymentService paymentService; } }OrderService构造时Lazy让Spring注入一个PaymentService的代理而不是立即创建PaymentService。这样OrderService可以正常完成构造等真正调用paymentService.pay()时代理再去创建PaymentService。两个Bean的构造器循环依赖就这样被代理打断了。唯一要注意的是构造器里拿到的是代理不是真实对象。如果你在构造器内部直接调用了paymentService的任何方法代理会立即触发创建此时很可能回到循环依赖的死锁里。所以这个方案最好配合构造器里只赋值不调用的原则。3.5 Lazy(false)强制不要懒注解的value默认是true那false什么时候有用实际开发里比较常见的是你在一段公共组件上标了Lazy但在某个关键路径上希望它立即初始化就可以在注入点上用Lazy(false)覆盖// 公共配置类里已经定义了懒加载 Bean Lazy public MetricsCollector metricsCollector() { ... } // 某个核心服务不希望延迟必须启动就绪 Component public class ApiGateway { Autowired Lazy(false) private MetricsCollector metricsCollector; }Lazy(false)相当于明确告诉Spring这次注入不要用代理请给我真实对象。如果目标Bean本身定义了懒加载真实对象的创建会在这次注入时被触发所以ApiGateway创建时MetricsCollector还是会被创建出来。3.6 一张表总结不同位置的语义标注位置影响对象实际效果类上Component/ServiceBean本身容器启动时不预实例化首次getBean时才创建Bean方法上该Bean定义同左但只针对这一个Bean字段/Autowired注入点当前注入点注入代理对象方法调用时才获取真实对象构造器参数上当前参数构造器接收代理对象防止构造期依赖创建Lazy(false)注入点或定义关闭延迟立即创建并注入真实对象4. 循环依赖场景里Lazy到底是怎么劝架的4.1 先理清一个背景三级缓存只能解决一种循环依赖Spring解决循环依赖靠的是DefaultSingletonBeanRegistry里的三级缓存一级缓存存放最终创建完成的单例对象二级缓存存放提前暴露的早期引用三级缓存存放ObjectFactory用来生成早期引用。这种设计能解决的是单例Bean 字段注入/Setter注入的循环依赖。原理简单说A创建时发现自己需要B但B还没创建完于是先把自己暴露到三级缓存创建B时B又发现自己需要A这次可以从三级缓存拿到A的早期引用注入进去B创建完成以后A再从缓存里拿到完整的B完成自己的后续流程。但构造器注入的循环依赖是三级缓存也无法覆盖的A的构造器需要BB的构造器需要A。两个对象都还在构造过程中都还没形成可暴露的早期引用谁也没法先注入谁。所以Spring的常规方案在这里直接判定失败抛出BeanCurrentlyInCreationException。4.2 Lazy如何从外部打断依赖链条Lazy做的事其实特别取巧既然两个正在构造的对象没法互相注入那我让其中一个先别急着要真实对象先拿一个代理顶上去。代理不触发真正的创建只需要在A的构造器参数位置上垫一下A就可以完成构造B随后正常创建完成后续A调用B的方法时代理才去获取B的真实实例。这种方案并不是去修改Spring循环依赖的处理逻辑而是从根本上改变了依赖的语义——A对B的强依赖变成了延迟依赖所以它能处理构造器循环依赖也能处理某些单例AOP代理场景下的复杂循环。代价是你的代码语义里多了一层代理调试时需要意识到某些对象不是真身。4.3 一个实战中的有趣现象Lazy解决启动失败但可能带来首次调用慢我在某项目里见过这种场景两个核心服务A和B互相调用来回传数据构造器循环依赖导致项目一启动就报错。后来有人把所有构造器参数都标了Lazy启动确实不再报错上线后的现象却是每次系统冷启动后第一个经过A→B链路的请求响应时间会突然飙高。原因不复杂启动时延迟创建了但第一个触发调用的线程要承担创建B的全部开销包括初始化连接、加载配置、建立线程池等。如果是高并发场景多个请求同时进来虽然getBean保证最终只创建一个实例但后续请求需要排队等待创建完成瞬时RT就会被拉高。所以我的建议是Lazy适合用来解决启动必须快和构造器循环依赖的问题但如果你能通过重构消除循环依赖那才是更健康的方向。Lazy更像是一个妥协性的补丁不该成为架构上循环依赖的遮羞布。5. 容易被忽略的几个坑和我的实践建议5.1 坑一Configuration类加Lazy里面的Bean方法可不会跟着懒前面提到过这里单独展开说。很多人把Lazy加到Configuration类上期待整个配置类里的Bean全部延迟创建。结果发现启动还是一样慢或者某个Bean依旧被提前实例化。原因在于Configuration类本身是一个Bean类上的Lazy只会让这个配置类实例延迟创建。但配置类里的Bean方法在容器启动解析阶段就会被读取和注册成BeanDefinition那些方法定义出的Bean是否懒加载取决于方法上有没有独立的Lazy标注。换句话说类级Lazy不会传播到方法级。如果想让每个Bean方法都懒加载要么每个方法都标要么换用全局配置方案。5.2 坑二代理对象的类型匹配问题Lazy注入的是代理对象这就涉及代理模式的选择。Spring会根据目标类是否实现接口来自动选择JDK动态代理还是CGLIB代理。如果你是面向接口编程默认情况下代理类实现的是接口OrderService里声明的字段类型如果是实现类而不是接口就可能出现类型不匹配。一个非常典型的现象Autowired Lazy private UserServiceImpl userService;如果UserServiceImpl实现了UserService接口Spring可能生成的是基于接口的JDK代理此时注入时类型检查会失败。这种场景下要么把字段类型改成接口要么主动指定使用CGLIB代理要么干脆用Resource注入而不是Autowired。5.3 坑三序列化场景下的懒加载代理如果Bean依赖了Lazy代理并且整个对象需要被序列化比如放到Redis缓存、写入消息队列、或经过网络传输代理对象不一定能按照预期完成序列化。序列化得到的可能是代理类的结构而不是真实对象的字段。等到反序列化回来你拿到的对象可能根本不能调用业务方法。项目里如果遇到缓存里的对象反序列化后方法调用异常排查方向可以加上一条这个对象的属性上是不是有Lazy。解决办法通常是让被注入的代理实现Serializable或者干脆在序列化前做一次代理穿透把真实对象取出来。5.4 坑四全局lazy-init开启后的连带影响Spring Boot提供了一个全局开关spring.main.lazy-initializationtrue。打开之后容器里所有Bean都会默认懒加载启动时间会大幅缩短。听起来很美好但实践中会带来一系列微妙影响ApplicationRunner和CommandLineRunner在容器刷新后会被执行如果它们依赖了某些Bean这些Bean实际上会在执行时才被创建启动完成的快可能只是假象。定时任务、消息监听器这类组件如果被延迟创建可能导致业务事件错过监听窗口。一些在启动阶段就要完成的校验逻辑比如数据库连通性检查、配置正确性校验会被推迟到第一次调用时问题暴露时间变晚。所以我一般只在两种情况下全局开启一是本地开发想加速联调二是某些只负责编译和打包的测试环境。生产环境不要用全局开关除非你非常清楚所有Bean的生命周期和行为。5.5 我的建议什么时候真的该用Lazy说下我在实际项目里的取舍标准。第一优先启动速度优化。如果容器启动时间已经明显影响开发效率和发布体验优先排查那些创建成本高但启动时用不到的基础设施组件对这些Bean方法加Lazy。第二优先构造器循环依赖。确实存在且无法快速重构时在相应注入参数上使用Lazy同时确保构造器里不调用依赖对象的方法。第三优先按需创建重资源对象。比如一个只能单例运行的模型引擎、一个需要加载超大配置文件的组件用懒加载能明显改善冷启动体验。不建议把Lazy当成默认选项到处加。Spring的控制反转里有一层重要的语义是容器管理完整性当你能从ApplicationContext里随时感知到Bean的存在时很多问题会在启动阶段暴露而懒加载会把问题推迟到运行时排查成本变高。懒加载是一种优化手段应当服务于明确的目标而不是一种随手的习惯。如果要测试某个Bean到底是否被提前创建最直接的办法是在它的构造方法里打日志然后对比启动日志和第一次调用日志的时间差。这个办法虽然土但在各种代理和间接引用混在一起的时候反而最有效。6. 最后说一点Lazy与优雅的延迟之间还差一个ObjectProvider很多朋友读到这可能已经开始在项目里寻找可以加Lazy的位置了。但在收尾之前我想再补一个相关但常被混淆的备选方案ObjectProvider。Spring提供的ObjectProviderT同样可以做到延迟获取Bean而且比Lazy在可编程性上更灵活。你可以在字段里注入一个ObjectProvider然后随时调用它的getIfAvailable()或getIfUnique()来决定何时真正取对象。它不用代理机制代码语义更明确还能处理可选的依赖多个候选Bean等复杂场景。我个人的分工习惯是在纯粹的关系注入场景里用Lazy处理这个依赖不要影响创建顺序在需要运行时决策、或者依赖可能不存在的场景里用ObjectProvider。二者不是替代关系是不同层次的两把工具。我之前的一个项目里一个组件在启动时完全不依赖外部服务运行后偶尔才触发一次外部调用。那时候我用Lazy标记了外部客户端Bean启动时间明显缩短而且第一次触发调用时自动完成初始化业务无感知。后来需求变化这个外部依赖变成可选配置项我就顺手换成了ObjectProvider代码反而比之前更直白。这个经历也印证了一件事工具本身没有绝对的好坏关键是搞清楚每个工具背后的机制然后在合适的场景用合适的方案。Spring里类似Lazy这样看似简单、实际很微妙的注解还有很多搞懂一个往往能触类旁通理解另外一大片AOP和生命周期相关的问题。希望这篇笔记能让你对Lazy的认知从听说过提升到用得准。