一、从一个翻车现场说起
先看一段很多初学者都写过的代码。假设我们有一个业务类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 生命周期大致包括:
- 实例化: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 属性填充阶段介入。
关键方法之一是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
如果确实需要在多线程、定时任务场景中执行,常见做法有三类:
- 使用 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,可以配置原型 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 基础回答框架
- 先说结论:手动 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,而是我们自己绕过了容器。