注入的 service 是 null,我对着空指针查了一下午
十二月初写了个定时任务,跑起来第一行就 NPE:
@Component
public class OrderTimeoutJob {
@Autowired
private OrderService orderService;
public void execute() {
List<Order> list = orderService.findTimeoutOrders(); // 这行 NPE
// ...
}
}
异常栈很简单:
java.lang.NullPointerException
at com.xxx.job.OrderTimeoutJob.execute(OrderTimeoutJob.java:24)
orderService 是 null。但同一个 OrderService 在 Controller 里注入得好好的。我一开始怀疑是 OrderService 本身有问题,查了半天注解,最后发现是 job 类根本没被 Spring 管理。
原因一:类不在包扫描范围内
启动类的位置:
package com.xxx;
@SpringBootApplication
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
}
而我的 job 类在 com.xxx.job 下,看着是子包,应该被扫到才对。问题出在我后来为了整理代码,把 job 挪到了 com.job(少了 xxx 这一层),而 @SpringBootApplication 默认只扫描启动类所在包及其子包。
验证方法,启动时加一行打印:
@SpringBootApplication
public class OrderApplication {
public static void main(String[] args) {
ConfigurableApplicationContext ctx = SpringApplication.run(OrderApplication.class, args);
System.out.println("是否包含 OrderTimeoutJob: " + ctx.containsBean("orderTimeoutJob"));
}
}
输出 false,确认没被注册。
解决办法有两种,一是把包挪回去(我选了这个),二是显式指定扫描路径:
@SpringBootApplication(scanBasePackages = {"com.xxx", "com.job"})
关于 @SpringBootApplication 还有个容易忽略的点:它是三个注解的组合:
@SpringBootConfiguration // 本质是 @Configuration
@EnableAutoConfiguration
@ComponentScan // 默认扫当前包及子包
所以启动类放的位置决定了整个项目的扫描根路径。我们项目的规范是启动类必须放在最外层的 com.xxx 下。
原因二:这个对象是我自己 new 出来的
第二次遇到注入为 null,是在一个工具类里:
public class PriceCalculator {
@Autowired
private SkuService skuService; // null
public BigDecimal calc(Long skuId) {
Sku sku = skuService.getById(skuId); // NPE
// ...
}
}
// 调用方
PriceCalculator calculator = new PriceCalculator(); // 自己 new 的
这是我当时最不理解的一条:Spring 只负责管理由它创建的对象。你 new 出来的对象,Spring 根本不知道它存在,自然不会给它注入依赖。
改法是把工具类也交给 Spring:
@Component
public class PriceCalculator {
private final SkuService skuService;
@Autowired
public PriceCalculator(SkuService skuService) {
this.skuService = skuService;
}
// ...
}
// 调用方也注入
@Autowired
private PriceCalculator calculator;
顺带说下我为什么改成构造器注入。用 @Autowired 标注字段的话,下面这种情况编译能过、运行时 NPE:
@Component
public class PriceCalculator {
@Autowired
private SkuService skuService;
public PriceCalculator() {
skuService.getById(1L); // 构造器执行时,字段还没被注入!
}
}
字段注入发生在对象实例化之后,所以构造器里用不了注入的字段。改成构造器注入之后,依赖关系在构造时就确定了,而且字段可以声明成 final,避免后续被改掉。Spring 4.3 之后,如果类只有一个构造器,连 @Autowired 都可以省。
原因三:静态字段注入
第三次,我写了个工具类想做成静态的:
@Component
public class SmsUtil {
@Autowired
private static SmsClient smsClient; // 注入失败,永远是 null
public static void send(String phone, String content) {
smsClient.send(phone, content); // NPE
}
}
Spring 不支持静态字段的依赖注入,它扫描的是实例字段。而且就算注入成功了,静态字段属于类,所有实例共享,跟 Spring 的单例/原型作用域设计冲突。
三种绕法,各有取舍:
// 方式一:@PostConstruct 里赋值给静态字段(能跑,但别扭)
@Component
public class SmsUtil {
private static SmsClient CLIENT;
@Autowired
private SmsClient smsClient;
@PostConstruct
public void init() {
CLIENT = smsClient;
}
public static void send(String phone, String content) {
CLIENT.send(phone, content);
}
}
// 方式二:实现 ApplicationContextAware 拿容器(我以前用过,不推荐)
@Component
public class SpringContextHolder implements ApplicationContextAware {
private static ApplicationContext ctx;
@Override
public void setApplicationContext(ApplicationContext applicationContext) {
ctx = applicationContext;
}
public static <T> T getBean(Class<T> clazz) {
return ctx.getBean(clazz);
}
}
// 方式三:别用静态,改成正常注入(最终方案)
@Component
public class SmsSender {
private final SmsClient smsClient;
@Autowired
public SmsSender(SmsClient smsClient) {
this.smsClient = smsClient;
}
public void send(String phone, String content) {
smsClient.send(phone, content);
}
}
师傅的说法是:"想用静态方法本质上是你想少写一行注入,代价是把自己绕进 Spring 的生命周期外面去了。别这么干。"我后来把项目里所有 ApplicationContextAware 的 holder 都删了。
原因四:多个实现类,Spring 不知道选哪个
第四次的报错信息不一样,是启动失败:
org.springframework.beans.factory.NoUniqueBeanDefinitionException:
No qualifying bean of type 'com.xxx.PaymentService' available:
expected single matching bean but found 2: alipayService, wechatPayService
代码:
public interface PaymentService {
void pay(Order order);
}
@Service
public class AlipayServiceImpl implements PaymentService { ... }
@Service
public class WechatPayServiceImpl implements PaymentService { ... }
@Service
public class OrderService {
@Autowired
private PaymentService paymentService; // 两个实现,选谁?
}
三种解法:
// 1. @Qualifier 指定 bean 名
@Autowired
@Qualifier("alipayService")
private PaymentService paymentService;
// 2. 字段名和 bean 名一致(Spring 的 byName 兜底策略)
@Autowired
private PaymentService alipayService; // 变量名就是 bean 名
// 3. 其中一个加 @Primary
@Service
@Primary
public class AlipayServiceImpl implements PaymentService { ... }
我们最后是这么做的——用 Map 把所有实现收集起来,按类型分发:
@Service
public class PaymentRouter {
private final Map<String, PaymentService> handlers = new ConcurrentHashMap<>();
@Autowired
public PaymentRouter(List<PaymentService> services) {
for (PaymentService s : services) {
handlers.put(s.supportType(), s);
}
}
public void pay(String payType, Order order) {
handlers.get(payType).pay(order);
}
}
Spring 会把某个接口的所有实现类注入到一个 List 或 Map 里(Map 的 key 是 bean 名)。这算是策略模式在 Spring 下的标准写法,加新的支付方式只要新增实现类,不用改动路由代码。
另外几个容易忽略的情况
注解用错了。 我见过有人把 @Autowired 写在接口上,或者把 @Service 写成 @Component 导致 AOP 不生效(其实这两个都能注入,但语义不对)。注解的语义要分清:@Component 是通用的,@Service/@Repository/@Controller 是它的特化,除了语义清晰,@Repository 还额外提供了异常转换(把 SQLException 转成 Spring 的 DataAccessException)。
作用域不匹配。 把一个 prototype 的 bean 注入到 singleton 里,只会注入一次,后续拿到的还是同一个实例:
@Scope("prototype")
@Component
public class TaskContext { ... }
@Service
public class TaskService {
@Autowired
private TaskContext context; // 只注入一次,不是每次都新的
}
这个坑很隐蔽。要每次拿新实例,得用 ObjectProvider 或者 @Lookup 方法。
循环依赖。 Spring 能解决字段注入方式的循环依赖(靠三级缓存和提前暴露对象),但解决不了构造器注入的循环依赖:
Description:
The dependencies of some of the beans in the application context form a cycle:
orderService defined in file [OrderService.class]
↓
stockService defined in file [StockService.class]
↓
orderService
Caused by: org.springframework.beans.factory.BeanCurrentlyInCreationException:
Error creating bean with name 'orderService':
Requested bean is currently in creation: Is there an unresolvable circular reference?
遇到这个,正确的做法不是改回字段注入,而是重新设计依赖关系。我们那次是把两个 service 共同依赖的逻辑抽成了第三个 service。
排查清单
注入为 null 或者注入失败,按这个顺序查:
- 目标类上有没有
@Component/@Service等注解; - 它所在的包在不在启动类的扫描范围(用
ctx.containsBean()验证); - 使用方是不是自己
new出来的对象(包括在各种线程池的 Runnable 里 new 的); - 字段是不是
static; - 是不是有多个实现类导致歧义(看启动日志里的 NoUniqueBeanDefinitionException);
- 有没有循环依赖导致 bean 创建不完整。
还有个调试小技巧,把所有注册的 bean 名字打印出来看看:
@Component
public class BeanPrinter implements CommandLineRunner {
@Autowired
private ApplicationContext ctx;
@Override
public void run(String... args) {
String[] names = ctx.getBeanDefinitionNames();
Arrays.sort(names);
for (String name : names) {
System.out.println(name + " -> " + ctx.getType(name).getSimpleName());
}
}
}
orderService -> OrderServiceImpl
stockService -> StockServiceImpl
...
我排查那次 job 注入失败时,就靠这个脚本一眼看出 orderTimeoutJob 不在列表里,前后不到五分钟。要是早知道这个办法,能省掉一下午。