news 2026/10/13 11:58:24

Spring @Lazy注解深度解析:从源码到实战,避开延迟初始化的那些坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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到LazyInitTargetSource

Spring在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方法时,可以单独给这个方法加@Lazy:

@Configuration 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/@Service)Bean本身容器启动时不预实例化,首次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的构造器需要B,B的构造器需要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-initialization=true。打开之后,容器里所有Bean都会默认懒加载,启动时间会大幅缩短。听起来很美好,但实践中会带来一系列微妙影响:

  • ApplicationRunner和CommandLineRunner在容器刷新后会被执行,如果它们依赖了某些Bean,这些Bean实际上会在执行时才被创建,启动完成的"快"可能只是假象。
  • 定时任务、消息监听器这类组件如果被延迟创建,可能导致业务事件错过监听窗口。
  • 一些在启动阶段就要完成的校验逻辑(比如数据库连通性检查、配置正确性校验)会被推迟到第一次调用时,问题暴露时间变晚。

所以我一般只在两种情况下全局开启:一是本地开发想加速联调,二是某些只负责编译和打包的测试环境。生产环境不要用全局开关,除非你非常清楚所有Bean的生命周期和行为。

5.5 我的建议:什么时候真的该用@Lazy

说下我在实际项目里的取舍标准。

第一优先:启动速度优化。如果容器启动时间已经明显影响开发效率和发布体验,优先排查那些创建成本高但启动时用不到的基础设施组件,对这些@Bean方法加@Lazy。

第二优先:构造器循环依赖。确实存在且无法快速重构时,在相应注入参数上使用@Lazy,同时确保构造器里不调用依赖对象的方法。

第三优先:按需创建重资源对象。比如一个只能单例运行的模型引擎、一个需要加载超大配置文件的组件,用懒加载能明显改善冷启动体验。

不建议把@Lazy当成默认选项到处加。Spring的"控制反转"里有一层重要的语义是"容器管理完整性",当你能从ApplicationContext里随时感知到Bean的存在时,很多问题会在启动阶段暴露,而懒加载会把问题推迟到运行时,排查成本变高。懒加载是一种优化手段,应当服务于明确的目标,而不是一种随手的习惯。

如果要测试某个Bean到底是否被提前创建,最直接的办法是在它的构造方法里打日志,然后对比启动日志和第一次调用日志的时间差。这个办法虽然土,但在各种代理和间接引用混在一起的时候反而最有效。

6. 最后说一点:@Lazy与"优雅的延迟"之间还差一个ObjectProvider

很多朋友读到这,可能已经开始在项目里寻找可以加@Lazy的位置了。但在收尾之前,我想再补一个相关但常被混淆的备选方案:ObjectProvider。

Spring提供的ObjectProvider<T>同样可以做到延迟获取Bean,而且比@Lazy在可编程性上更灵活。你可以在字段里注入一个ObjectProvider,然后随时调用它的getIfAvailable()或getIfUnique()来决定何时真正取对象。它不用代理机制,代码语义更明确,还能处理"可选的依赖""多个候选Bean"等复杂场景。

我个人的分工习惯是:在纯粹的关系注入场景里,用@Lazy处理"这个依赖不要影响创建顺序";在需要运行时决策、或者依赖可能不存在的场景里,用ObjectProvider。二者不是替代关系,是不同层次的两把工具。

我之前的一个项目里,一个组件在启动时完全不依赖外部服务,运行后偶尔才触发一次外部调用。那时候我用@Lazy标记了外部客户端Bean,启动时间明显缩短,而且第一次触发调用时自动完成初始化,业务无感知。后来需求变化,这个外部依赖变成可选配置项,我就顺手换成了ObjectProvider,代码反而比之前更直白。这个经历也印证了一件事:工具本身没有绝对的好坏,关键是搞清楚每个工具背后的机制,然后在合适的场景用合适的方案。

Spring里类似@Lazy这样"看似简单、实际很微妙"的注解还有很多,搞懂一个,往往能触类旁通理解另外一大片AOP和生命周期相关的问题。希望这篇笔记能让你对@Lazy的认知,从"听说过"提升到"用得准"。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 11:58:03

分层图最短路与0-1BFS:钥匙锁门类路径规划算法解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 11:56:03

OpenClaw实战:AI浏览器智能体如何替代传统爬虫与RPA

看到标题里的“龙虾”点进来的朋友&#xff0c;估计都对 OpenClaw 有点耳闻。圈里习惯管它叫“龙虾”&#xff0c;倒不是因为它跟海鲜有什么关系&#xff0c;而是这个项目的标志本来长得就像一对钳子&#xff0c;作者的解释也很直接&#xff1a;Claw 就是“钳子”&#xff0c;加…

作者头像 李华
网站建设 2026/10/13 11:55:48

Ant-design Table固定列行高错位:根因分析与三种解决方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 11:55:23

Restorator 2009汉化PE文件:从字符串表到对话框的实操指南

简介&#xff1a;Restorator 2009 是一款面向软件本地化和汉化的专业工具&#xff0c;适合需要汉化 Windows 程序的个人用户、翻译人员和中小型本地化团队。它支持直接打开 EXE、DLL、RES、RC 等常见资源文件&#xff0c;查看并修改字符串、菜单、对话框、图标、位图等资源&…

作者头像 李华