Spring Bean从注册到注入:生命周期、作用域与常见问题详解 搞过Java开发的朋友大概都有过这样的经历第一次看到Spring的时候满屏的Autowired和Service代码好像“莫名其妙”就工作起来了但你真要问一句“这个Bean到底是怎么被创建的它又是什么时候被塞进来的”很多人会愣一下。这篇文章我想把这堆事儿彻底捋一捋。标题叫“从怎么交给容器到怎么被注入使用”听起来范围有点大但实际上核心就两个问题第一对象是怎么变成Spring容器里的Bean的第二调用方是怎么把这个Bean拿过来用的。中间牵涉到的生命周期、作用域、循环依赖、常见报错都是围绕这两件事展开的。这篇总结适合正在学Spring的初学者也适合用了几年Spring但没系统梳理过Bean机制的后端工程师。我会按“注册Bean → 容器管理Bean → 消费Bean → 排查问题”这条链路来讲争取把每一步背后的道理说透而不是只贴几个注解用法。1. Bean“入容器”的三种老方式从XML到全注解先说一个底层事实Spring容器本质上是个Map里面存的是Bean的名字和Bean的实例准确说是BeanDefinition实例是后话。所以“把Bean交给容器”这件事本质上是往这个Map里注册一条元数据。1.1 最早的XML配置时代零几年那会儿写Spring是清一色的XML。你要在applicationContext.xml里写bean iduserService classcom.example.service.UserService property nameuserDao refuserDao/ /bean bean iduserDao classcom.example.dao.UserDao/这种做法最大的问题是配置量和代码量几乎一样多。每加一个类就要去XML里加一段声明类多了以后XML文件动辄上千行。而且改配置还要重启非常痛苦。但也别急着嘲笑XMLXML的好处是它的配置和代码是“分离”的。你可以不动代码就更换Bean的实现类比如换一个数据源、换一个邮件发送器这在当时的插件化开发里很有价值。1.2 注解扫描半XML半注解的过渡期后来Spring 2.5引入了context:component-scan加上Component、Service、Repository这些注解情况好转了一大截。类上标一个注解容器启动的时候就自动扫描、自动注册不再需要一个个手写bean。但注入那边还没完全解放很多人还是用XML配property或者用Autowired配Qualifier混着来。我那会儿见过最乱的工程XML里既有bean又有context:component-scan扫出来的一些类又被XML里重复定义了一遍结果启动直接报BeanDefinitionOverrideException这类问题当时特别常见。1.3 JavaConfig最终形态Spring 3.0之后推荐方式变成了JavaConfig。完全用Java类来定义配置告别XMLConfiguration public class AppConfig { Bean public DataSource dataSource() { return new HikariDataSource(); } Bean public JdbcTemplate jdbcTemplate(DataSource dataSource) { return new JdbcTemplate(dataSource); } }加上类路径扫描注解结合Component系列注解整个Bean注册的过程就完全收敛到代码里了。现在Spring Boot时代SpringBootApplication本身就打包了Configuration、EnableAutoConfiguration和ComponentScan你什么都不用配只要类被扫描到它就自动进容器。我的建议是新项目直接走Spring Boot全注解路子别再用XML。除非你的系统有什么动态加载、热替换的极端需求否则XML带来的只有维护成本。当然你得理解Bean和Component的区别这是很多人的第一个认知死角。1.4 一张表看明白各种注册方式对比注册方式适用场景优点缺点XMLbean老系统、代码和配置分离需求不侵入代码配置量大、易出错Component系列自己写的业务类写法简单、扫描即用无法控制构造细节Bean第三方类、复杂初始化逻辑完整控制创建过程方法增多后会显臃肿Import/ImportSelector模块化装配、框架starter开发批量注册、按条件装配理解门槛略高2. 容器拿到Bean之后怎么管生命周期与作用域对象交给容器管理管理的核心是什么我认为是生命周期。你自己new出来的对象用完就扔GC自动处理但Spring容器里的Bean什么时候初始化、什么时候销毁都要按规矩走。2.1 默认作用域单例Spring里Bean默认是singleton也就是整个容器内同一个Bean名字只有一个实例。很多人写了几年代码都没想过一个问题为什么Spring要默认单例因为业务对象大多是无状态的。比如你的UserService里面只有方法逻辑和注入的依赖没有用户相关的状态字段。这种对象完全可以在多线程间共享多个请求进来大家用的都是同一个实例不会互相污染。单例的好处很明显省内存、省创建销毁开销、容器启动后热路径上几乎没有对象创建操作。但有个前提你这个Bean不能有可变实例字段。我见过有人图省事在单例Bean里塞了个HashMap当缓存做的时候没问题因为并发不高后来QPS一上去各种数据串味排查了半天才发现是这个Map被所有线程共享了。2.2 原型作用域与另类作用域如果你把Scope(prototype)标上那每次从容器里拿这个BeanSpring都会创建一个新实例。注意我说的是“拿的时候才创建”。你用Autowired注入一个原型Bean到一个单例Bean里那个原型Bean实际上只创建了一次因为注入动作只在单例初始化时发生一次真要用原型效果得用ObjectFactory或者Lookup方法。还有request、session、application这些Web作用域一般用在Web应用里。比如request作用域的Bean每个HTTP请求拿到的都是新实例请求结束销毁适合承载一次请求内的临时状态。实际项目中我的经验是大部分服务、仓库、工具类都是单例有状态的、或者特别吃内存的考虑原型Web请求相关的用request。别上来就原型原型意味着容器不做缓存每次获取都有创建开销并不一定划算。2.3 初始化与销毁三种姿势一个Bean创建完之后可能需要初始化一些资源容器关闭前可能要释放资源。Spring给了三条路// 方式一实现接口 public class MyBean implements InitializingBean, DisposableBean { Override public void afterPropertiesSet() throws Exception { // 初始化逻辑 } Override public void destroy() throws Exception { // 销毁逻辑 } } // 方式二init-method / destroy-method // 在Bean注解里指定 Bean(initMethod init, destroyMethod close) public MyBean myBean() { return new MyBean(); } // 方式三JSR-250注解使用最广泛 Component public class MyBean { PostConstruct public void init() { // 初始化逻辑 } PreDestroy public void shutdown() { // 销毁逻辑 } }三条路我建议优先用PostConstruct/PreDestroy因为它最贴近业务代码、可读性最好。实现InitializingBean接口会把你和Spring的API耦合起来initMethod又显得不够直白。这里有一个很容易被忽视的顺序问题构造方法执行 →PostConstruct→InitializingBean.afterPropertiesSet→init-method。如果你在三处都写了逻辑它们的执行顺序是固定的。实际中几乎不会有人同时用三种但知道这个顺序排查“为什么初始化日志顺序不对”时就很管用。2.4 BeanPostProcessor容器留给你的后门生命周期里还有一类东西叫BeanPostProcessor中文叫Bean后置处理器。它在Bean初始化前后各有一个回调Spring内部大量用它来实现Autowired注入、Value赋值、AOP代理创建这些功能。构造实例 → 属性填充 → BeanPostProcessor.postProcessBeforeInitialization → 初始化方法执行 → BeanPostProcessor.postProcessAfterInitialization → 实际可用这段链路是所有Spring高级特性的“地基”。比如你自定义了一个注解想在Bean创建后自动做一些代理包装或者逻辑增强实现一个BeanPostProcessor就是最常见的路子。3. 怎么被注入使用三种注入方式的利弊拆解好Bean进容器了、生命周期也在管了现在到了正题怎么把它拿来用这是Spring日常开发里出现频率最高的操作但踩坑率也奇高。3.1 字段注入最爽也最不推荐Service public class OrderService { Autowired private UserService userService; // 其他逻辑 }这是最常见、写起来最舒服的写法。声明一个字段标一个Autowired完事儿。但我要泼盆冷水字段注入不推荐用在正式项目里除非你有特殊偏好。原因有三个第一隐藏了依赖关系。这个类到底依赖什么单独看字段可能漏掉尤其是字段很多很散的时候。第二不利于测试。你想mock掉UserService但它是私有的测试框架得用反射去改不如构造函数传过来直接。第三容易产生循环依赖。字段注入给了编译器一个“还没初始化完就能引用”的通道虽然Spring的控制反转能处理部分循环依赖但这属于踩着钢丝跳舞。3.2 Setter注入可选参数的好伙伴Service public class OrderService { private UserService userService; Autowired public void setUserService(UserService userService) { this.userService userService; } }Setter注入的好处是可以随时重新设置依赖而且配合JavaBean规范某些场景里能让容器“先创建后装填”。但它的缺点也很明显对象可能在构造完成时还没拿到完整依赖要等Setter被执行后才能用。如果业务逻辑恰好在构造方法里跑了那此时userService还是null直接空指针。所以Setter注入一般用在可选依赖、循环依赖需要打破的场景、人有重设需求的场景。3.3 构造器注入官方推荐治根Service public class OrderService { private final UserService userService; public OrderService(UserService userService) { this.userService userService; } }Spring官方文档明确推荐构造器注入。理由很简单依赖不可变、保证不为空、方便测试。你可以在构造器参数里直接传mock对象不需要反射。现在Spring Boot里还有个小技巧如果一个类只有一个构造函数连Autowired都不用标Spring会自动使用那个构造函数来实例化。代码可以更简洁Service public class OrderService { private final UserService userService; public OrderService(UserService userService) { this.userService userService; } }3.4 辅助注解Qualifier与Resource的抉择当你一个接口有多个实现类时Autowired会懵因为它默认按类型匹配匹配到多个会直接报NoUniqueBeanDefinitionException。这时候有三个选择// 方式一Primary 在某一个实现类上标记优先 Service Primary public class UserServiceImpl implements UserService {} // 方式二Qualifier 指定名字 Service public class OrderService { Autowired Qualifier(userServiceImpl) private UserService userService; } // 方式三Resource(name userServiceImpl) Service public class OrderService { Resource(name userServiceImpl) private UserService userService; }我用Resource用得多一些因为Resource是先按名字找再按类型找在很多场景下更自然。但Resource是JSR-250标准提供的Spring也支持只是它的功能没Autowired那么丰富——比如Autowired配合Qualifier在集合注入时的精准匹配Resource就做不到。如果你们项目里有多个实现类确实需要同时存在我建议不要在字段上玩花活直接在构造器参数上写Qualifier清晰得多。4. 实际开发里的高发问题和排查实录这部分是干货中的干货。不管是现场面试还是日常联调这些问题的出现频率都极高。我尽量说人话把排查思路讲清楚。4.1 启动报错NoSuchBeanDefinitionException这个报错信息一看就知道——容器里根本没有这个Bean。排查路径很固定是否加了s组件注解Component/Service/Repository/Controller。是否在扫描包路径下Spring Boot的默认扫描包是启动类所在包及子包。你把UserService放在另一个包路径下没被扫描到就会出现这个错。如果是Bean方法定义的Bean检查配置类是否被扫描到、方法是否被执行。如果你是手写new了一个对象然后在这个对象里用Autowired注入那肯定注入不进去——Spring只管自己容器里的Bean手new出来的东西它管不着。排查技巧在配置类或启动类里临时加一个CommandLineRunner打印一下容器里所有Bean的名字看看你要用的Bean在不在里面。4.2 循环依赖三级缓存的前因后果循环依赖是A依赖BB依赖A。Spring单例模式下默认能处理“setter注入型”的循环依赖靠的是三级缓存。但构造器注入的循环依赖处理不了启动直接报错BeanCurrentlyInCreationException: Error creating bean with name a: Requested bean is currently in creation我个人的建议是别再依赖Spring的三级缓存解决问题改设计。循环依赖往往意味着你的类职责边界没划清楚。比如OrderService和UserService互相调用很多时候它们的关系其实是“共同依赖第三个服务”或者“可以合并成一个服务”。如果你把循环依赖当成常态你的项目结构会越来越黏糊后期没人敢动。如果确实要解决方案有几种把字段注入改成Lazy代理延迟加载、用ObjectProvider延迟获取、或者重新设计接口职责。4.3 一个接口多个实现类注入歧义这个前面提过再展开说下我的排查心得。报错长这样NoUniqueBeanDefinitionException: expected single matching bean but found 2原因就一个Spring按类型找结果同一类型有两份Bean定义。最简单的应对是找出这两个Bean的定义位置确认其中一个到底是不是多余的。比如你写了两个Service实现同一个接口其中一个是遗留类那直接删了就行。只有业务上确实需要多个实现才考虑Primary或Qualifier。4.4 Value注入不进去或取到的是nullValue注入外部配置是很常见的需求。出现取不到值先核对配置文件里面的key拼写再检查配置类上有没有PropertySource或ConfigurationProperties扫描到。有一个容易被坑的点在静态工具类里用Value。Spring不会给静态字段注入因为静态字段不归对象实例管。你必须在非静态字段上标Value然后在PostConstruct里转存到静态成员或者干脆用一个配置实例对象专门承载这些值。4.5 排查快查表问题现象最可能原因处理建议启动报NoSuchBeanDefinition没扫到包/没加注解检查扫描路径与注解BeanCurrentlyInCreation构造器循环依赖改设计/加LazyNoUniqueBeanDefinition接口多实现类Primary或Qualifier字段注入为null手动new对象/静态类交给容器管理Value返回null配置key拼错/静态字段核对key与非静态改造BeanDefinitionOverride同名Bean被重复定义查XML与注解扫描5. 进阶理解从“会用到”到“能给别人讲清楚”最后的这部分我想聊一点进阶内容不是教课但帮你把之前零散的知识串起来。5.1 容器扩展点BeanFactoryPostProcessor vs BeanPostProcessor这两个名字很像作用却千差万别。BeanFactoryPostProcessor是在Bean定义阶段后执行的。可以理解为Spring拿到所有BeanDefinition之后还没开始创建对象此时你来改BeanDefinition的属性、替换BeanClass、调整作用域。PropertySourcesPlaceholderConfigurer就是它的一个知名实现负责把占位符${xxx}替换成真实值。BeanPostProcessor则是在Bean实例化和初始化阶段执行的。对象已经创建出来了你可以在这里做属性填充、代理包装、初始化前后的额外逻辑。理解这两者你就能看懂为什么Value能生效——因为Spring在属性填充阶段调用了处理Value的AutowiredAnnotationBeanPostProcessor。5.2 配置类自己也是Bean很多人可能忽略一个细节Configuration标注的类本身也是一个Bean。Spring对它做了一层CGLIB代理目的是保证Bean方法被重复调用时返回的是同一个实例单例。这也是为什么你写Bean方法时不要在方法内部直接new并返回新对象——如果逻辑本身没有缓存你每次调方法都会得到新实例。但如果你在Configuration类里写了一个Bean方法然后又在这个方法里手动new了一个依赖方并手动把另一个Bean返回的依赖塞进去那这个新new出来的依赖就不是Spring管理的——它绕过了容器只是普通对象。5.3 我对Bean设计的一些习惯踩了太多坑之后我总结了几条写Spring代码的土规矩Bean尽可能做成无状态单例。要缓存要么用外部组件要么非常小心地做并发控制。用构造器注入少用字段注入。项目是自己在维护还好如果团队协作字段注入的“灵活”会变成别人埋雷的入口。循环依赖不是你用三级缓存解决的问题是你的设计出了问题的警示灯。能用ConfigurationProperties就别用散落的Value配置集中管理比什么都重要。写单元测试时尽量不启动完整Spring容器能用构造器手动组装对象就手动组装。这些习惯可能不会让功能写得快很多但会让你的代码在别人接手时少一些“怎么这也能跑”的惊叹。6. 实操演示手写一个“注入”的最小子集为了印证上面所有内容我们完全可以不依赖Spring自己写一个极简版的Bean容器。这个动过手的经历会让你对“容器”的理解瞬间落地。6.1 设计一个Mini容器我们需要一个MapString, Object存单例Bean一个MapString, BeanDefinition存定义一个扫描逻辑找到Component注解一个注入逻辑处理Autowired字段。// 简化版注解 Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) public interface MiniComponent {} Target(ElementType.FIELD) Retention(RetentionPolicy.RUNTIME) public interface MiniAutowired {}容器核心类public class MiniApplicationContext { private final MapString, Object singletonBeans new HashMap(); private final MapString, Class? beanDefinitions new HashMap(); public void scan(String basePackage) { // 扫描包下所有类找出标了MiniComponent的类 // 注册到beanDefinitions } public Object getBean(String name) { // 先从singletonBeans取没有则反射创建 // 创建后反射处理MiniAutowired字段递归创建依赖 // 创建完成后放回singletonBeans return singletonBeans.get(name); } }扫描逻辑用ClassPathScanningCandidateComponentProvider也可以但我们现在简化用固定类名注册来演示public class SampleMain { public static void main(String[] args) throws Exception { MiniApplicationContext context new MiniApplicationContext(); context.scan(com.example.mymini); OrderService orderService (OrderService) context.getBean(orderService); orderService.placeOrder(); } }看到没这个简化版容器的工作过程和Spring如出一辙扫描定义 → 反射创建 → 注入依赖 → 缓存单例。区别只是Spring还多了生命周期回调、AOP、事件发布、条件装配、延迟初始化等等一大堆功能但底层的骨架就是这个。自己动手实现一次之后很多平时不理解的问题都会豁然开朗。比如为什么Bean默认是单例——因为Spring就是用一个Map缓存起来的啊。6.2 构造器注入的最小支持字段注入的简化版已经能跑但如果你也想在极小容器里体验构造器注入逻辑也不复杂// 扫描构造器参数递归getBean然后反射创建实例 Constructor? constructor clazz.getDeclaredConstructors()[0]; Object[] args Arrays.stream(constructor.getParameterTypes()) .map(type - getBean(lowerCamelCase(type.getSimpleName()))) .toArray(); Object instance constructor.newInstance(args);这段代码其实就是Spring AutowiredAnnotationBeanPostProcessor内部在构造器注入时做的事的粗糙版。你会注意到如果A构造器依赖BB又依赖A这里就死循环了。这正是Spring三级缓存要解决的问题你不实现三级缓存就无法处理构造器循环依赖。这也是为什么Spring对构造器循环依赖直接报错的原因——它根本没法通过缓存挽救。亲手踩一遍这个坑你在面试里聊循环依赖时的底气都不一样。最后的实操心得我从头到尾传递的一个观点是Spring Bean机制并不玄学它就是一个加强版的“工厂注册表”模式。你把它当黑盒用能写代码你把它当白盒用能写好代码。个人经验里最值钱的几条我再强调一遍第一写业务代码优先构造器注入让依赖一目了然。第二不要试图用字段注入规避代码规约问题那是在给未来的排查挖坑。第三遇到循环依赖先怀疑设计再想怎么绕。这第三条看起来简单但很多团队最终都是靠重构才彻底摆脱了这个顽症。最后分享一个小技巧如果你们项目里Bean的初始化顺序偶尔出问题可以在PostConstruct方法里打印一段带时间戳的日志结合Spring启动日志里的Bean创建顺序一起看。这个方法不高级但在排查“这个Bean为什么比那个Bean先创建”的问题时比看源码都快。Spring这套体系已经活了二十多年核心逻辑没怎么变过。把“怎么交给容器”和“怎么被注入使用”这两条链路彻底吃透不只是应付面试更是后续你看Spring源码、写自定义starter、做中间件集成的基础。你越往后写越会发现所有花哨的框架特性最终都回归到这两个朴素的问题上。