☰
【面朝大厂】面试官:为什么在 new 对象里面使用自动注入对象会报空指针异常?
2026/10/5 2:32:45 网站建设 项目流程

一、从一个翻车现场说起

先看一段很多初学者都写过的代码。假设我们有一个业务类OrderService,它内部依赖一个OrderMapper,并且使用@Autowired完成自动注入:

@Service public class OrderService { @Autowired private OrderMapper orderMapper; public void createOrder(Order order) { orderMapper.insert(order); } }

上面的写法在正常请求链路里没有任何问题。因为OrderService是被 Spring 容器管理的 Bean,Spring 在创建它的时候会扫描@Autowired字段,把容器中的OrderMapper实现类注入进来。

然后某一天,你写了一个普通的 Java 类,想复用OrderService的能力,于是毫不犹豫地这样写:

public class OrderTask implements Runnable { @Autowired private OrderService orderService; @Override public void run() { Order order = new Order(); order.setAmount(new BigDecimal("99.00")); orderService.createOrder(order); } public static void main(String[] args) { OrderTask task = new OrderTask(); task.run(); // 这里会抛出 NullPointerException } }

程序一运行,控制台直接抛出让人血压升高的异常:

Exception in thread "main" java.lang.NullPointerException at com.example.demo.task.OrderTask.run(OrderTask.java:25) at com.example.demo.task.OrderTask.main(OrderTask.java:31)

很多人第一次遇到这个异常时会非常困惑:OrderService明明已经标注了@Service,字段也加了@Autowired,为什么在new OrderTask()之后,orderService还是null?

这个问题的答案,本质上需要回到 Spring 最核心的设计思想上:Spring 只能管理自己容器中的 Bean,永远无法对脱离容器的普通 new 对象进行依赖注入。下面我们就从原理、源码、字节码、场景和最佳实践几个维度,把这个问题彻底讲透。

二、先搞清楚:new 对象和 Spring Bean 到底有什么区别

在讨论为什么会空指针之前,必须先把两个概念区分清楚:普通 Java 对象和Spring Bean。它们是两种完全不同的对象创建方式,背后的生命周期也截然不同。

2.1 普通 Java 对象:由 JVM 直接创建

当你在代码中写OrderTask task = new OrderTask();的时候,JVM 做了这样几件事:

  • 在堆内存中分配一块内存空间给OrderTask实例;
  • 调用OrderTask的无参构造方法完成初始化;
  • 将对象的引用赋值给变量task。

整个过程中,Spring 容器完全不知情。JVM 只负责按照构造方法初始化字段,而@Autowired注解本身并不具备任何魔法,它只是类文件中的一个元数据标记。JVM 执行new的时候,并不会主动去解析这个注解,更不会去 Spring 容器里查找依赖对象。

因此,OrderTask中的orderService字段会保持默认值。引用类型字段的默认值是null,后续调用orderService.createOrder(order)时,就相当于在null上调用方法,JVM 自然抛出NullPointerException。

2.2 Spring Bean:由容器负责实例化、装配和销毁

和普通 new 对象不同,Spring Bean 的生命周期由容器统一管理。一个典型的 Bean 生命周期大致包括:

  1. 实例化:Spring 根据配置或注解,通过反射调用构造方法创建 Bean 实例;
  2. 属性填充:扫描@Autowired、@Resource、@Value等注解,完成依赖注入;
  3. 初始化:执行InitializingBean的afterPropertiesSet(),或@PostConstruct标注的方法;
  4. 使用阶段:Bean 被注入到其他 Bean 中,参与业务处理;
  5. 销毁:容器关闭时执行DisposableBean的destory()(注意 Spring 早期版本的方法名)或@PreDestroy标注的方法。

可以看到,属性填充是 Spring 容器主动完成的,而不是 JVM 自动完成的。这就是两者最本质的差异。

2.3 一句话总结

通过new创建的对象不受 Spring 管理,Spring 不会对其执行依赖注入;只有交给 Spring 容器创建的对象,才会经历完整的 Bean 生命周期,@Autowired才会生效。

三、Spring 依赖注入的核心原理

要彻底理解空指针的原因,我们需要进一步认识 Spring 是如何完成依赖注入的。这里以最常见的@Autowired字段注入为例进行拆解。

3.1 注解驱动背后的处理器

Spring 在启动容器时,会注册一系列后置处理器,其中和我们关系最大的就是AutowiredAnnotationBeanPostProcessor。它实现了InstantiationAwareBeanPostProcessor接口,能够在 Bean 属性填充阶段介入。

关键方法之一是postProcessProperties:

public PropertyValues postProcessProperties(PropertyValues pvs, Object bean, String beanName) { InjectionMetadata metadata = findAutowiringMetadata(beanName, bean.getClass(), pvs); try { metadata.inject(bean, beanName, pvs); } catch (BeanCreationException ex) { throw ex; } catch (Throwable ex) { throw new BeanCreationException(beanName, "Injection of autowired dependencies failed", ex); } return pvs; }

这里的findAutowiringMetadata会查找当前 Bean 中所有需要自动注入的元素,包括字段和方法。随后metadata.inject()会通过反射给这些字段赋值。

3.2 字段注入的实际执行过程

对于字段注入,Spring 会封装出AutowiredFieldElement,它的inject方法核心逻辑如下:

protected void inject(Object bean, @Nullable String beanName, @Nullable PropertyValues pvs) throws Throwable { Field field = (Field) this.member; Object value; if (this.cached) { value = resolvedCachedArgument(beanName, this.cachedFieldValue); } else { DependencyDescriptor desc = new DependencyDescriptor(field, this.required); desc.setContainingClass(bean.getClass()); Set<String> autowiredBeanNames = new LinkedHashSet<>(1); TypeConverter typeConverter = beanFactory.getTypeConverter(); value = beanFactory.resolveDependency(desc, beanName, autowiredBeanNames, typeConverter); // 省略缓存逻辑 } if (value != null) { ReflectionUtils.makeAccessible(field); field.set(bean, value); } }

可以看到两个关键动作:

  • 调用beanFactory.resolveDependency从容器中解析依赖对象;
  • 调用field.set(bean, value)利用反射把依赖对象设置到目标 Bean 的字段上。

这两个动作都发生在 Spring 创建某个 Bean 的过程中。换句话说,只有被 Spring 创建的 Bean,才会进入这个后置处理流程。

3.3 为什么手动 new 无法触发这套流程

当你写OrderTask task = new OrderTask();时,对象实例化走的是 JVM 的new指令,而不是 Spring 的createBean流程。Spring 没有机会对这个对象执行:

  • 实例化前处理;
  • 属性填充;
  • AutowiredAnnotationBeanPostProcessor的注入逻辑;
  • 初始化回调。

所以即使字段上写着@Autowired,它也只是静静地躺在字节码的注解表里,没有任何人去解析和执行。

四、从字节码和注解特性再往下挖一层

有些同学会问:注解不是可以被反射读取吗?JVM 在 new 对象的时候为什么不能顺便把注解处理掉?这里需要明确两点。

4.1 注解只是元数据,不是可执行代码

注解本质上是一个特殊的接口。反编译一个带@Autowired的类,你会发现注解只是被记录在类的常量池和方法、字段的RuntimeVisibleAnnotations属性中。

例如下面这个类:

public class OrderTask { @Autowired private OrderService orderService; }

通过javap -v OrderTask.class可以看到字段信息中带有注解标记:

private com.example.demo.service.OrderService orderService; descriptor: Lcom/example/demo/service/OrderService; flags: ACC_PRIVATE RuntimeVisibleAnnotations: 0: #31() org.springframework.beans.factory.annotation.Autowired

这些信息只是描述性的数据,并不会自动触发任何行为。必须有某个框架主动扫描并解释这些元数据,注解才具有业务意义。

4.2 谁来解释这些注解

在 Spring 体系中,解释@Autowired的是前面提到的AutowiredAnnotationBeanPostProcessor。它是 Spring 容器启动过程中注册和调用的组件。脱离 Spring 容器运行环境,就没有这个解释器,注解自然失效。

所以你看到的空指针,并不是@Autowired失效,而是注解的解释者根本没有上场。

五、同源问题:@Resource、@Value 为什么也会失效

理解了@Autowired的失效原因之后,@Resource和@Value的问题也就迎刃而解。

5.1 @Resource 的处理机制

@Resource是 JSR-250 规范中的注解,Spring 通过CommonAnnotationBeanPostProcessor来处理它。它的作用和@Autowired类似,也是在 Bean 初始化阶段完成字段或方法注入。

如果在普通 new 对象中使用:

public class SmsHelper { @Resource private SmsTemplate smsTemplate; public void send(String mobile, String content) { smsTemplate.send(mobile, content); // NPE } } SmsHelper helper = new SmsHelper(); helper.send("13800000000", "验证码");

同样会抛出空指针。因为CommonAnnotationBeanPostProcessor只处理 Spring 容器创建的 Bean。

5.2 @Value 的处理机制

@Value注解用于注入配置值,它由AutowiredAnnotationBeanPostProcessor一并处理。常见用法如下:

@Component public class AliyunOssConfig { @Value("${aliyun.oss.bucket}") private String bucket; public String getBucket() { return bucket; } }

如果手动new AliyunOssConfig(),bucket不会从配置文件中读取,而是保持null。这是因为@Value的值解析同样发生在 Spring Bean 的属性填充阶段。

5.3 共性结论

凡是依赖 Spring 容器后置处理器完成的注入,在手动 new 出的对象上都不会生效。这包括但不限于:

  • @Autowired
  • @Resource
  • @Value
  • @Inject
  • @Qualifier

六、业务中常见的翻车场景

了解了原理之后,我们再看一看实际开发中,大家最容易在什么地方踩坑。

6.1 在多线程任务类中使用 @Autowired

最常见的就是异步任务、定时任务、线程池执行的任务。例如:

public class SyncUserTask implements Runnable { @Autowired private UserService userService; @Override public void run() { userService.sync(); // 手动 new 后调用会 NPE } } ExecutorService executor = Executors.newFixedThreadPool(4); executor.submit(new SyncUserTask());

这里的new SyncUserTask()绕过了 Spring 容器,userService自然为null。

6.2 在静态工具类中注入 Mapper

很多开发者在编写工具类时,会试图这样注入 DAO:

public class UserCodeUtil { @Autowired private UserMapper userMapper; public static String generateUniqueCode() { return "U" + System.currentTimeMillis(); // 这里没用到注入对象 } public String queryName(Long userId) { return userMapper.selectNameById(userId); // 实例方法,但对象仍是 new 出来的 } } String name = new UserCodeUtil().queryName(1L); // NPE

即使工具类是 Spring 管理的 Bean,只要通过 new 获取实例,注入同样不存在。

6.3 在 POJO 或领域对象中注入 Service

有些同学会尝试在普通实体类中注入 Service:

public class Order { private Long id; private BigDecimal amount; @Autowired private OrderService orderService; public void cancel() { orderService.cancel(this.id); // 这里的 orderService 是 null } } Order order = new Order(); order.setId(100L); order.cancel(); // NPE

实体类是典型的数据对象,应该保持领域数据职责,不适合注入业务 Service,也不应该依赖 Spring 容器。这个设计本身就违反了职责单一原则。

6.4 在反射创建的对象中使用 @Autowired

通过反射clazz.newInstance()创建的对象,同样不会触发 Spring 注入:

Class<?> clazz = Class.forName("com.example.demo.handler.PayHandler"); PayHandler handler = (PayHandler) clazz.getDeclaredConstructor().newInstance(); handler.pay(order); // 如果依赖字段未初始化,可能 NPE

七、深入:Spring 容器如何管理 Bean 的注册与创建

接下来我们把视角切换到 Spring 容器内部,看看一个类是如何变成 Bean 的。这有助于理解为什么“对象必须经过容器”才能获得依赖。

7.1 扫描阶段

在 Spring Boot 项目中,启动类上的@SpringBootApplication组合注解包含了@ComponentScan。容器启动时会扫描指定包路径,找出标注了@Component、@Service、@Repository、@Controller等注解的类。

扫描到类之后,Spring 会将其封装成BeanDefinition,注册到BeanDefinitionRegistry中。此时对象还没有被创建,只是记录了类信息、作用域、依赖等元数据。

7.2 实例化阶段

当程序第一次需要某个 Bean 时,Spring 会根据BeanDefinition创建实例。默认情况下,Spring Boot 中的单例 Bean 并不是等到第一次被请求时才创建,而是在容器刷新阶段的finishBeanFactoryInitialization()流程中,由preInstantiateSingletons()提前完成实例化。Spring 获取 Bean 实例的方式通常有三种:反射调用构造器、工厂 Bean 的工厂方法,以及Supplier回调。无论哪一种,实例化动作都由容器内的AbstractAutowireCapableBeanFactory统一驱动,普通的new并不参与这套流程。

7.3 属性填充与初始化阶段

实例化完成后,Bean 仍然只是一个“毛坯对象”,字段大多还是默认值。容器会在populateBean()阶段处理属性填充,前面反复提到的AutowiredAnnotationBeanPostProcessor正是在这个阶段发挥作用。它会查找目标 Bean 中标记了@Autowired、@Value的字段或方法,并把容器解析到的依赖注入进去。

属性填充之后,Spring 还会执行初始化逻辑,来源包括:

  • 实现了InitializingBean接口的afterPropertiesSet();
  • 标注了@PostConstruct的方法;
  • 通过@Bean(initMethod = "...")指定的自定义初始化方法。

直到这些步骤全部完成,一个“可用”的 Spring Bean 才真正诞生。它和手动 new 出来的对象最本质的差别,就是多出了“属性填充 + 初始化回调”这两个关键动作。

7.4 从 BeanDefinition 到 Bean 的完整路径

如果把这个过程画成一张图,大致如下:

flowchart LR A[扫描并注册 BeanDefinition] --> B[resolveBeanClass 确定类型] B --> C[createBeanInstance 实例化] C --> D[populateBean 属性填充] D --> E[initializeBean 初始化回调] E --> F[放入单例池 singletonObjects] F --> G[供业务代码依赖注入使用]

手动 new 出的对象只在 JVM 层面完成了“分配内存 + 调用构造器”,缺失了图中的populateBean和initializeBean,所以依赖注入不会发生。

八、正确姿势一:把需要注入的对象也交给 Spring 管理

既然问题的根源是对象脱离了容器,那么最直接的解法就是想办法让对象回到 Spring 的生命周期里。下面按业务类型分别来看。

8.1 普通组件直接声明为 Bean

如果OrderTask本身需要依赖OrderService,最简单的做法是把它也声明为 Spring 组件:

@Component public class OrderTask { private final OrderService orderService; public OrderTask(OrderService orderService) { this.orderService = orderService; } public void run() { Order order = new Order(); order.setAmount(new BigDecimal("99.00")); orderService.createOrder(order); } }

使用的时候,不要自己 new,而是从容器中获取:

@Service public class TaskExecutorService { private final OrderTask orderTask; public TaskExecutorService(OrderTask orderTask) { this.orderTask = orderTask; } public void execute() { orderTask.run(); // orderTask 中的依赖已被 Spring 注入,不会 NPE } }

可以看到,只要调用方和被调用方都处于 Spring 容器中,依赖链就能自动串起来。这是最自然、最符合 Spring 设计理念的写法。

8.2 线程任务类:不要让业务代码自己 new

如果确实需要在多线程、定时任务场景中执行,常见做法有三类:

  1. 使用 Spring 提供的@Async:把任务方法交给 Spring 的线程池执行,任务类本身注册为 Bean;
  2. 使用@Scheduled:定时任务方法所在的类注册为 Bean,由 Spring 调度;
  3. 在创建线程时从容器获取依赖:线程入口只承担路由职责,真正执行业务的是容器中的 Bean。

以@Async为例:

@Service public class AsyncUserService { private final UserService userService; public AsyncUserService(UserService userService) { this.userService = userService; } @Async public void syncUsers() { userService.sync(); } }

调用方只需要注入AsyncUserService并调用syncUsers(),Spring 会把方法提交给线程池执行,userService也一定是已经注入完成的对象。

8.3 需要多个实例时使用原型作用域

如果业务要求每次使用都要获得一个新对象,不要用 new,可以配置原型 Bean:

@Component @Scope("prototype") public class ReportHandler { private final ReportService reportService; public ReportHandler(ReportService reportService) { this.reportService = reportService; } public void handle(Report report) { reportService.process(report); } }

每次注入或通过ObjectProvider获取时,Spring 都会创建一个全新的ReportHandler实例,同时自动完成reportService的注入。

九、正确姿势二:优先使用构造器注入

除了“让对象回归容器”,我们还应该讨论一个更有工程价值的话题:代码怎么写才能更早暴露这类问题。答案就是构造器注入。

9.1 字段注入的隐患

字段注入虽然写起来简单,但有一个明显缺点:依赖被隐藏在字段默认值里。当你自己 new 对象时,编译器不会提醒你缺少依赖,运行时才以 NPE 的形式爆发。

@Service public class FieldInjectionService { @Autowired private OrderMapper orderMapper; // 看起来“没问题”,但依赖并不强制 }

9.2 构造器注入从编译期约束依赖

如果把依赖放到构造器里,事情就变得不同:

@Service public class ConstructorInjectionService { private final OrderMapper orderMapper; public ConstructorInjectionService(OrderMapper orderMapper) { this.orderMapper = orderMapper; } }

此时任何想创建ConstructorInjectionService对象的代码,都必须提供一个OrderMapper实例。自己 new 的成本大幅提高,依赖缺失也会在创建对象的那一刻被暴露出来,而不是等到方法调用时才抛出 NPE。

正因为这些优势,Spring 官方也推荐强制依赖使用构造器注入,可选依赖才考虑 setter 注入或字段注入。

9.3 借助 Lombok 让构造器注入更简洁

很多人觉得构造器注入代码冗长,项目里如果使用了 Lombok,可以借助@RequiredArgsConstructor减少样板代码:

@Service @RequiredArgsConstructor public class OrderQueryService { private final OrderMapper orderMapper; private final OrderConverter orderConverter; public OrderVO query(Long orderId) { Order order = orderMapper.selectById(orderId); return orderConverter.toVO(order); } }

Lombok 会在编译期为final字段生成构造器,Spring 依然按照构造器方式完成注入,既简洁又安全。

十、正确姿势三:通过 ApplicationContext 手动获取 Bean

在一些老项目或框架限制下,确实有些对象无法直接交给 Spring 管理。这时可以通过ApplicationContext手动查找 Bean,作为一种过渡方案。但需要强调:这只是应急手段,不推荐作为主流写法。

10.1 实现 ApplicationContextAware

先定义一个上下文持有类:

@Component public class SpringContextHolder implements ApplicationContextAware { private static ApplicationContext context; @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { SpringContextHolder.context = applicationContext; } public static <T> T getBean(Class<T> clazz) { return context.getBean(clazz); } }

在无法交给 Spring 管理的对象中手动获取依赖:

public class LegacyOrderTask implements Runnable { @Override public void run() { OrderService orderService = SpringContextHolder.getBean(OrderService.class); orderService.createOrder(new Order()); } }

10.2 为什么它只是过渡方案

这种写法把容器 API 直接嵌入业务代码,破坏了业务逻辑的纯粹性,也让单元测试变得更加困难。它适合在遗留系统、插件化入口或确实无法改造的对象中使用。凡是新项目、新模块,都应该优先考虑前两种方案。

十一、进阶方案:@Configurable 与 AspectJ LTW

Spring 其实提供了一种“即使 new 出来,也能完成注入”的机制,答案就是@Configurable。

11.1 @Configurable 的原理

@Configurable来自spring-aspects模块,配合 AspectJ 的加载期织入(LTW,Load-Time Weaving),在对象构造完成后截获构造器调用,并委托 Spring 容器完成依赖注入。这相当于给普通的 new 对象补上了属性填充步骤。

11.2 使用示例

先引入相关依赖,并在启动时开启 LTW:

@Configuration @EnableLoadTimeWeaving public class LtwConfig { }

然后在实体类上标注@Configurable:

@Configurable public class Order { @Autowired private transient OrderService orderService; private Long id; public void cancel() { orderService.cancel(this.id); } }

这样new Order()得到的实例,orderService字段会被 Spring 注入。但它的缺点也很明显:需要额外的启动参数、JVM 代理配置,调试链路更长,而且对团队的技术门槛要求较高。除非业务确实存在“大量 new 出来的对象必须注入”的场景,否则不建议轻易引入。

十二、别忽略:静态字段注入是另一个经典陷阱

和 new 对象空指针经常一起出现的,还有静态字段注入问题。例如:

@Component public class FilePathHelper { @Value("${file.upload.path}") private static String uploadPath; // 静态字段注入通常不生效 public static String getUploadPath() { return uploadPath; } }

Spring 的属性填充依赖实例对象,静态字段属于类而不属于实例,因此字段注入无法可靠地作用在静态变量上。更合理的做法是提供实例方法,或通过@PostConstruct在初始化时显式赋值。

如果确实要用静态变量,建议让配置类在初始化后主动赋值:

@Component public class FilePathHelper { private static String uploadPath; @Value("${file.upload.path}") public void setUploadPath(String path) { FilePathHelper.uploadPath = path; } public static String getUploadPath() { return uploadPath; } }

不过这里调用setUploadPath的仍然是 Spring 创建的实例方法,静态字段只是借用实例初始化时机完成设置。真正从设计上还是要尽量避免静态可变状态。

十三、面试现场:如何有条理地讲清这个问题

如果面试官抛来这个问题,建议不要只回答一句话,而是按照“现象 → 本质 → 延伸”三层展开。

13.1 基础回答框架

  1. 先说结论:手动 new 出来的对象不归 Spring 容器管理,@Autowired不会生效,字段保持null,所以方法调用会抛出 NPE。
  2. 补充机制:Spring 的依赖注入发生在 Bean 生命周期的属性填充阶段,由AutowiredAnnotationBeanPostProcessor等后置处理器完成。手动 new 走的是 JVM 普通实例化流程,不会触发这些处理器。
  3. 给出方案:让对象也注册为 Bean,或者用构造器注入减少隐藏依赖,必要时可用ApplicationContext手动获取。

13.2 加分回答

  • 能说出@Resource、@Value同样失效,因为背后处理逻辑都依赖容器后置处理器;
  • 能提到@Configurable配合 AspectJ LTW 可以实现 new 对象注入,并说明其成本;
  • 能对比字段注入、setter 注入、构造器注入的差异,指出构造器注入更早暴露依赖缺失;
  • 能结合线程池、定时任务、静态工具类等实际场景说明如何规避。

把这几层讲清楚,这个问题就不只是一次“排错经验”,而是一次对 Spring IoC 容器的系统展示。

十四、总结:一句话记住这个问题

回到最初的问题:为什么在 new 对象里面使用自动注入对象会报空指针?

因为new创建对象时只完成了 JVM 层面的实例化,没有经过 Spring 容器的 Bean 生命周期,@Autowired等注解所依赖的后置处理器不会被触发,注入字段自然保持null。

应对这个问题,记住三件事:

  • 对象要回归容器:需要依赖注入的类,尽量注册为 Spring Bean,不要自己 new;
  • 依赖要显式声明:优先构造器注入,让依赖缺失尽早暴露;
  • 特殊场景有边界:多线程、静态方法、遗留对象等场景要特别小心,宁可多走一步容器获取,也不要盲目 new。

Spring 的 IoC 容器本质上是在帮我们管理对象的“生老病死”。理解了这一点,你会发现很多看似诡异的空指针,其实都不是 Spring 的 Bug,而是我们自己绕过了容器。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询