资深 Java 面试到 Agent 开发工程师 · 第 16 集
给类加了 @Service,给字段加了 @Autowired,为什么下面这个对象里的 priceClient 仍然是 null?
java
@Service
class OrderService {
@Autowired
private PriceClient priceClient;
}
OrderService service = new OrderService();
这次 new 只执行了 Java 的对象创建过程。扫描注解、查找依赖、写入字段,都是 Spring 容器额外做的事;容器里即使已经有另一个 OrderService,也不会顺手修改这份对象。
要理解注入为什么发生、什么时候失败,可以跟着容器创建同一个 OrderService:先登记创建信息,启动预创建或后续真正需要实例时再选构造器,解析依赖、填充字段,完成初始化,最后交给调用方。
扫描到类以后,容器里先存的是什么?
@ComponentScan 找到 @Service 后,通常先登记一份 BeanDefinition,并没有当场把所有业务对象都创建出来。BeanDefinition 保存类、作用域、是否懒加载、初始化方法等创建信息。@Bean 方法也会形成相应定义,由容器在创建时调用工厂方法。
比如容器知道"orderService 对应 OrderService,作用域是 singleton",和容器已经拥有那个对象,是两回事。前者是创建规则,后者才是 Bean 实例。通常说的 IoC(控制反转),在这里就是把对象创建和组装交给容器;DI(依赖注入)则是容器把选好的 PriceClient 提供给 OrderService。
容器启动的 refresh() 流程中,三个调用决定了这些规则怎样变成实例。以下取自 Spring Framework 5.3.23 的 AbstractApplicationContext,只保留相关调用,其他启动步骤省略:
java
invokeBeanFactoryPostProcessors(beanFactory); // 处理、补充 Bean 定义
registerBeanPostProcessors(beanFactory); // 注册实例处理器
// 其他启动步骤省略
finishBeanFactoryInitialization(beanFactory); // 创建剩余非懒加载单例
这两个名字相近的处理器,操作对象不同:
| 扩展点 | 主要处理什么 | 对这次创建的作用 |
|---|---|---|
| BeanFactoryPostProcessor | 容器及 BeanDefinition 等配置信息 | 在普通业务 Bean 创建前调整定义;配置类解析使用的注册表处理器也属于这一体系 |
| BeanPostProcessor | 正在创建的 Bean 实例 | 填充依赖、执行注解回调,或返回包装后的对象 |
处理器本身也需要被创建,所以"所有定义处理都结束之前绝不创建任何 Bean"不成立。写定义处理器时,如果过早 getBean() 拿业务对象,它可能赶不上完整的实例处理器链。
常规启动会预先创建非懒加载单例;懒加载 Bean 可以推迟到实际需要时,但如果被一个立即创建的 Bean 直接依赖,也可能提前创建。prototype 则在每次请求实例时走创建流程。

getBean 怎样把定义变成对象?
getBean("orderService") 先尝试取已有单例。正常情况下已经创建成功,就直接复用;还没有时,单例创建流程才调用 createBean(),进一步进入 doCreateBean()。
AbstractAutowireCapableBeanFactory里,普通 Bean 的创建可以沿着这几处调用读下去。以下是同一方法的删节,省略缓存、异常处理和早期引用分支:
java
instanceWrapper = createBeanInstance(beanName, mbd, args);
Object bean = instanceWrapper.getWrappedInstance();
// 省略定义处理、早期引用等分支
Object exposedObject = bean;
populateBean(beanName, mbd, instanceWrapper);
exposedObject = initializeBean(beanName, exposedObject, mbd);
beanName 是实例在容器中的名字;mbd 是合并后的创建定义;instanceWrapper 包装刚构造出来的对象。三个方法依次负责实例化、属性填充、初始化。
因此,构造器已经执行,并不等于 Bean 已经可用。字段注入还没做,初始化也没完成。exposedObject 单独保存对外对象,是因为后处理器可能返回代理,最终交付的引用不一定等于最初构造的引用。
@Bean 方法里也可以 return new OrderService(client),这个返回对象随后进入容器处理。判断是否受管理,要看对象有没有进入这条路径,不能只搜索代码中有没有 new。
@Autowired 怎样找到字段,又怎样找到依赖?
普通字段注入发生在 populateBean()。它会调用属性处理阶段的后处理器,AutowiredAnnotationBeanPostProcessor 就在其中。处理器查找带注解的字段和方法,形成一份注入元数据:
java
// postProcessProperties 中的关键调用,省略 try/catch
InjectionMetadata metadata =
findAutowiringMetadata(beanName, bean.getClass(), pvs);
metadata.inject(bean, beanName, pvs);
metadata 记录"哪些成员要注入",pvs 是已有的属性值信息。查找结果会缓存,不必每次都从头扫描这个类。注入元数据再逐个处理字段或方法。
但找到 priceClient 字段以后,还得决定给它哪一个对象。字段解析先把"这次依赖是什么"交给容器,下面是 resolveFieldValue 中相关语句的删节:
java
DependencyDescriptor desc = new DependencyDescriptor(field, this.required);
desc.setContainingClass(bean.getClass());
// 省略前置变量准备与异常处理
value = beanFactory.resolveDependency(
desc, beanName, autowiredBeanNames, typeConverter);
field 是反射拿到的字段,required 表示这个注入点是否必需。DependencyDescriptor 把字段类型、注解等信息带到依赖解析流程;autowiredBeanNames 记录实际使用了哪些 Bean,方便登记依赖关系。
对于这里单个 PriceClient 字段,容器按所需类型查找候选,再结合泛型、@Qualifier 等条件筛选。多个候选都合格时,还要按 @Primary、优先级等规则选出一个;仍无唯一结果时,名称匹配也可能参与回退。字段名因此可能影响结果,但 @Autowired 并不是简单地"先按字段名找对象"。

假如同时有 remotePriceClient 和 cachedPriceClient,都实现 PriceClient,没有进一步选择依据时会报候选不唯一。给其中一个加 @Primary,表示它是默认首选;在注入点写 @Qualifier("remotePriceClient"),则在类型合格的候选里继续限定。@Qualifier 不是跳过类型检查、任意按字符串取对象。
依赖本身还没创建时,解析会继续获取或创建它,所以创建 OrderService 可能递归触发 PriceClient 的创建。拿到依赖对象后,字段注入处理器才执行真正的赋值:
java
if (value != null) {
ReflectionUtils.makeAccessible(field);
field.set(bean, value); // 把解析好的依赖写进已有对象
}
注解本身不执行赋值。默认必需依赖找不到时,解析阶段就会抛创建异常;多个候选无法选择,也会失败。正常创建完成的普通 Bean,不应因为"找不到必需依赖"而悄悄留下 null。遇到空值,要区分手动对象、构造器内提前访问,以及显式配置的可选注入。
构造器注入,改变了哪一步?
java
@Service
class OrderService {
private final PriceClient priceClient;
OrderService(PriceClient priceClient) {
this.priceClient = Objects.requireNonNull(priceClient);
}
}
这里 PriceClient 必须在调用构造器前解析好。Spring 选择构造器后,ConstructorResolver 为参数解析依赖,再调用构造器;不是构造完对象再反射修改这个 final 字段。
只有一个构造器时,可以不写 @Autowired。多个构造器时需要明确选择,不能推成"Spring 随便挑一个"。必需依赖用构造器表达,手动测试也要传入依赖,创建成功后就不会再有"忘了给这个字段赋值"的中间状态。
这也是字段循环依赖与构造器循环依赖行为不同的原因:字段注入之前,原始对象已经存在;构造器参数还没解析好时,对象根本没构造出来。
初始化与代理,分别在哪个时点?
initializeBean() 的关键顺序如下。以下保留主要调用,省略条件判断和异常包装:
java
invokeAwareMethods(beanName, bean);
Object wrappedBean = bean;
wrappedBean = applyBeanPostProcessorsBeforeInitialization(wrappedBean, beanName);
invokeInitMethods(beanName, wrappedBean, mbd);
wrappedBean = applyBeanPostProcessorsAfterInitialization(wrappedBean, beanName);
return wrappedBean;
Aware 回调让对象获知 Bean 名、BeanFactory 等容器信息。初始化前处理器中,生命周期注解处理器调用 @PostConstruct。invokeInitMethods 再执行 InitializingBean.afterPropertiesSet() 和定义中的自定义 init;这些是不同方法同时存在时的顺序。

这里"初始化前"是指初始化方法之前,属性填充已经完成。字段注入的依赖在构造器里还不可用,正常走到 @PostConstruct 时已经填入。
常见 AOP 自动代理在初始化后处理阶段返回包装对象。容器和调用方应该接收这个返回值,不能做完增强又继续使用旧引用。初始化方法内部的自调用,也不能指望经过尚未由这次正常流程交付的代理;"@PostConstruct 调完了"更不能单独代表整个应用已经就绪。
容器正常关闭时,为已登记销毁回调的单例执行清理。prototype 创建完成后,容器不替使用方统一追踪销毁;它持有的连接、文件等仍需使用方释放。强制结束进程也不能依赖正常关闭回调。
三级缓存为什么能处理部分循环依赖?
假设 A 有一个 @Autowired B 字段,B 又有一个 @Autowired A 字段。创建 A 时构造器已经完成,接下来创建 B,B 又来要 A:它需要的是一个尚未完成属性填充的对象。
允许循环引用的单例创建路径,会在填充属性之前登记一个"取得 A 早期引用"的工厂。doCreateBean() 的这一段保留了适用条件:
java
boolean earlySingletonExposure = (mbd.isSingleton()
&& this.allowCircularReferences
&& isSingletonCurrentlyInCreation(beanName));
if (earlySingletonExposure) {
addSingletonFactory(beanName,
() -> getEarlyBeanReference(beanName, mbd, bean));
}
这里保存的是延迟取得早期引用的工厂,不是把 A 当作初始化完成的单例直接放出去。三处缓存分别承担不同状态:
| 缓存 | 里面放什么 | 在这次 A、B 互相依赖时做什么 |
|---|---|---|
| singletonObjects | 创建完成后正式登记的单例引用 | A 完成整条创建流程后供后续复用 |
| earlySingletonObjects | 已经取得的早期引用 | B 需要 A 时,复用同一份提前取出的引用 |
| singletonFactories | 能产生早期引用的工厂 | 被需要时才调用,可经处理器取得早期代理 |
B 获取 A 时,查到 A 正在创建,才有机会调用第三层工厂,拿到早期引用并保存到第二层。B 把早期 A 注入自己的字段后完成创建,再返回给 A;A 拿到 B,才能继续完成自己的属性和初始化。正式登记完成后,临时缓存会清理。

工厂的作用不只是拖延一次赋值:getEarlyBeanReference() 还允许自动代理处理器提前返回与后续交付相协调的引用。但这不是所有包装方式都能成功的保证;已经注入原始对象,之后又产生不兼容包装时,Spring 也可能拒绝创建。
这套机制有明确范围:要允许循环引用、对象是单例,而且已经构造出来。纯构造器 A→B→A 没有可提前交付的 A,无法靠这些缓存补出对象;prototype 的循环也不由这套单例缓存处理。应用层还可能直接禁止循环依赖。例如 Spring Boot 2.6 起默认关闭循环引用;框架能处理某种循环,不代表项目默认允许。
提前拿到 A,也不等于 A 已初始化。B 在初始化时调用 A,可能碰上 A 还没填好的字段。因此,业务应优先拆开循环:把共同计算提取成独立服务,或由上层服务协调 A、B。不能用"容器启动成功"代替对业务调用关系的检查。
用创建过程排查工作中的问题
| 现象 | 对应流程 | 应检查什么 |
|---|---|---|
| 字段在业务调用里是 null | 实例来源与属性填充 | 是否自己 new;是否可选注入;是否拿错对象 |
| 启动报候选不唯一 | 依赖解析 | 实际候选、Qualifier、Primary,不能只改变量名碰运气 |
| 初始化时依赖对象状态不完整 | 递归创建与早期引用 | 是否循环依赖并提前调用了未完成对象 |
| 单例注入 prototype,多次调用却总是同一个 | 依赖解析发生的时机 | 单例只解析并保存一次;按次创建可用 ObjectProvider.getObject() |
| 多个请求互相覆盖成员变量 | 单例被共享使用 | 请求状态放参数或局部变量,singleton 不提供并发保护 |
单例直接注入一个 prototype,只会在单例创建时取得一次。需要每次操作都向容器请求,可以注入 ObjectProvider<Resource>:
java
Resource resource = provider.getObject(); // 使用方已注入 ObjectProvider<Resource>
try {
resource.execute();
} finally {
resource.close(); // Resource 的示例业务方法,释放它持有的资源
}
Resource 是这里假设的任务资源类,execute()、close() 表示使用与清理。是否每次新建取决于 Bean 的作用域:配置 prototype 时按次创建;配置 singleton 时,Provider 获取的仍是同一份单例。不要把共享单例当一次性资源关闭。
排查时,记录或断点观察实际 getBean() 的返回对象、依赖候选和创建异常的最早原因。NoSuchBeanDefinitionException、NoUniqueBeanDefinitionException 和 BeanCurrentlyInCreationException 指向不同阶段,不能都按"扫描不到类"处理。
常见面试题怎么答?
1. BeanFactoryPostProcessor 和 BeanPostProcessor 有什么区别?
前者处理容器及创建定义,后者处理实例。配置类解析与定义调整先参与启动,注入、初始化和代理等实例处理器再作用于创建流程。业务对象过早被取出,可能错过尚未注册完整的处理器链。
2. @Autowired 是按类型还是按名称?
对单个普通字段,先按依赖类型等条件找候选,结合限定符、Primary、优先级和名称回退完成选择。选中后仍需获取相应实例,最后反射写入字段。依赖缺失和候选不唯一会在解析阶段失败;它不是只按字段名取对象。
3. Bean 的生命周期为什么不只是 new、注入、使用?
实例化与属性填充之后,还要执行 Aware、初始化前处理、初始化方法、初始化后处理,最终对象可能被包装。完成创建后才正式缓存复用,关闭时再执行登记的销毁;构造器、初始化回调和代理的可用时点不能互换。
4. 三级缓存能解决所有循环依赖吗?
只能帮助允许循环引用且已有原始实例的部分单例创建。构造器循环没有早期对象,prototype 不走单例缓存;提前注入的对象还可能未完成初始化。三级缓存还要配合早期代理处理,不代表允许任意提前调用或包装。
读完自测
1. 两个 PriceClient 都注册了,字段恰好与其中一个同名,能据此认定 @Autowired 只按名称吗?
参考要点:不能。名称只是候选选择中的一个因素;类型、限定符和其他首选规则仍参与。明确业务选择可使用 Qualifier 或 Primary,并用实际候选验证。
2. A、B 的字段循环能启动,B 的初始化方法调用 A 却报空指针,先查哪里?
参考要点:检查 B 是否拿到了尚未完成填充的 A。早期引用只能让装配继续,不能保证 A 已可执行业务。拆开循环或改变初始化职责,再从原调用入口验证。
3. 把 prototype 直接注入单例,再给 prototype 加 PreDestroy,容器关闭时能保证清理每次使用的资源吗?
参考要点:直接注入只取得一次;prototype 的销毁不由容器统一管理。按次获取用 ObjectProvider,使用方按资源协议清理,不能依赖单例销毁自动遍历所有原型实例。
参考:Spring 容器与依赖注入手册、依赖解析源码、单例缓存源码。源码节选依据 Spring Framework 5.3.23;注解生命周期对应 javax.annotation 包。