JDK动态代理能代理final方法吗?final方法下的事务会失效吗?

303 阅读9分钟

这是一个很有意思的知乎问题,也是日常开发中比较少见的场景。能感觉出来,题主对 JDK 动态代理和 Cglib 动态代理的理解比较透彻,提出这个问题肯定有过自己的思考。本文比较长,我会从动态代理的实现机制出发,过渡到测试案例的设计,随后分析调试效果来完整的回答这个问题。

1. 动态代理的实现机制

我们先回顾一下两种动态代理方式的实现机制,并基于实现机制进行一个初步的分析。

1.1 JDK动态代理

JDK 动态代理需要被代理对象的所属类至少实现一个接口,因此 JDK 动态代理的实质是创建了一个与被代理对象具备全部(或部分)相同行为的新的实现类对象(也就是代理对象)

这里所谓的 “相同行为”指的就是对象所属类实现的接口方法(接口定义方法,方法即行为)

当程序调用这个具备相同行为的代理对象时,代理对象会在执行增强逻辑的过程中调用目标对象的原始方法,以此完成逻辑的增强。

所以发现了吗,JDK 动态代理的本质是内部包一层原始对象,在接口方法被调用时转发到目标对象中。那么与实现类方法上限定的 final 也就没有关系了,因为代理对象压根就没有考虑重写方法,而是内部调用

1.2 Cglib动态代理

与 JDK 动态代理不同,Cglib 动态代理的实现原理是字节码增强,而这种运行期字节码增强的实现方式是继承

这个方式不难理解,由于 Cglib 动态代理不要求被代理对象的所属类实现接口,因此对于一个没有任何接口实现的类而言,运行期扩展的方法就只有继承这个类,才能具备这个类的所有行为

基于 Cglib 动态代理生成的代理对象,所有的行为增强都需要建立在重写方法的前提下,那么这就出现问题了,被限定为 final 的方法是无法重写的,因此 Cglib 动态代理不能对这些 final 方法增强

简单回顾了实现机制,我们的分析是否正确?下面用实践来检验答案的对错。

2. 测试代码制作

为了构造一个可以测试 JDK 动态代理和 Cglib 动态代理的案例,我们可以基于 SpringBoot 搭建一个简单的测试工程,并引入 AOP 和 JDBC 相关模块。

本文涉及的测试代码可在 GitHub 仓库中找到:github.com/LinkedBear/…

2.1 工程搭建

本文配套工程使用的 SpringBoot 版本为 3.2.12 ,基于 2.x 的 SpringBoot 同样可以测试。导入的核心依赖包括 WebMvc 、AOP 、JDBC 以及方便临时测试用的 H2 内存数据库。

 <dependencies>
     <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-web</artifactId>
     </dependency>
     <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-aop</artifactId>
     </dependency>
 ​
     <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-jdbc</artifactId>
     </dependency>
     <dependency>
         <groupId>com.h2database</groupId>
         <artifactId>h2</artifactId>
         <scope>runtime</scope>
     </dependency>
 </dependencies>

对应的配置文件中,我们需要声明连接的数据源,以及配置一些 H2 内存数据库的选项。

这其中有一点要注意,由于 SpringBoot 2.x 开始默认全局使用 Cglib 动态代理,因此需要在配置文件中取消这个设定,即设定 spring.aop.proxy-target-class=false

 spring.datasource.driver-class-name=org.h2.Driver
 spring.datasource.url=jdbc:h2:mem:testdb
 spring.datasource.username=sa
 spring.datasource.password=sa
 ​
 spring.h2.console.settings.web-allow-others=true
 spring.h2.console.path=/h2
 spring.h2.console.enabled=true
 ​
 # 禁止强制使用Cglib动态代理
 spring.aop.proxy-target-class=false

2.2 初始化数据库的脚本

H2 内存数据库在启动时需要初始化一些数据,我们可以借助 SpringBoot 的脚本初始化机制实现,分别在 resources 目录下新建一个 schema.sqldata.sql 即可,并在其中编写建表语句和初始化数据的 SQL 语句。

 CREATE TABLE tbl_dept (
     id int NOT NULL AUTO_INCREMENT PRIMARY KEY,
     name varchar(32) DEFAULT NULL,
     pid int DEFAULT NULL
 );
 delete from tbl_dept;
 insert into tbl_dept (name, pid) values ('总公司', 0);
 insert into tbl_dept (name, pid) values ('第一分公司', 1);

2.3 核心测试代码

基本环境和数据库都准备好了,下面是核心的测试代码,我们分别制作一个基于 JDK 动态代理的 JdkService 和基于 Cglib 动态代理的 CglibService ,并在其中编写一个会抛出异常的数据库写操作方法 addDept 。如果 addDept 方法事务生效,那么数据库中应该不会产生新的数据。

 public interface JdkService {
     void addDept(String name, Integer pid);
 }
 @Service
 public class JdkServiceImpl implements JdkService {
     
     @Autowired
     private JdbcTemplate jdbcTemplate;
     
     @Override
     @Transactional(rollbackFor = Exception.class)
     public final void addDept(String name, Integer pid) {
         jdbcTemplate.update("insert into tbl_dept (name, pid) values (?, ?)", name, pid);
         int i = 1 / 0;
     }
 }
 @Service
 public class CglibService {
     
     @Autowired
     private JdbcTemplate jdbcTemplate;
     
     @Transactional(rollbackFor = Exception.class)
     public final void addDept(String name, Integer pid) {
         jdbcTemplate.update("insert into tbl_dept (name, pid) values (?, ?)", name, pid);
         int i = 1 / 0;
     }
 }

最后我们补充一个可以调用 Service 的 TestController 类,之所以选它而不是单元测试,是基于 Web 应用可以随时查看 H2 数据库中的数据。

 @RestController
 public class TestController {
     
     @Autowired
     private JdkService jdkService;
     
     @Autowired
     private CglibService cglibService;
     
     @GetMapping("/testJdk")
     public void testJdk() {
         jdkService.addDept("JDK", 1);
     }
     
     @GetMapping("/testCglib")
     public void testCglib() {
         cglibService.addDept("Cglib", 1);
     }
 }

3. 观察效果

下面我们来观察效果,当应用启动完毕后,此时访问 http://localhost:8080/h2 ,会要求我们登录 H2 数据库的后台,按照配置文件的内容登录后,可以看到当前 tbl_dept 表的数据有两条,且就是 data.sql 中的那两条。

image-20250625211053990.png

接下来就开始测试,首先调用 /testJdk 接口,发现浏览器中响应 500 错误,IDEA 的控制台中提示除零异常,与此同时数据库中并没有新的数据产生,说明事务生效,JDK 动态代理的确对 final 方法正确进行了增强

image-20250625211251461.png

我们再来测试 Cglib 增强的效果,调用 /testCglib 接口,浏览器中同样响应 500 错误,但是此时 IDEA 的控制台中提示的错误是 NPE ,也就是空指针异常!这个测试结果可能出乎我们的意料,明明在 JDK 动态代理中可以正常注入的,Cglib 动态代理的对象还有依赖注入错误的问题?

image-20250625211428704.png

带着这些问题,下面我们换用 Debug 启动的方式,观察一下问题。

4. Debug观察

4.1 发现问题

我们把端点打在 CglibServiceaddDept 方法上,随后在浏览器中发送一次请求使程序停在断点,可以发现当前 this 对象的确是被 Cglib 动态代理过的增强对象,但是这个对象的 jdbcTemplate 属性却是 null !这是为什么呢?

image-20250625211822141.png

4.2 注入有问题吗

可能这时会有小伙伴好奇,因为我写了一个 final 方法,导致 Cglib 动态代理过的对象连依赖注入都有问题了吗?我们可以在 CglibService 中再增加一个方法 addDept2 ,这个方法不标注为 final 方法。相应的,TestController 中也要增加对应方法的调用。

 @Transactional(rollbackFor = Exception.class)
 public void addDept2(String name, Integer pid) {
     jdbcTemplate.update("insert into tbl_dept (name, pid) values (?, ?)", name, pid);
     int i = 1 / 0;
 }

如果我们调用 /testCglib2 接口,并把断点打在 addDept2 方法的第一行,会发现此时 jdbcTemplate 又有值了,并且此时 this 就是 CglibService 这个 bean 对象!

image-20250625212355907.png

为什么会出现这种奇怪的现象?这两个方法的调用有什么区别?

4.3 观察调用栈

下面我们观察一下断点停住时左侧的调用栈:

image-20250625212919574.png

发现区别了吗?调用 testCglib 方法时,调用链路直接从 TestController 走到 CglibService ,调用的是 CglibService 的代理对象,但栈帧中显示的却是 CglibServiceaddDept 方法,很明显这就是调用的 CglibService 的那个被标记为 finaladdDept 方法。反观右侧调用 testCglib2 方法时,调用链路中包含 AOP 的过程、事务切面的逻辑,最终通过反射走到了目标对象 CglibService 中,也就是我们理解的正确的过程。

而出现这个的原因就是 CglibServiceaddDept 方法无法被重写,调用 CglibService 的代理对象的 addDept 方法会直接来到 CglibService 的方法体中,而结合右边 addDept2 方法被调用时的 Debug 截图,可以发现 CglibService 对象本身其实是有正确的 JdbcTemplate 对象注入,只是 CglibService 的代理对象没有注入。

4.4 观察代理对象的结构

我们再观察一个现象,当我们把 CglibService 的代理对象的内部展开,会发现其中包含一个 TargetSource 对象,其内部就是 CglibService 的原始对象!这个原始对象中有正确注入的 JdbcTemplate 对象!而且这个对象就是上面图中 CglibService 的那个原始对象!

image-20250625213724412.png

这个也不难理解,本来代理对象 = 目标对象 + 通知,所以目标对象本来就是代理对象的基础。另外基于 Cglib 动态代理的对象本身是继承自目标对象,因此其内部也就有目标对象的所有成员属性,只不过这些成员属性的值都不在代理对象中,而是在原始对象,导致调用 this.jdbcTemplate 出现 NPE 。

通过上面的 Debug 结果,小伙伴有没有对 1.2 节中 Cglib 动态代理的实现机制有一个直观地感受?核心原因就是 Cglib 代理基于继承,而 final 方法无法继承,导致无法增强

5. 为什么JDK动态代理对象没问题

下面我们再探究第二个问题:为什么基于 JDK 动态代理生成的代理对象,在代理 final 方法时依然可以生效?

这个问题回答起来就简单多了,我们同样以 Debug 的方式观察 JDK 动态代理产生的代理对象,如下图所示。很明显这个代理对象从表面上看,跟 JdkService 的确没有直接的关联关系,即便我们借助 IDEA 探测这个代理对象的类型,也只能看出它是一个 Proxy 类型的对象。最起码这跟继承没有半点儿关系了。

image-20250625222528241.png

那 JDK 动态代理对象是怎么调用的目标对象呢?答案很简单:反射。既然代理对象的内部包含了目标对象,那么触发原始逻辑的方式也就是反射了。通过追踪调用方法栈,可以找到下图的反射调用切入点(即目标方法)的反射调用。那既然是反射调用,那也就与方法重写没有任何关系了。

image-20250625223317109.png

6. 最终结论与指导意见

通过前面好长的分析,我们可以得出最终结论:基于 JDK 的动态代理可以代理 final 方法,final 方法下的事务不会失效;基于 Cglib 的动态代理无法代理 final 方法,强行调用时可能会引发 NPE 问题

至于实际情况该怎么办:

  • 如果你的项目强制开启了 Cglib 动态代理,那就不要写 final 方法了
  • @EnableTransactionManagement 注解上也有一个 proxyTargetClass 属性,这个属性如果设置为 true ,则也不要写 final 方法
  • 如果都没有强制开启 Cglib 动态代理,那么如果遇到必须要声明 final 方法时,给这个方法抽取一个接口,并使用 JDK 动态代理