标题里那个 O(2),是我给自己排查归档用的编号,第二例。这例留给 AOP:项目里上了 AOP 之后,线上开始出现 NullPointerException,而且特征非常反直觉——业务代码一行没改,异常却从“切面内部”慢慢转移到“业务方法内部”。这篇记录会完整复盘两次 NPE 的定位过程,把 AOP 触发的根源和后续预防动作都写清楚。
如果你是第一次在团队里引入 AOP,或者手头正好遇到“加了切面就报空指针”的怪事,这篇文章可以直接当排查手册用。全文不堆源码,但会尽量把 Spring 在底层做了什么讲明白,毕竟这类问题不搞清楚原理,光靠试配置很难稳定复现。
1. 现象确认:第一次NPE在切面类,修完第二次又出现在业务方法
先说第一次。订单服务平时接口都很稳,某次迭代为了做接口耗时统计,我加了一个自定义统一切面,拦截业务层所有 public 方法,在方法执行完后打印耗时,顺便统计返回结果里的行数。上线一周后,监控平台开始报警,频率不高,每天十几次,但异常栈十分扎眼。
1.1 第一次异常栈:切面对返回值下手太重
当时的切面长这样:
@Aspect @Component @Slf4j public class ApiMetricAspect { @Around("execution(public * com.example.order.service..*.*(..))") public Object around(ProceedingJoinPoint pjp) throws Throwable { Object result = pjp.proceed(); if (result instanceof PageResult) { PageResult pageResult = (PageResult) result; log.info("method={}, rows={}", pjp.getSignature().getName(), pageResult.getRows().size()); } return result; } }第一次的异常栈长这样:
java.lang.NullPointerException at com.example.order.aspect.ApiMetricAspect.around(ApiMetricAspect.java:47) at com.example.order.service.OrderQueryServiceImpl.queryList(...) at com.example.order.controller.OrderController.query(...)注意栈顶就是切面类自己的代码,这说明 NPE 不是业务方法抛出来的,而是切面里那段pageResult.getRows().size()抛出来的。定位到这一行,原因基本就清楚了:某个业务方法在特定查询条件下正常返回了PageResult,但其中的rows列表是 null,切面代码没判空,直接调.size(),NPE 当场爆掉。
这个案例非常简单,修复也就是加一个rows != null的判断。真正让我警觉的是第二周出现的第二次问题——它居然绕开了切面内部的代码,直接打到了业务方法的深处。
1.2 第二次异常栈:业务方法内部炸了
第二次的调用路径是这样的:
java.lang.NullPointerException at com.example.order.service.OrderServiceImpl.getCacheKeys(OrderServiceImpl.java:42) at com.example.order.aspect.ApiMetricAspect.around(ApiMetricAspect.java:31) at com.example.order.service.OrderServiceImpl.queryList(...) ...栈顶是OrderServiceImpl.getCacheKeys(),第二层才是切面类。从栈上看,切面代码执行pjp.proceed()后,正常的业务方法入栈,但这个方法内部访问了一个为 null 的字段,所以 NPE 发生在业务方法里。
而且getCacheKeys()这个方法已经跑了半年,没有 AOP 之前从来没有问题。这就不太对劲了:同样的业务代码,为什么加了切面之后,内部的依赖字段会变成 null?
1.3 “AOP一边关一边开”的验证法
为了确认 AOP 就是触发因素,我用了最朴素也最有效的验证方法:给切面加开关,灰度环境动态关掉。
@Aspect @Component @ConditionalOnProperty(name = "app.aop.api-metric.enabled", havingValue = "true", matchIfMissing = true) public class ApiMetricAspect { // 切面逻辑 }把配置项app.aop.api-metric.enabled置为false,发布一版观察两天,NPE 消失;重新置回true,问题又复现。到这一步,可以排除业务数据随机性问题,问题一定出在切面组件和 Spring 容器的交互上。
提醒一句:关切面只是排查手段,不是修复手段。如果切面里还承担了鉴权、脱敏这类强功能,贸然关闭可能引入更严重的问题。操作之前先确认当前切面只做了打点和耗时统计这类旁路逻辑。
2. 排查第一站:把NPE定位到“代理层”还是“业务层”
很多同学遇到这类问题,第一反应是怀疑 Spring 或 CGLIB 出了问题。但从我踩坑的经验看,绝大多数 NPE 反而出在自己写的代码里,区别只在“栈顶位置”。所以排查第一步不是看源码,而是把异常栈里每一层都摊开,判断 NPE 到底发生在哪个对象上。
2.1 区分切面栈顶和业务栈顶
把两次异常栈放在一起比较,可以快速得到两个方向:
| 异常栈位置 | 大概率原因 | 处理方向 |
|---|---|---|
栈顶在xxx.Aspect类 | 切面自己对入参、返回值做了未判空处理 | 检查切面里的链式调用、拆箱、强转 |
| 栈顶在被代理的业务实现类 | 切面改变了业务方法的调用时机或调用者 | 检查切点是否覆盖了 Bean 初始化阶段的调用 |
栈中出现CGLIB$$、$Proxy且调用链里有 Bean 工厂 | 代理对象包裹了尚未完成依赖注入的 Bean | 重点排查循环依赖和早期引用 |
第一次的栈明显属于第一种:代码在切面里炸的,业务方法是无辜的。第二次的栈属于第二种,切面看起来只是老老实实调用了pjp.proceed(),但业务方法内部访问到的依赖是 null,这就需要往 Spring Bean 的创建过程方向查。
2.2 打印 this 和 target,判断代理关系
在切面代码里临时加几行日志,能快速确认当前对象是不是代理:
Object target = pjp.getTarget(); Object thisObj = pjp.getThis(); log.info("target = {}", target.getClass().getName()); log.info("this = {}", thisObj.getClass().getName()); log.info("isCglibProxy = {}", AopUtils.isCglibProxy(thisObj)); log.info("ultimateTargetClass = {}", AopProxyUtils.ultimateTargetClass(thisObj));如果target的 class 正常,this的 class 带着$$EnhancerBySpringCGLIB$$字样,说明外部持有的是代理对象,真正执行业务逻辑的是代理背后的目标对象。Spring 官方也提供了现成的判断工具:AopUtils.isAopProxy(...)、AopUtils.isCglibProxy(...)、AopUtils.isJdkDynamicProxy(...),排查阶段多打一行这样的日志,比盯着异常栈瞎猜高效得多。
2.3 异常栈里出现过哪些 Spring 生命周期类
第二个关键信号是:把异常栈所有at ...行完整展开,看看有没有这些类:
AbstractAutowireCapableBeanFactoryDefaultSingletonBeanRegistryAutowiredAnnotationBeanPostProcessorAbstractBeanFactory
只要有这些类出现在调用链里,基本可以断定这一次调用发生在“Spring 容器创建 Bean 的过程中”,而不是某个正常接口请求线程里。
这一点很重要:常规请求进来时,容器里的 Bean 都已经初始化完毕,依赖注入已经全部完成,字段不可能因为“还没注入”而变成 null。但在 Bean 创建过程中,字段是允许暂缺的。二次排查时,我把完整栈继续往上翻,发现 NPE 的触发源头不是 Tomcat 线程,而是finishBeanFactoryInitialization阶段进入某个InitializingBean的回调,是容器启动阶段触发了这次不该有的业务方法调用。
2.4 用条件断点观察“半成品”状态
在ApiMetricAspect.around里打一个断点,条件写:
pjp.getSignature().getName().equals("getCacheKeys")命中后在调试窗口查看pjp.getTarget()指向的原始对象,能看到类似orderMapper = null、cacheTemplate = null的字段状态。而正常请求线程里,同一个对象的这些字段全部有值。
到这一步,问题定性已经很清晰了:目标方法被调用时,目标对象还处于依赖未填充的中间状态。接下来要回答的问题只有一个:为什么切面会让一个正常对象变成“半成品”?
3. 为什么AOP能改变Bean的初始化时序
这也是我最想重点讲的部分。AOP 最容易被忽略的副作用,不是多了一次方法拦截,而是它改变了 Spring 对 Bean 的“创建节奏”。
3.1 一个正常 Bean 的诞生过程
Spring 容器里,一个普通的、没有被 AOP 关照过的 Bean,大致经历这几个阶段:
| 阶段 | 发生的动作 | 此时依赖是否完整 |
|---|---|---|
| 实例化 | 调用构造函数 new 出对象 | 字段全部为 null |
| 属性填充 | 依赖注入,setter 或字段赋值 | 逐步完整 |
| Aware 回调 | 调用 setBeanName、setApplicationContext 等 | 基本完整 |
| 初始化回调 | @PostConstruct、afterPropertiesSet、init-method | 完整 |
| BeanPostProcessor 后置处理 | AOP 代理生成逻辑在这里 | 完整 |
| 投入容器 | 外部开始正常调用 | 完整 |
关键点在于最后两步:正常情况下,代理对象是在“依赖全部填充完成、初始化回调执行完毕”之后才生成的。也就是说,常规请求中你拿到的代理对象背后,是一个依赖完整的业务对象。
3.2 循环依赖时,Spring 被迫提前“发货”
如果 Bean 之间没有循环依赖,上面这套流程严格执行,不会出任何岔子。但订单服务里恰好存在OrderService和RedisCacheWarmer的相互依赖:
OrderService创建时需要注入RedisCacheWarmer;RedisCacheWarmer创建时需要注入OrderService。
Spring 遇到这种情况,会启用三级缓存逻辑:
OrderService先执行构造函数,得到一个半成品实例;- 半成品被放进第三级缓存,包装成一个
ObjectFactory; - Spring 尝试给
OrderService做属性填充,发现需要RedisCacheWarmer,转而创建它; RedisCacheWarmer开始填充依赖,发现自己需要OrderService;- 此时
OrderService还没创建完,Spring 从第三级缓存取出ObjectFactory,执行后得到一个“早期引用”交给RedisCacheWarmer。
如果没有 AOP,早期引用就是那个半成品原始对象。它之后还会继续被填充其它依赖,直到最终成为一个完整单例。这在大多数情况下并不致命,因为只要RedisCacheWarmer自己先完成初始化,后面再调用OrderService时,OrderService差不多也已经完整了。
3.3 加了 AOP 之后,早期引用变成了“半成品的代理”
问题就出现在第 5 步之后。Spring 在生成早期引用时会多问一句:这个 Bean 是不是被切点匹配到了?如果是,AbstractAutoProxyCreator就会当场生成一个 CGLIB 代理对象,把这个早期引用包装起来再交出去。
于是RedisCacheWarmer手里拿到的不是原始半成品,而是“半成品对象的代理”。这时候如果RedisCacheWarmer在自己的初始化阶段调用了orderService.getCacheKeys(),实际链路就变成了:
- 外部持有的是 CGLIB 代理;
- 代理把调用转发给内部那个还没完成依赖填充的
OrderService原始对象; getCacheKeys()内部访问orderMapper,而orderMapper此刻还是 null;- NPE 在业务方法内部炸开。
这就是第二次异常栈里,栈顶是业务方法、第二层是切面类的原因。AOP 并没有改变业务方法的代码,它改变的是这个 Bean 被调用的时机:从“依赖完整之后”提前到了“依赖补完之前”。
3.4 一个不严谨但好懂的生活类比
可以把这个过程想成装修房子。正常情况下,房子装修完、家具进场、保洁做完,保安才上岗。AOP 相当于把“保安”提前派到了一座毛坯房里。这时候有业主来找保安要钥匙,保安手里什么都没有,只能两手一摊,对应到程序里就是NullPointerException。
没有 AOP 时,虽然房子的施工过程也比较曲折,但起码“访客”拿到的还是施工队自己的联系人,打电话询问时施工队至少知道现场情况;有了 AOP,访客联系的是保安,而保安并不知道房屋内部装修细节,一旦试图访问内部设施,就会暴露问题。
3.5 一个必要的澄清
必须说明:不是所有 AOP 引发的 NPE 都是初始化时序问题。如果切面只是对返回值做链式调用,那就是纯代码判空问题,和 Spring 生命周期无关。我在团队里看到的真实情况是:大部分 NPE 是第一种“判空不到位”,少部分是第二种“早期代理 + 初始化时机”。排查时先看栈顶,再决定要不要往容器原理的方向走,否则容易走弯路。
4. 两处根因落地:返回值假设与早期代理
这次排查看下来,我们实际处理了两个不同类型的根因。为了以后少踩坑,我把它们分开记录。
4.1 根因A:PageResult.getRows() 可能是 null
第一次 NPE 的直接原因非常简单:PageResult对象本身非空,但它的rows字段可能是 null。比如某些统计类查询只返回了总条数,没有返回明细列表,rows就是 null。切面里写了pageResult.getRows().size(),等价于在 null 上调用方法。
修复方式:
if (result instanceof PageResult) { PageResult pageResult = (PageResult) result; if (pageResult.getRows() != null) { log.info("method={}, rows={}", pjp.getSignature().getName(), pageResult.getRows().size()); } }这是治标方案。更稳健的做法是统一走一个工具方法,比如ResultAssert.hasData(pageResult),让所有切面都通过同一个工具类做判空,避免以后再有人写类似代码。
4.2 根因B:预热回调踩到了早期代理
第二次 NPE 的链路是这样的:RedisCacheWarmer实现了InitializingBean,在afterPropertiesSet()里调用orderService.getCacheKeys(),想提前把缓存数据准备出来。而OrderService和RedisCacheWarmer之间存在循环依赖,同时OrderService的 public 方法又被切点表达式全部命中。
于是RedisCacheWarmer手里拿到的是OrderService的早期 CGLIB 代理,调用getCacheKeys()时,目标对象还缺orderMapper,NPE 顺理成章发生。
4.3 修复B:收窄切点 + 打破循环依赖
针对根因 B,我做两件事。
第一件,收窄切点。原切点写法太宽了:
@Around("execution(public * com.example.order..*.*(..))")这个写法把订单模块下所有类的所有 public 方法都纳入了切面,包括初始化回调期间触发的方法。改成了注解驱动:
@Around("@annotation(com.example.order.annotation.ApiMetric)")只有显式标注了@ApiMetric的方法才会被切面拦截。Controller 层和真正需要统计的核心 Service 方法加上注解就行,容器启动早期那些内部回调方法天然不会被匹配到。
第二件,打破OrderService和RedisCacheWarmer之间的循环依赖。最简单的方式是加@Lazy:
@Component public class RedisCacheWarmer implements InitializingBean { @Lazy @Autowired private OrderService orderService; @Override public void afterPropertiesSet() { orderService.getCacheKeys(); } }@Lazy会让orderService这个引用在真正调用时才去容器中解析,而不是在创建阶段强绑早期代理。更彻底的做法是调整依赖方向,把预热逻辑从afterPropertiesSet()挪到ApplicationReadyEvent事件里,等容器完全启动后再执行,这样就不会在任何 Bean 的半成品阶段触发业务调用了。
4.4 修复后如何验证
修复不是改完代码就完事,我一般按三步验证:
- 启动期验证:在本地启动应用,观察日志中是否还有
RedisCacheWarmer.afterPropertiesSet相关的 NPE; - 回归验证:压测脚本跑一遍订单查询链路,确认切面统计日志正常输出,且没有新的空指针;
- 灰度验证:灰度环境保留 AOP 开关打开状态,观察一周,确认监控平台上的 NPE 数量归零。
同时保留了一个检查手段:启动成功后,手动查询ApplicationContext里OrderService的实例 class,确认它确实是最终完整对象的代理,而不是早期代理。这个可以通过 Actuator 的 Bean 信息端点查看,也可以用一段临时代码验证:
AopUtils.isCglibProxy(applicationContext.getBean(OrderService.class))这类代码只放在排查期,跑完就删。
5. 复盘:AOP场景下NPE的成因地图与排查顺序
两次问题修完,我把团队里见过的所有 AOP 相关 NPE 做了一张速查表。以后再有类似告警,优先按表对号入座。
5.1 成因速查表
| 成因 | 表象 | 定位要点 | 处理方向 |
|---|---|---|---|
| 切面对返回值未判空 | 栈顶在 Aspect 类 | 链式调用返回值字段 | 判空、统一工具类兜底 |
| 切点过宽命中初始化期调用 | 启动阶段或预热阶段偶发 NPE | 调用链里有 Bean 生命周期类 | 收窄切点,优先注解匹配 |
| 循环依赖 + 早期代理 | 业务方法内部访问依赖字段为 null | 调用链在容器创建过程,target 字段半空 | 打破循环依赖,预热延后 |
| 切面类自身依赖未注入 | 切面内部调用其它 service 时报 NPE | 切面类是否被 Spring 管理 | 确认@Component或配置扫描 |
| 多个切面顺序混乱 | 切面链中前一个切面修改了返回值,后一个切面拿到 null | 查看切面@Order | 明确切面执行顺序 |
5.2 团队AOP接入规约
这次之后,我给我们团队定了几条规约,简单但实用:
- 切点表达式遵循“最小暴露面”原则,优先使用
@annotation或@within,避免写execution(* com.example..*.*(..))这种包级通配; - 新切面必须提供开关,默认打开没关系,但必须能一键关闭;
- 切面内禁止对返回值做无条件的解包、拆箱、链式调用;
- 如果要对返回值做统计,统一走公共工具方法,禁止在切面里临时写判断逻辑;
- 一个目标对象命中多个切面时,必须通过
@Order明确执行顺序,避免后执行的切面拿到被前一个切面改过的 null 值; - 新增切面时需要同步准备一个最小启动自测用例,确保容器启动过程中不会触发切面逻辑。
5.3 那些容易被误判的“AOP问题”
排查过程中有两个坑,非常容易误判成 NPE 问题,顺便记录一下:
第一个是自调用问题。同一类内部this.method()调用是不经过代理的,所以切面不会生效。这并不是 NPE,但表现可能是“切面统计少了数据”,容易让人误以为哪里的引用为 null。排查时可以先确认调用方是不是this。
第二个是 CGLIB 对 final 方法无法代理。如果某个方法被 final 修饰,代理对象无法重写它,切面自然拦不到。这和 NPE 没有直接关系,但调试时很容易让人怀疑代理对象内部是空的,浪费不少时间。判断方法很简单:查看方法修饰符。
6. 排查这类问题我常用的操作清单
最后整理一份排查操作清单,都是我实际用过的,不是理论建议。
6.1 条件断点,优先于全局日志
在切面类里打条件断点,条件表达式直接写方法名匹配,命中后不用翻日志,就能在调试窗口看到目标对象的所有字段状态。特别是要看“字段是否已注入”时,条件断点比日志高效得多。
6.2 打开 Spring Framework 的 debug 日志
排查循环依赖问题时,可以临时在配置里开启:
logging.level.org.springframework.beans.factory=debug logging.level.org.springframework.aop=debug日志里能看到 Bean 的创建顺序、早期引用是否生成代理,信息量非常大。排查完记得关掉,别留着刷日志。
6.3 用 Actuator 查看 Bean 依赖和代理信息
Spring Boot Actuator 的beans端点能列出所有 Bean 的依赖关系和类名。如果某个 Bean 的 class 名里带$$EnhancerBySpringCGLIB$$,说明它已经被代理了。配合heapdump端点还能进一步分析内存里的对象状态,尤其在偶发问题上很管用。
6.4 给切面加开关,灰度隔离
这一点前面已经提过,但值得重复。AOP 类问题有一个共性:线上偶发,本地难复现。加一个@ConditionalOnProperty开关,就能在灰度环境快速做“开/关”对照实验,把问题范围缩小到切面本身,省去大量猜测。
6.5 每次修完顺手补一个回归用例
AOP 相关 bug 最大的特点是:别说没经验的同事了,就是本人,过两个月再回来看这个切面也不一定记得清逻辑。所以修复后我会在测试工程里补一个针对切面判空或时序的回归用例,让这个坑变成一个不会遗忘的“已知问题”。
排查这类问题给我最大的体会是:NPE 并不总意味着“某个变量忘了赋值”,有时候它意味着“你改变了一个对象的创建时序”。AOP 的定位思路不能只盯着切面里的业务逻辑,更要审视切点覆盖了什么、代理是在哪个阶段生成的、被切入的这个方法是否可能在一个尚未就绪的 Bean 上执行。把这几个问题想清楚,大部分 AOP 引发的空指针问题都能快速收口。