资讯动态

【面朝大厂】面试官:为什么在 new 对象里面使用自动注入对象会报空指针异常?

发布时间:2026/10/5 2:33:00 来源:尧图企业网站定制
一、从一个翻车现场说起先看一段很多初学者都写过的代码。假设我们有一个业务类OrderService它内部依赖一个OrderMapper并且使用Autowired完成自动注入Service public class OrderService { Autowired private OrderMapper orderMapper; public void createOrder(Order order) { orderMapper.insert(order); } }上面的写法在正常请求链路里没有任何问题。因为OrderService是被 Spring 容器管理的 BeanSpring 在创建它的时候会扫描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 生命周期大致包括实例化Spring 根据配置或注解通过反射调用构造方法创建 Bean 实例属性填充扫描Autowired、Resource、Value等注解完成依赖注入初始化执行InitializingBean的afterPropertiesSet()或PostConstruct标注的方法使用阶段Bean 被注入到其他 Bean 中参与业务处理销毁容器关闭时执行DisposableBean的destory()注意 Spring 早期版本的方法名或PreDestroy标注的方法。可以看到属性填充是 Spring 容器主动完成的而不是 JVM 自动完成的。这就是两者最本质的差异。2.3 一句话总结通过new创建的对象不受 Spring 管理Spring 不会对其执行依赖注入只有交给 Spring 容器创建的对象才会经历完整的 Bean 生命周期Autowired才会生效。三、Spring 依赖注入的核心原理要彻底理解空指针的原因我们需要进一步认识 Spring 是如何完成依赖注入的。这里以最常见的Autowired字段注入为例进行拆解。3.1 注解驱动背后的处理器Spring 在启动容器时会注册一系列后置处理器其中和我们关系最大的就是AutowiredAnnotationBeanPostProcessor。它实现了InstantiationAwareBeanPostProcessor接口能够在 Bean 属性填充阶段介入。关键方法之一是postProcessPropertiespublic 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()); SetString 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 出的对象上都不会生效。这包括但不限于AutowiredResourceValueInjectQualifier六、业务中常见的翻车场景了解了原理之后我们再看一看实际开发中大家最容易在什么地方踩坑。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很多开发者在编写工具类时会试图这样注入 DAOpublic 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有些同学会尝试在普通实体类中注入 Servicepublic 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如果确实需要在多线程、定时任务场景中执行常见做法有三类使用 Spring 提供的Async把任务方法交给 Spring 的线程池执行任务类本身注册为 Bean使用Scheduled定时任务方法所在的类注册为 Bean由 Spring 调度在创建线程时从容器获取依赖线程入口只承担路由职责真正执行业务的是容器中的 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可以配置原型 BeanComponent 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(ClassT 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 LTWSpring 其实提供了一种“即使 new 出来也能完成注入”的机制答案就是Configurable。11.1 Configurable 的原理Configurable来自spring-aspects模块配合 AspectJ 的加载期织入LTWLoad-Time Weaving在对象构造完成后截获构造器调用并委托 Spring 容器完成依赖注入。这相当于给普通的 new 对象补上了属性填充步骤。11.2 使用示例先引入相关依赖并在启动时开启 LTWConfiguration EnableLoadTimeWeaving public class LtwConfig { }然后在实体类上标注ConfigurableConfigurable 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 基础回答框架先说结论手动 new 出来的对象不归 Spring 容器管理Autowired不会生效字段保持null所以方法调用会抛出 NPE。补充机制Spring 的依赖注入发生在 Bean 生命周期的属性填充阶段由AutowiredAnnotationBeanPostProcessor等后置处理器完成。手动 new 走的是 JVM 普通实例化流程不会触发这些处理器。给出方案让对象也注册为 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而是我们自己绕过了容器。

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

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

免费获取报价 →
↑