资讯动态

Spring多实例注入从原理到实战:Bean作用域、ObjectProvider与循环依赖排查指南

发布时间:2026/9/9 11:07:57 来源:尧图企业网站定制
在Spring项目里Spring多实例注入是我见过最能暴露容器理解深浅的一类问题。它的外在表现多种多样有时候是启动时报NoUniqueBeanDefinitionException提示“expected single matching bean but found 2”有时候是明明配置了好几个实现类注入到List里的顺序却和预期完全不一致还有时候是费劲把Bean声明成prototype但注入到单例对象之后每次拿到的居然还是同一个对象。这些现象背后的原因都不复杂但如果不把“容器里到底管理了多少个实例、每个实例什么时候创建、注入时按什么规则挑选”这三件事想清楚很容易在项目里反复踩坑。这篇文章我把多实例注入从概念到落地、从原理到排查串起来讲一遍适合正在写Spring Boot业务代码、或者准备Spring面试时想把这个点彻底弄明白的读者。1. 从“一个接口注入多个实现”说起多实例注入到底在解决什么问题多实例注入这个说法有点绕因为很多初学者会把“多实例”直接理解为Scope(prototype)。但在真实项目里讨论更多的其实是另一类场景容器中同一个接口或同一个父类下面存在多个Bean定义需要在注入时把它们全部拿到或者按条件精确拿到其中一个。这两类问题经常混在一起我建议先把它拆开。1.1 最常见的业务场景一组可扩展的策略实现假设我正在做一个订单处理系统支付渠道有微信、支付宝、银行卡三种每个渠道的处理逻辑不同。常规设计是定义接口public interface OrderHandler { String channel(); void handle(Order order); }然后分别写三个实现类WechatPayHandler、AlipayHandler、BankCardHandler。如果不做任何配置就直接往Service里注入Autowired private OrderHandler orderHandler;启动时Spring会直接报错因为按照类型OrderHandler去容器里找候选Bean找到了三个它不知道你到底要哪个。这个场景就是典型的多实例注入冲突解决方式会在后面展开。但核心矛盾在这里已经出现了Spring的类型注入天生是“按类型找唯一候选”的而业务上往往希望“按条件挑选一个”或者“把全部候选都拿过来”。再比如消息处理场景不同消息类型对应不同处理器又比如对接第三方AI平台时同一个项目里可能同时接入了多家大模型供应商每一家的ChatClient都是同类型的不同Bean。这时候如果只有一个执行入口类最优雅的方式往往就是把所有客户端都注入到Map里按供应商名称去路由。这和订单处理器的需求本质上是同一类问题。1.2 注入目标的三种形态单对象注入、集合注入、延迟注入针对上面的场景Spring提供了三种完全不同的注入形态。单对象注入代码里只声明一个接口字段希望容器帮我决定使用哪个实现。这种形态要求容器能找到唯一候选或者通过Primary、Qualifier、Resource来缩小范围。集合注入代码里声明ListOrderHandler或MapString, OrderHandler希望把容器中所有匹配的实例都拿过来由业务代码自己做路由、遍历、排序。延迟注入声明ObjectProviderOrderHandler一开始不急着确定具体实例等到运行时再通过getIfAvailable、getIfUnique、orderedStream等方法动态决策。我看过很多项目不管什么场景都一律用字段Autowired硬注入等到出现NoUniqueBeanDefinitionException再临时加一个Qualifier这其实是不理解容器提供的三种形态导致的。正确做法是先想清楚业务到底需要哪一种如果是需要“全部处理器都执行”那就该注入集合如果是“启动时不确定、运行时才根据配置选择”那ObjectProvider才是更合适的表达。1.3 多实例不等于多例Bean先澄清概念这里必须把概念捋清楚否则后面看源码和面试都会懵。“多实例注入”这个词在英文社区通常有两种指代第一种容器中存在多个相同类型的Bean实例注入时存在多个候选。这是“多候选注入”问题和Bean是不是单例没有必然关系。第二种希望每次从容器获取时都得到一个新的对象对应Scope(prototype)即多例Bean。两者可以组合出现比如一个prototype类型的策略类有多个实现注入成List之后列表里的每个元素都是独立的、每次请求都会新建。但在日常业务代码里绝大多数“多实例注入”指的是第一种。弄清楚这一点之后再去看Spring的BeanFactory源码你会发现Spring处理这两种情况走的完全是两条路径集合注入走resolveMultipleBeans而原型作用域走getBean时的创建策略。2. 手工还是自动Spring把单例与原型的作用域差异藏在哪里理解了要解决的问题接下来就得看Spring的Bean作用域是怎么影响注入结果的。很多人在Scope上只是背概念实际写代码的时候经常出现“注入了但没用”的情况。2.1 singleton与prototype初始化时机和生命周期完全不同Spring默认的Bean作用域是singleton。在AnnotationConfigApplicationContext启动时非懒加载的单例Bean就会在容器刷新过程中被创建创建完成之后放进singletonObjects这个Map里后续所有地方注入到的都是同一个对象。prototype作用域则完全相反。声明为Scope(prototype)的Bean容器不会在启动阶段提前创建它也不会在销毁容器的时候帮你调用销毁方法。每次执行getBean或者某个注入点需要解析这个Bean时容器都会调用createBean重新创建一份。代码示例Configuration public class ScopeConfig { Bean Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) public TaskBuilder taskBuilder() { return new TaskBuilder(); } }这样配置之后每次从容器里拿TaskBuilder都是一个全新的对象。但注意如果你写了一个单例的TaskService在里面直接注入TaskBuilderService public class TaskService { Autowired private TaskBuilder taskBuilder; }那这个TaskBuilder虽然在容器里是prototype但真正注入到TaskService里只有一次。因为TaskService是单例它在初始化时只会解析一次依赖拿到第一个TaskBuilder实例后就存到自己的字段里了。后面无论TaskService被调用多少次你看到的都是同一个TaskBuilder。这是整个多实例注入话题里最容易踩的坑。2.2 Lazy与ObjectProvider解决prototype被单例“固化”的问题既然单例注入原型会被固化那怎么解决常见的方案有三种。第一种是直接注入ApplicationContext然后在方法里主动getBean。这个方案最简单但是把代码和容器耦合在一起单元测试很麻烦我不推荐在正常业务代码里大量使用。第二种是使用ObjectProviderService public class TaskService { private final ObjectProviderTaskBuilder taskBuilderProvider; public TaskService(ObjectProviderTaskBuilder taskBuilderProvider) { this.taskBuilderProvider taskBuilderProvider; } public void run() { TaskBuilder builder taskBuilderProvider.getObject(); // 每次都是新实例 } }ObjectProvider本质上是延迟到每次调用getObject时才去容器里解析所以即使TaskService是单例也能保证每次执行run方法时拿到的是一个新的TaskBuilder。第三种是Lookup方法注入这个放到第三章详细讲。一句话概括Lookup让Spring在运行时通过CGLIB生成子类代理重写你标注的方法每次调用都走一次getBean逻辑。它比ObjectProvider更隐蔽但用的好也很干净。还有一个小技巧在注入依赖时加Lazy可以延迟依赖解析。遇到循环依赖或者一些启动阶段耗时较长的配置类时Lazy能绕过“初始化时就要拿到完整依赖”的约束但它解决的核心问题不是“每次拿新实例”而是“启动时先不拿”。所以在多实例注入场景里ObjectProvider和Lookup更对路。2.3 request/session作用域的多实例语义Spring还提供request、session、application等Web作用域。这些作用域下的Bean严格来说也是“一个请求/会话对应一个实例”。如果你在单例Bean里注入一个request作用域的Bean同样会遇到固化问题。但Spring为我们提供了代理模式Bean Scope(value WebApplicationContext.SCOPE_REQUEST, proxyMode ScopedProxyMode.TARGET_CLASS) public UserContext userContext() { return new UserContext(); }加上proxyMode之后注入到单例Bean里的其实是一个代理对象每次调用方法时代理会解析当前请求对应的真实实例。这和prototype的“每次重新创建”不一样但都说明了一个共同点作用域和多实例注入经常组合出现单例持有非单例依赖时必须借助间接层来延迟解析。3. 多实例注入的六种落地姿势与适用边界说完了作用域回到最让人头疼的“同类型多个候选”问题。这里我整理了六种常用姿势按实战中使用频率排序。3.1 Autowired Qualifier按名字精确命中最简单直接的方式就是给Bean取名字然后通过Qualifier指定。Service(wechatPayHandler) public class WechatPayHandler implements OrderHandler { // ... }Autowired Qualifier(wechatPayHandler) private OrderHandler orderHandler;这里Qualifier的值默认就是Bean名称。如果Bean是通过Bean方法定义的方法名就是Bean名如果通过Service、Component定义默认是类名首字母小写。所以保持Service注解里的显式名称和Qualifier里的字符串一致可以减少很多低级错误。还有一个容易忽略的细节Spring在Autowired按类型匹配到多个候选时会有一个降级规则——如果字段名恰好和某个候选Bean的名称一致就按字段名选中它。比如Autowired private OrderHandler alipayHandler;如果容器里刚好有名为alipayHandler的Bean就不会报错。这种写法虽然能跑但可读性和安全性都很差我建议不要依赖这个隐式规则显式用Qualifier更清晰。3.2 List 注入依赖容器顺序策略模式的标准解法当需要一次拿到所有同类Bean时直接注入List是很自然的想法Service public class OrderDispatchService { private final ListOrderHandler handlers; public OrderDispatchService(ListOrderHandler handlers) { this.handlers handlers; } }Spring会解析泛型类型OrderHandler然后把容器中所有匹配该类型的Bean放入List。顺序由Order注解或Ordered接口决定数值越小越靠前。比如Service Order(1) public class WechatPayHandler implements OrderHandler { ... } Service Order(2) public class AlipayHandler implements OrderHandler { ... }这样注入的handlers列表里微信处理器会排在支付宝前面。这个特性非常适合责任链模式一组校验器、一组消息转换器、一组过滤器按顺序执行新增实现类时只需要加一个Component和Order不需要改业务代码。有一点需要提醒ListT注入时Spring会解析字段或构造函数参数的ResolvableType如果类型没有泛型信息比如ListObject是拿不到目标类型的。另外如果某个OrderHandler实现类依赖了未初始化的Bean启动时整个应用都会失败而不是等你用到它才报错。3.3 MapString, T注入Bean名做Key适合编号路由Map注入和List注入的底层逻辑几乎一样区别是Map的Key是Bean名称Service public class OrderDispatchService { private final MapString, OrderHandler handlerMap; public OrderDispatchService(MapString, OrderHandler handlerMap) { this.handlerMap handlerMap; } }注入进来后可以根据业务参数直接路由String channel order.getChannel(); OrderHandler handler handlerMap.get(channel PayHandler);这种写法在需要“按名称动态查找”的场景非常顺手尤其是对接多个外部渠道的时候。配合配置中心甚至可以做到不改代码、只改配置就能切换使用的渠道实例。需要注意的是Map的Key一定是容器内的BeanName如果某个实现类没有显式命名那么Key就是默认生成的类名。你可以通过Service(wechatPayHandler)这种方式显式固定名称避免类重构后路由Key跟着变。3.4 ObjectProvider 延迟获取动态决策前面讲作用域时已经提到ObjectProvider它其实是Spring提供的一个通用延迟解析入口。除了解决prototype固化问题它还能解决“候选Bean不存在时不要报错”的问题。Service public class ReportService { private final ObjectProviderReportSender senderProvider; public ReportService(ObjectProviderReportSender senderProvider) { this.senderProvider senderProvider; } public void send(Report report) { ReportSender sender senderProvider.getIfAvailable(); if (sender ! null) { sender.send(report); } } }这段代码里即使容器中没有任何ReportSender类型的Bean启动也不会报错因为ObjectProvider允许延迟解析。getIfAvailable()在没有任何候选时返回nullgetIfUnique()在候选唯一时返回该Bean、有多个候选时返回nullorderedStream()则会把所有候选按Order排序后返回Stream。Spring Boot的自动配置大量使用ObjectProvider。比如你在Configuration类里写Bean public RestTemplate restTemplate(ObjectProviderRestTemplateCustomizer customizers) { RestTemplate restTemplate new RestTemplate(); customizers.orderedStream().forEach(customizer - customizer.customize(restTemplate)); return restTemplate; }这样即使没有RestTemplateCustomizer实现应用也能正常启动。这种写法正是多实例注入场景里“可选的、可扩展的依赖集合”的最佳实践。3.5 Lookup每次调用都拿新实例Lookup是方法级注解Spring通过CGLIB生成当前Bean的子类代理并覆盖被标注的方法使每次调用都走容器getBean。Component public abstract class TaskService { public void run() { TaskBuilder builder getTaskBuilder(); System.out.println(builder); } Lookup public abstract TaskBuilder getTaskBuilder(); }如果类不是抽象的接口方法也可以写成普通方法Component public class TaskService { public void run() { TaskBuilder builder getTaskBuilder(); } Lookup public TaskBuilder getTaskBuilder() { return null; } }方法的返回类型必须是要获取的Bean类型方法体内容无所谓因为Spring会直接覆盖掉。使用Lookup有两个前提类不能是final方法不能是private否则CGLIB无法生成子类或无法代理。在内部调用this.getTaskBuilder()时this是代理对象所以能正常生效但如果某个方法通过普通Java方式把this传递出去外部调用方拿到的可能就不是代理对象了这一点在自调用场景里要格外注意。对比一下如果只有一个类需要prototype的依赖Lookup比ObjectProvider更隐蔽、更直观但它必须依赖Spring的CGLIB代理字节码增强对单元测试不太友好。3.6 构造器注入与Primary、Order的配合最后说说日常写代码最推荐的方式——构造器注入。它能让依赖关系显式化也方便单测时手动传参。当构造函数参数存在多个同类型候选时需要配合Primary或QualifierService public class OrderService { private final OrderHandler orderHandler; public OrderService(Qualifier(wechatPayHandler) OrderHandler orderHandler) { this.orderHandler orderHandler; } }Primary用于标注多个候选中的“默认首选”Service Primary public class DefaultOrderHandler implements OrderHandler { ... }这样其他位置直接Autowired OrderHandler时会优先注入DefaultOrderHandler。但要注意Primary只是指定默认项不会禁止其他候选存在。如果几处注入点想要不同实现还是要用Qualifier显式指定。而且当接口同时有多个实现且只有一个标了Primary时Qualifier显式指定优先级更高Spring会优先按Qualifier里指定的名称去找。下面是几种方式的综合对比注入方式适用场景动态性易用性主要风险Qualifier启动时就知道要哪个实现低高字符串拼错重构时名称不一致ListT需要遍历或按序执行全部实现中高顺序处理不当启动时全部初始化MapString, T按名称动态路由中高Key依赖Bean名重构时易失效ObjectProviderT可选依赖、延迟决策高中过度使用会让依赖关系不直观Lookup单例中每次获取原型Bean高中CGLIB代理限制Primary设置默认实现低高容易被忽略造成“为什么不是我这个”的困惑4. 多数据源与多监听器场景下的真实踩坑记录理论讲完来看几个我在项目里实际遇到过的场景。这些场景的共同点是代码本身看起来没毛病但启动时总是报各种“多实例”相关的错误。4.1 多个DataSource时Spring Boot自动配置为什么会失效多数据源是最典型的多实例注入冲突。当你定义了两个DataSourceBean后如果直接注入DataSourceAutowired private DataSource dataSource;Spring会一脸懵容器里有两个DataSource你要哪个于是抛出NoUniqueBeanDefinitionException。更隐蔽的是你给其中一个DataSource标了Primary之后确实不报错了但Spring Boot的DataSourceAutoConfiguration会因为你已经存在DataSourceBean而不生效这时候JdbcTemplate、事务管理器等组件都需要手动配置。很多初学者以为多数据源就是加两个Bean方法然后各注各的实际上还要考虑PlatformTransactionManager、SqlSessionFactory、JdbcTemplate等一系列基础组件全部要显式声明。我当时处理多数据源时踩过的坑是事务管理器只注入了一个结果两个数据源的操作都走了同一个事务管理器某个库的写操作回滚了另一个库的数据却已经提交。后来拆成了DataSourceATransactionManager和DataSourceBTransactionManager并在Transactional(transactionManagerA)中显式指定才算稳定下来。这类问题的排查往往不是“注入报错”而是“能启动但行为不对”所以在设计阶段就要想好每个注入点到底要用哪个实例。4.2 多个RedisTemplate/消息监听容器Bean命名冲突的排查思路Spring Boot Redis Stream的消息拉取本身需要配置StreamMessageListenerContainer。如果你项目中同时使用了普通Redis缓存和Redis Stream那么容器里可能会有两个RedisTemplate甚至多个监听容器。我遇到的情况是在RedisConfig里定义了StringRedisTemplate redisTemplate和一个专门处理Stream的RedisTemplateString, Object streamRedisTemplate结果在业务类里注入Autowired private RedisTemplateString, Object redisTemplate;启动时Spring警告或者直接报错因为这些模板的类型擦除后都是RedisTemplate属于同类型多候选。解决办法是在注入点加Qualifier(streamRedisTemplate)或者干脆把每个RedisTemplate定义成不同的泛型子类从类型上区分开。对于RedisMessageListenerContainer多个监听容器也需要显式命名。更常见的问题是配置了一个容器之后往里面注册多个监听器发现只有一个监听器生效。排查半天最后发现是注册的时候用了同一个消息监听适配器后注册的覆盖了前面的。正确做法是给每个Listener单独创建MessageListenerAdapter或者为不同的消费组创建不同的StreamMessageListenerContainer然后用Qualifier注入到对应的地方。4.3 多个SecurityFilterChain安全框架里的多实例注入Spring Security 6之后的SecurityFilterChain也是一个很好的多实例注入案例。应用里可以定义多个SecurityFilterChainBean每个Bean通过securityMatcher()匹配不同路径Spring会把它们注入到FilterChainProxy中统一调度。Bean Order(1) public SecurityFilterChain adminFilterChain(HttpSecurity http) throws Exception { http.securityMatcher(/admin/**) .authorizeHttpRequests(auth - auth.anyRequest().hasRole(ADMIN)); return http.build(); } Bean Order(2) public SecurityFilterChain appFilterChain(HttpSecurity http) throws Exception { http.securityMatcher(/api/**) .authorizeHttpRequests(auth - auth.anyRequest().authenticated()); return http.build(); }这里Order不仅决定了过滤链的匹配顺序也决定了多个SecurityFilterChain实例注入FilterChainProxy时的顺序。如果两个过滤链的securityMatcher存在重叠前面的Order(1)会优先响应。这个例子说明多实例注入不只是简单的“选一个或全部拿”有时候还涉及排序和匹配规则Spring通过Order注解统一解决了“多实例时的顺序”问题。4.4 多实例注入与Spring AI多模型客户端最近做AI应用时我遇到了一个很有意思的多实例注入场景同一个项目要对接多个大模型供应商每个供应商都需要自己的ChatClient。最开始的写法是给每个客户端定义一个不同的类型结果代码里全是类型转换和适配器非常痛苦。后来改成直接注入MapString, ChatClientService public class AiRoutingService { private final MapString, ChatClient chatClientMap; public AiRoutingService(MapString, ChatClient chatClientMap) { this.chatClientMap chatClientMap; } public String chat(String provider, String message) { ChatClient client chatClientMap.get(provider); return client.prompt().user(message).call().content(); } }这样每个供应商的ChatClientBean命名就是Map的Key路由逻辑和业务上下文天然对齐。Spring AI的ChatClient.Builder本身也是典型的多实例配置对象每个供应商的BaseUrl、ApiKey、模型名都组装在自己的Builder里。如果你也想用这种模式记得每个Bean方法名要起成和路由Key一致比如deepseekChatClient、openaiChatClient否则Map里的Key会变得不可预测。4.5 排查思路NoUniqueBeanDefinitionException处理链路遇到多实例注入报错时不要急着乱加注解。我的排查顺序是这样的先看异常里列出的候选Bean名称Spring会把找到的所有候选都打印出来。打开ApplicationContext用getBeansOfType接口确认容器中到底有哪些实例。MapString, OrderHandler beans context.getBeansOfType(OrderHandler.class); beans.forEach((name, bean) - System.out.println(name));检查注入点是否存在Primary、Qualifier、Resource(name ...)等限定条件。如果注入的是集合检查每个候选类的Order和Ordered实现。最后检查自动配置类是否悄悄注册了一个你没注意到的默认Bean比如Spring Boot的RedisAutoConfiguration会注册默认的RedisTemplate。5. Spring三级缓存与多实例注入的关联为什么原型Bean循环依赖救不了你Spring三级缓存原理是面试高频题它和多实例注入有什么关系答案是关系非常密切。多实例注入经常导致循环依赖而循环依赖能不能被解决完全取决于Bean是不是单例、是不是被框架提前缓存了。5.1 三级缓存机制三级缓存分别存什么多实例注入的Bean进不进缓存Spring内部维护了三个Map通常被称为三级缓存一级缓存singletonObjects存放完整创建好的单例Bean。二级缓存earlySingletonObjects存放早期暴露的Bean引用这个Bean还在创建过程中依赖它的其他Bean可以先拿到这个未完成初始化的对象。三级缓存singletonFactories存放ObjectFactory是生成早期Bean引用的工厂。创建单例Bean时Spring在实例化之后、属性填充之前就会把该Bean的工厂放入三级缓存。这样如果A依赖B、B依赖AB在创建时发现需要A就能从三级缓存中找到A的工厂提前拿到A的早期引用。等A完整创建完成后再替换成真正的A实例。但要注意只有单例Bean才会进入三级缓存。多实例注入场景里如果你把Bean声明成prototype它根本不会进入singletonFactories因为Spring不打算缓存它也不打算提前暴露一个还没创建完的原型对象给别的Bean引用。5.2 原型Bean为什么无法解决循环依赖假设A是prototypeB是单例A依赖BB依赖A。当容器尝试创建A时因为A是prototype直接新建对象开始填充B属性B是单例创建B时发现它依赖A于是又去创建新的A这次创建A再次需要B……于是无限循环最终抛出BeanCurrentlyInCreationException。结果就是循环依赖想通过把其中一个Bean改成原型来解决只会让问题变得更糟。单例Bean能解决循环依赖靠的是“创建过程中先放到三级缓存允许别人提前引用”而原型Bean压根没有“提前暴露”的机制创建多少次都是全新对象永远没办法把“还没创建完的自己”暴露给依赖方。很多人问我“那我把所有的Bean都改成原型是不是就不会循环依赖了”答案是否定的。改为原型后依赖关系反而变成了无休止的创建链路启动直接失败。更合理的做法是重构依赖或者延迟其中一个依赖的获取。5.3 ObjectProvider如何作为循环依赖的“逃生门”前面提到ObjectProvider可以延迟解析这正好可以作为一个合理方案来打破循环依赖。比如B的构造函数依赖A但A又依赖BComponent public class A { private final B b; public A(B b) { this.b b; } } Component public class B { private final ObjectProviderA aProvider; public B(ObjectProviderA aProvider) { this.aProvider aProvider; } }B创建时虽然声明了依赖A但通过ObjectProvider变成延迟获取B的构造函数不会立刻去拿A。A继续创建等到A完整准备好之后再调用aProvider.getIfAvailable()时就能拿到A了。这种做法在Spring Boot自动配置里也很常见它把“强依赖”变成了“弱依赖”避免了某些创建顺序上的问题。但我要说一句实话ObjectProvider并不是专门用来解决循环依赖的它的主要价值是延迟和可选。如果你的依赖关系里出现了循环优先考虑的是重构而不是用延迟加载来遮遮掩掩。ObjectProvider可以作为临时方案但长期看循环依赖本身就是一个设计信号说明类之间的职责划分可能出了问题。5.4 手写Spring容器时为什么多实例注入容易踩坑市面上有不少“手写Spring”的教程我曾经也尝试过。自己写一个简化版IoC容器时最容易翻车的地方就在多实例注入。因为手写容器往往只是维护一个MapString, Object singletonPool注册Bean时遇到接口就不知道该怎么处理。你没有BeanDefinition来记录每个Bean的scope、依赖、初始化顺序也没有ResolvableType来判断一个注入目标是OrderHandler还是ListOrderHandler更不知道Qualifier的含义。于是当你实现了Autowired的基本逻辑后只要遇到两个同类型Bean就束手无策。真正要支持多实例注入必须把三件事做扎实注册阶段记录BeanName和类型支持同类型多个定义。解析依赖时先按类型过滤候选再按限定符筛选。注入集合时需要用字段的泛型类型去匹配所有候选而不是只匹配字段本身的原始类型。手写一次Spring你会对官方容器处理多实例注入的代码有完全不同的认识。DefaultListableBeanFactory里那上百行的doResolveDependency逻辑就是在处理这些边界情况。6. 排查多实例注入问题的实操清单最后分享一套我自己排查多实例注入问题的实操清单都是可以直接落地的动作。6.1 启动前如何判断会不会冲突代码写完后不确定会不会冲突可以在启动类里临时打印所有候选Bean名SpringBootApplication public class DemoApplication { public static void main(String[] args) { ConfigurableApplicationContext context SpringApplication.run(DemoApplication.class, args); String[] names context.getBeanNamesForType(OrderHandler.class); Arrays.stream(names).forEach(System.out::println); } }getBeanNamesForType会返回所有匹配类型的BeanName比getBeansOfType更轻量。看到输出后你就能确定容器里到底注册了几个实例。如果启动日志里没有报错但你怀疑顺序不对也可以打印每个Bean的顺序配合Order调整。还可以用Spring Boot Actuator的/actuator/beans端点查看Bean定义和Scope这个在生产环境排错时很有用但要注意生产环境的安全配置。6.2 几个关键断点位置如果你打算深入排查Spring的注入逻辑下面几个方法值得打断点DefaultListableBeanFactory#doResolveDependency所有依赖解析的公共入口能看到当前注入点类型、候选名称和限定条件。DefaultListableBeanFactory#findAutowireCandidates按类型查找候选Bean的核心方法能清楚看到为什么找到了多个候选以及Qualifier是怎么过滤的。DefaultListableBeanFactory#resolveMultipleBeans处理List、Map、ObjectProvider等集合注入的地方。在doResolveDependency里你可以看到Spring先处理ObjectProvider、List、Map这些“多值”类型再处理普通对象依赖。这也是为什么ListOrderHandler注入永远不会报NoUniqueBeanDefinitionException因为Spring走的是完全不同的分支。6.3 常见报错对照表异常信息典型原因处理方式NoUniqueBeanDefinitionException按类型找到多个候选没有限定条件加Qualifier/Primary/Resource(name...)或改成集合注入BeanCurrentlyInCreationException存在循环依赖且其中Bean不是单例或无法提前暴露重构依赖或使用ObjectProvider/Lazy延迟获取NoSuchBeanDefinitionException找不到目标类型的Bean检查Component扫描路径、Bean方法是否执行、Scope是否正确BeanNotOfRequiredTypeExceptionLookup方法返回类型和实际Bean类型不一致或CGLIB代理失效检查返回类型、类是否final、方法是否privateUnsatisfiedDependencyException构造函数注入时某个参数无法解析追查Cause定位到具体依赖字段按前几类问题处理6.4 我个人的几条硬性原则被多实例注入问题折磨过几次之后我给自己定了几条硬性原则。能不用ApplicationContext就不用。直接getBean虽然快但把业务代码绑死在Spring容器上后面换框架、做单测都会痛苦。能用构造器注入就不用字段注入构造器能把依赖关系变得显式IDE也能帮你检查。同一类实例需要动态路由时优先选择MapString, T注入BeanName做Key配合配置中心最灵活。需要“可选、可扩展”的依赖时用ObjectProvider.orderedStream()这是Spring Boot自动配置的标准做法也是最贴近框架设计意图的写法。最后一条也是最重要的一条如果发现项目里大量使用Qualifier时这往往是一个设计信号说明你正在一个类里组装太多职责不同的实现。这时候可以停下来想想是不是应该把路由逻辑抽出到专门的Router或者StrategyContext里让每个类的依赖关系更简单。多实例注入本身不是问题问题是你有没有想清楚每个实例在系统里的位置和生命周期。想清楚了Spring这套机制其实非常顺手。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价