我们都知道AOP本质上就是一个代理模式,但是因为Spring使用了CGLIB来实现运行期动态创建Proxy,如果我们没能深入理解其运行原理和实现机制,就可能遇到各种诡异的问题。
1、举例反应现象
假设我们定义了一个UserService:
@Component
public class UserService {
// 成员变量:
public final ZoneId zoneId = ZoneId.systemDefault();
// 构造方法:
public UserService() {
System.out.println("UserService(): init...");
System.out.println("UserService(): zoneId = " + this.zoneId);
}
// public方法:
public ZoneId getZoneId() {
return zoneId;
}
// public final方法:
public final ZoneId getFinalZoneId() {
return zoneId;
}
}
再写个MailService,并注入UserService:
@Component
public class MailService {
@Autowired
UserService userService;
public String sendMail() {
ZoneId zoneId = userService.zoneId;
String dt = ZonedDateTime.now(zoneId).toString();
return "Hello, it is " + dt;
}
}
最后用main()方法测试一下:
@Configuration
@ComponentScan
@EnableAspectJAutoProxy
public class AppConfig {
public static void main(String[] args) {
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
MailService mailService = context.getBean(MailService.class);
System.out.println(mailService.sendMail());
}
}
查看输出,一切正常:
UserService(): init...
UserService(): zoneId = Asia/Shanghai
Hello, it is 2020-04-12T10:23:22.917721+08:00[Asia/Shanghai]
下面,我们给UserService加上AOP支持,就添加一个最简单的LoggingAspect:
@Aspect
@Component
public class LoggingAspect {
@Before("execution(public * com..*.UserService.*(..))")
public void doAccessCheck() {
System.err.println("[Before] do access check...");
}
}
再次运行,不出意外的话,会得到一个NullPointerException:
Exception in thread "main" java.lang.NullPointerException: zone
at java.base/java.util.Objects.requireNonNull(Objects.java:246)
at java.base/java.time.Clock.system(Clock.java:203)
at java.base/java.time.ZonedDateTime.now(ZonedDateTime.java:216)
at com.itranswarp.learnjava.service.MailService.sendMail(MailService.java:19)
at com.itranswarp.learnjava.AppConfig.main(AppConfig.java:21)
仔细跟踪代码,会发现null值出现在MailService.sendMail()内部的这一行代码:
@Component
public class MailService {
@Autowired
UserService userService;
public String sendMail() {
ZoneId zoneId = userService.zoneId;
System.out.println(zoneId); // null
...
}
}
我们在UserService中特意用final修饰了一下成员变量:
@Component
public class UserService {
public final ZoneId zoneId = ZoneId.systemDefault();
...
}
用final标注的成员变量为null?what happened?
2、原因分析
为什么加了AOP就报NPE,去了AOP就一切正常?final字段不执行,难道JVM有问题?为了解答这个诡异的问题,我们需要深入理解Spring使用CGLIB生成Proxy的原理:
第一步,正常创建一个UserService的原始实例,这是通过反射调用构造方法实现的,它的行为和我们预期的完全一致;
第二步,通过CGLIB创建一个UserService的子类,并引用了原始实例和LoggingAspect:
public UserService$$EnhancerBySpringCGLIB extends UserService {
UserService target;
LoggingAspect aspect;
public UserService$$EnhancerBySpringCGLIB() {
}
public ZoneId getZoneId() {
aspect.doAccessCheck();
return target.getZoneId();
}
}
如果我们观察Spring创建的AOP代理,它的类名总是类似UserService E n h a n c e r B y S p r i n g C G L I B EnhancerBySpringCGLIB EnhancerBySpringCGLIB1c76af9d。为了让调用方获得UserService的引用,它必须继承自UserService。然后,该代理类会覆写所有public和protected方法,并在内部将调用委托给原始的UserService实例。
当启用AOP时,从ApplicationContext中获取的UserService实例是proxy,注入到MailService中的UserService实例也是proxy。那么最终的问题来了:proxy实例的成员变量,也就是从UserService继承的zoneId,它的值是null。原因在于,proxy代理类没有初始化zoneId,因为proxy的目的是代理方法。
实际上,成员变量的初始化是在构造方法中完成的。这是我们看到的代码:
public class UserService {
public final ZoneId zoneId = ZoneId.systemDefault();
public UserService() {
}
}
这是编译器实际编译的代码:
public class UserService {
public final ZoneId zoneId;
public UserService() {
super(); // 构造方法的第一行代码总是调用super()
zoneId = ZoneId.systemDefault(); // 继续初始化成员变量
}
}
然而,对于Spring通过CGLIB动态创建的UserService$$EnhancerBySpringCGLIB代理类,它的构造方法中,并未调用super(),因此,从父类继承的成员变量,包括final类型的成员变量,统统都没有初始化。
我们可能会有疑问:Java语言规定,任何类的构造方法,第一行必须调用super(),如果没有,编译器会自动加上,怎么Spring的CGLIB就可以搞特殊?
这是因为自动加super()的功能是Java编译器实现的,它发现你没加,就自动给加上,发现你加错了,就报编译错误。但实际上,如果直接构造字节码,一个类的构造方法中,不一定非要调用super()。Spring使用CGLIB构造的Proxy类,是直接通过被代理类生成字节码,并没有源码-编译-字节码这个步骤。
那么如何修复呢?
3、避坑操作
修复很简单,只需要把直接访问字段的代码,改为通过方法访问:
@Component
public class MailService {
@Autowired
UserService userService;
public String sendMail() {
// 不要直接访问UserService的字段:
ZoneId zoneId = userService.getZoneId();
...
}
}
无论注入的UserService是原始实例还是代理实例,getZoneId()都能正常工作,因为代理类会覆写getZoneId()方法,并将其委托给原始实例:
public UserService$$EnhancerBySpringCGLIB extends UserService {
UserService target = ...
...
public ZoneId getZoneId() {
return target.getZoneId();
}
}
注意到我们还给UserService添加了一个public+final的方法:
@Component
public class UserService {
...
public final ZoneId getFinalZoneId() {
return zoneId;
}
}
如果在MailService中,调用的不是getZoneId(),而是getFinalZoneId(),又会出现NullPointerException,这是因为,代理类无法覆写final方法(这一点绕不过JVM的ClassLoader检查),该方法返回的是代理类的zoneId字段,即null。
实际上,如果我们加上日志,Spring在启动时会打印一个警告:
10:43:09.929 [main] DEBUG org.springframework.aop.framework.CglibAopProxy - Final method [public final java.time.ZoneId xxx.UserService.getFinalZoneId()] cannot get proxied via CGLIB: Calls to this method will NOT be routed to the target instance and might lead to NPEs against uninitialized fields in the proxy instance.
上面的日志大意就是,因为被代理的UserService有一个final方法getFinalZoneId(),这会导致其他Bean如果调用此方法,无法将其代理到真正的原始实例,从而可能发生NPE异常。
因此,正确使用AOP,我们需要一个避坑指南:
1、访问被注入的Bean时,总是调用方法而非直接访问字段;
2、编写Bean时,如果可能会被代理,就不要编写public final方法。
这样才能保证有没有AOP,代码都能正常工作。
最后提出几个问题并给出回答:
1、为什么Spring刻意不初始化Proxy继承的字段?
@Component
public class HelloServiceImpl implements HelloService {
@Value("${good.from:xxx}")
String goodFrom;
Good good;
@PostConstruct
public void init() {
good = new Good(goodFrom, ...);
}
public void sentMail(String to) {
...
}
}
因为通过上面的解读我们知道,cglib是直接生成代理类的字节码的,没有走预编译的过程,所以他的构造函数没有加上父类的super(),而编译过程中实际的初始化都是在构造函数中完成的,自然没办法初始化,另外就是有些变量需要通过依赖其他注入比如上面的goodForm和good,Spring生成的代理类怎么注入?所以说根本原因就是spring无法在逻辑上正常初始化proxy的字段,所以干脆不初始化,并通过NPE直接暴露出来。
另外还有一个次要原因就是如果对字段进行修改,proxy的字段其实根本没改:
@Component
public class MailService {
String status = "init";
public void sentMail(String to) {
this.status = "sent";
}
}
你想如果在代理类中sentMail,又会委托给原生类去调用,实际改的还是原生类的字段内容,所以只有原始Bean的方法会对自己的字段进行修改,他无法改proxy的字段。
2 如果一个Bean不允许任何AOP代理,应该怎么做来“保护”自己在运行期不会被代理?
将类设置为 final 的防止 cglib 创建Proxy,并且不继承接口防止 JDK 自带的动态代理。
以上内容主要转载自:https://www.liaoxuefeng.com/wiki/1252599548343744/1339039378571298 稍稍加入了一些个人的理解