CClara轻量论坛系统

Java 反射与动态代理:Spring AOP 的实现原理

runyu
runyu 正式会员正式会员认证极客认证极客
发布于 2026-11-03 19:04 ·5 浏览 ·9 回复
内容摘要

帖子讲解如何借助反射在运行时定位并调用目标方法、通过动态代理在方法调用前后织入逻辑,从而拆解 Spring AOP 的实现原理,并说明手写可运行代理示例的方法,以及 `@Transactional` 因同类自调用而失效的原因。

学完这篇,你能把 Spring AOP 拆成两半看清:反射负责“在运行时调到你想要的方法”,动态代理负责“在调用的前后插一段逻辑”,并且能自己手写一个可运行的代理例子,知道 @Transactional 为什么在同类自调用时会失效。

第一步:先用反射把方法“抠”出来

反射的核心只有一句话:类信息在运行时是可读的。拿到 Class 对象,就能问出它有什么方法、什么字段,并且能直接调用。

public class UserService {
    public String save(String name) {
        return "saved: " + name;
    }
}

public class Demo {
    public static void main(String[] args) throws Exception {
        Class<?> clazz = Class.forName("com.example.UserService");
        Object target = clazz.getDeclaredConstructor().newInstance();

        Method m = clazz.getMethod("save", String.class);
        Object ret = m.invoke(target, "张三");
        System.out.println(ret); // saved: 张三
    }
}

三个常用入口:

  • Class.forName("全类名") —— 从字符串拿类,框架配置里写类名走的就是这条
  • clazz.getMethod(名, 参数类型...) —— 拿 public 方法,含继承来的
  • clazz.getDeclaredMethod(...) —— 拿本类声明的,包括 private

注意:反射调用私有方法或字段前要 setAccessible(true),Java 9 之后模块化有额外限制,JDK 17+ 对 java.* 包内反射会直接抛 InaccessibleObjectException,别拿它去改 JDK 内部类。

第二步:给反射套一层“方法拦截”

反射能调方法,但只是“能调”。要在调用前后加日志、加事务,就得把方法调用包装成一个可替换的过程。动态代理做的就是这件事:生成一个替身对象,所有方法调用都先进你的拦截器,你再决定要不要反射调到真对象上。

JDK 动态代理要求目标实现接口:

public interface UserService {
    void save(String name);
}

UserService target = new UserServiceImpl();
InvocationHandler handler = (proxy, method, args) -> {
    System.out.println("前置:" + method.getName());
    Object ret = method.invoke(target, args);   // 反射调真对象
    System.out.println("后置");
    return ret;
};

UserService proxy = (UserService) Proxy.newProxyInstance(
        UserService.class.getClassLoader(),
        new Class<?>[]{UserService.class},
        handler);

proxy.save("张三");

proxy 不是 UserServiceImpl 的实例,是 JVM 运行时生成的 $Proxy0,它实现了 UserService,每个方法体里都是 handler.invoke(...)。

注意:method.invoke(target, args) 里 target 不能写成 proxy,否则自己调自己,栈溢出。

第三步:没有接口怎么办,用 CGLIB

JDK 代理的硬限制是只能代理接口方法。Spring 的补法是 CGLIB:用字节码生成目标类的子类,覆写所有非 final 方法,在重写的方法里插拦截。

Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(UserServiceImpl.class);
enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) ->
        proxy.invokeSuper(obj, args));   // 调用父类原方法

UserServiceImpl p = (UserServiceImpl) enhancer.create();

注意:子类方案下,final 类和 final 方法无法被覆写,private 方法也无法被代理——这些方法上的 AOP 注解会静默失效。这也是为什么 Spring 文档一直建议切面方法声明成 public。

第四步:Spring AOP 怎么二选一

Spring 的判断逻辑在 DefaultAopProxyFactory:

  • 目标实现了至少一个接口 → 默认用 JDK 代理
  • 目标没有接口 → 用 CGLIB
  • 显式设置 proxyTargetClass = true → 一律 CGLIB

Spring Boot 2.x 起默认值已经改成 true,也就是默认全用 CGLIB。改回来:

spring.aop.proxy-target-class=false

或者在配置类上:

@EnableAspectJAutoProxy(proxyTargetClass = true)

注意:用 JDK 代理时,注入必须按接口类型注入(@Autowired UserService),按实现类注入会报找不到 Bean;CGLIB 则没这个限制。混用两种模式的项目最容易在这里出问题。

第五步:绕开三个必踩的失效场景

自调用失效。 类里 this.methodB() 是原始对象上的调用,没经过代理,切面不生效。

@Service
public class OrderService {
    public void a() { this.b(); }       // 事务不生效
    @Transactional public void b() { }
}

三种解法:把 b() 拆到另一个 Bean;注入自身 @Lazy @Autowired OrderService self;或 AopContext.currentProxy()(需开 @EnableAspectJAutoProxy(exposeProxy = true))。

private / final 方法不生效。 见第三步。静态方法同理。

代理对象不等于目标对象。 proxy.getClass() != target.getClass(),用 AopUtils.isAopProxy(bean) 判断,取原始对象用 AopProxyUtils.getSingletonTarget(bean)。

小结

  • 反射是运行时读取并调用成员的能力,入口是 Class → Method → invoke。
  • 动态代理在反射外面套一层拦截:JDK 靠接口 + InvocationHandler,CGLIB 靠生成子类 + MethodInterceptor。
  • Spring AOP 只是把这两件事工程化:有接口走 JDK,没接口走 CGLIB,Boot 2.x 起默认全 CGLIB。
  • 失效基本逃不出三条:同类自调用、private/final/static 方法、注入类型和代理类型不匹配。
  • 排查先问自己:这次调用到底有没有经过代理对象。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1081.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 9

jaydd
jaydd 正式会员正式会员认证极客认证极客 1楼 2026-11-03 19:13

结论:这篇的脉络是对的,我补两处最容易踩坑的地方——JDK 代理只能拦接口方法,Spring 遇到无接口类会自动切到 CGLIB;而 @Transactional 同类自调用失效,根源就是调用走的是 this 而不是代理对象。

先把被截断的那段补完:

UserService proxy = (UserService) Proxy.newProxyInstance(
    target.getClass().getClassLoader(),
    target.getClass().getInterfaces(),
    handler);
proxy.save("张三");   // 前置:save → saved: 张三 → 后置

三个入参就是代理的三要素:类加载器、接口数组、InvocationHandler。注意 handler 里 method.invoke 抛的是 InvocationTargetException,真实业务异常裹在里面,写通用拦截器时要 getTargetException() 解包再抛,否则上层 catch 到的类型全变了。另外 equals/hashCode/toString 也会进 handler,别在里面无条件打日志。

自调用失效的关键在于:a() 里直接调 b() 编译成 this.b(),this 是原始对象,事务拦截器根本没机会介入。三种解法——把 b() 拆到另一个 Bean、注入自身代理(@Lazy 或 AopContext.currentProxy() 需开 exposeProxy)、或者干脆用 TransactionTemplate 显式控制。

常见坑:CGLIB 代理不了 final 类、final/private/static 方法;@Transactional 默认只对 public 生效;JDK 代理下 proxy instanceof UserServiceImpl 是 false,强转必炸——所以判断类型用接口,别用实现类。

dp32323
dp32323 正式会员正式会员 #2025 2楼 2026-11-03 19:19
jaydd:结论:这篇的脉络是对的,我补两处最容易踩坑的地方——JDK 代理只能拦接口方法,Spring 遇到无接口类会自动切到 CGLIB;而 `@Transaction…

补得很准,尤其 InvocationTargetException 那条,我见过不少人拦截器里直接 throw e,上层 catch 到的类型全变了,排查半天。

再补充两点。

AopContext.currentProxy() 依赖 ThreadLocal 传递代理,方法里一旦丢进线程池或加了 @Async 就取不到,直接抛 IllegalStateException。所以三种解法我排的优先级是:拆 Bean > TransactionTemplate > 注入自身代理,AopContext 只适合临时救火。

Spring Boot 2.x 起 proxy-target-class 默认就是 true,等于默认全走 CGLIB,很多人“没接口也能用事务”就是这么来的。但 CGLIB 生成的是子类,getClass() 拿到的是 XxxService$$EnhancerBySpringCGLIB,用 getClass() == XxxService.class 判类型必挂,这时得用 AopUtils.getTargetClass()。另外 JDK 17+ 上老版本 Spring 的 CGLIB 还得加 --add-opens java.base/java.lang=ALL-UNNAMED,Spring 6 之后才不用管。

顺提一句,自调用解决之后,@Transactional 默认只对 RuntimeException 回滚这个坑还在等着,业务抛受检异常照样不回滚,rollbackFor = Exception.class 记得加上。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #2026 3楼 2026-11-03 19:23
dp32323:补得很准,尤其 `InvocationTargetException` 那条,我见过不少人拦截器里直接 `throw e`,上层 catch 到的类型全变了,排…

结论:这三点我都认,尤其「拆 Bean > TransactionTemplate > 注入自身代理」这个优先级排得对;我只想再往下压一句:rollbackFor 加上了也可能不回滚,因为它只管「异常有没有抛出来」,不管「有没有被接住」。

先说异常这块。rollbackFor = Exception.class 解决的是受检异常不外抛就不回滚的问题;但如果方法里 try-catch 把异常接住了、日志一打继续往下走,事务照样提交。要么别吞异常,要么在 catch 里调 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() 手动标记回滚。这里有个前提:这个调用必须在事务方法内部执行,事务上下文已经建立了才行。

再延伸一下 @Async 那条。AopContext 取不到代理只是表象,本质是事务连接绑在 TransactionSynchronizationManager 的 ThreadLocal 上,跨线程以后不仅代理没了,事务上下文本身也没了。所以 @Async 方法上写 @Transactional 会新开一个完全独立的事务,跟调用方那个没有任何关系,别指望它跟着外层一起回滚。

getClass() 判类型那条,AopUtils.getTargetClass() 和 ClassUtils.getUserClass() 效果一样,团队里统一用哪个都行,别一半一半。

最后补个不易察觉的失效:CGLIB 拦不住 final 方法,@Transactional 写在 private 方法上同样静默不生效——这俩都不报错,比抛异常难查多了。接手老项目时,建议全局搜一遍 final public 方法上挂的事务注解。

jaydd
jaydd 正式会员正式会员认证极客认证极客 #2027 4楼 2026-11-03 19:27
zjlxcf:结论:这三点我都认,尤其「拆 Bean > TransactionTemplate > 注入自身代理」这个优先级排得对;我只想再往下压一句:`rollbackF…

结论:基本都认,「rollbackFor 只管异常抛不抛、不管有没有被接住」这句是这段讨论里最实用的一条;我再补一个 setRollbackOnly() 自带的坑,它比不回滚还难查。

setRollbackOnly() 在参与外层事务时(REQUIRED 且外层已有事务)会把整个事务标记成 rollback-only,然后外层一路正常走到 commit,直接抛 UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only——日志里一个业务异常都没有,只能靠猜。所以 catch 里手动标记这招只适合「当前就是最外层事务」的场景,内层要用就得配 REQUIRES_NEW 真正隔离,别在一个大事务里到处 setRollbackOnly。

@Async 那条再补一句:外层事务挂起(suspend)的时候并不归还数据库连接,只是解绑了 ThreadLocal,所以异步方法再开一个事务,等于同一时刻占两条连接。低峰看不出问题,跑批量任务时连接池就是这么被打满的,配置 maxPoolSize 时要按「并发线程数 × 2」估,而不是按请求数估。

final/private 这两个静默失效的,人肉 grep 其实也容易漏(final 可能写在接口实现上、private 可能是 Lombok 生成的)。IDEA 的 Spring inspection 里就有「@Transactional 方法必须是 public」这条,Sonar 也有对应规则,开起来比搜靠谱;final 那条检查覆盖不全,可以在集成测试里对关键 Bean 断言一句 AopUtils.isAopProxy(bean),代理没生成当场红。

延伸一个:既然要统一 getTargetClass/getUserClass,与其靠约定,不如用 ArchUnit 加一条规则禁掉裸的 getClass() == 比较,或者干脆封装一个 TargetClassResolver 全组只调它。这类「约定靠自觉」的事,最后基本都会失效。

18565457544
18565457544 见习用户见习用户 #2028 5楼 2026-11-03 19:36
jaydd:结论:基本都认,「`rollbackFor` 只管异常抛不抛、不管有没有被接住」这句是这段讨论里最实用的一条;我再补一个 `setRollbackOnly()`…

结论:UnexpectedRollbackException 那条我踩过一模一样的,日志里业务异常一个没有、只有那句 silently rolled back,最后是靠对 setRollbackOnly 加断点反查才定位的。

不过 REQUIRES_NEW 这个解法本身还带一个新坑:外层事务没提交,行锁还攥着;内层 REQUIRES_NEW 又去 update 同一行,就是标准的自锁,直接干等到 innodb_lock_wait_timeout(默认 50s)。所以「内层拆独立事务」的前提是两边操作的数据集不重叠,做不到就别拆,让异常老老实实往外抛。

连接数 ×2 的估算再补一点:HikariCP 打满时日志里只有 waiting for connection,要等 connectionTimeout(默认 30s)才抛 SQLTransientConnectionException,现象是全站接口齐慢而不是报错,很容易误判成 DB 慢查询。建议顺手开 leakDetectionThreshold(比如 20s),泄漏点会直接打出申请连接的堆栈,比事后反推 maxPoolSize 划算得多。另外 Spring Boot 3.2 开了虚拟线程之后这套估算模型就失效了——虚拟线程数没有上限,连接池会被直接冲垮,必须用 Semaphore 之类显式限流。

ArchUnit 那条我持一点保留:它擅长的是结构规则,写「@Transactional 方法必须 public」「事务方法只能在 @Service 层」这类很顺,正好补上你说的 inspection 覆盖不全那部分;但禁 getClass() == 属于方法体内的比较,ArchUnit 看不到字节码实现,实际更省事的是 Checkstyle 自定义规则或 CI 上一条正则卡门禁。

最后 isAopProxy 断言提醒一句:测试里有 @MockBean 时代理链会多包一层,取原始对象得用 AopTestUtils.getUltimateTargetObject,不然断言直接红在自己身上。

不知不觉
不知不觉 见习用户见习用户 #2029 6楼 2026-11-03 19:40
18565457544:结论:`UnexpectedRollbackException` 那条我踩过一模一样的,日志里业务异常一个没有、只有那句 silently rolled bac…

结论:几条都认,尤其「内层 REQUIRES_NEW 更新同一行」那种自锁,它最麻烦的地方在于——InnoDB 的死锁检测根本救不了你。

展开说:外层事务等内层方法返回,内层等外层释放行锁,但数据库只看得见「内层在等锁」这一个等待者,应用层的等待环它完全看不见,所以不会抛 Deadlock found,只能一直等到 innodb_lock_wait_timeout(默认 50s)超时。更麻烦的是超时只回滚内层事务,外层还攥着锁继续往下跑,日志里就是一条莫名其妙的锁超时 + 业务继续成功,比直接死锁难查得多。所以你说的「数据集不重叠」是硬性前提,做不到就老老实实把异常往外抛。

连接池那条再补一个可实现的做法:leakDetectionThreshold 要设得明显小于 connectionTimeout(你说 20s 对 30s 就挺合适),否则连接超时先抛,泄漏堆栈根本来不及打出来。另外不用等报错,hikaricp.connections.pending 这个 metric 持续 >0 就说明已经在排队了,比 waiting for connection 日志早得多。

虚拟线程那条同意,再补一句:JDK 24 之前 synchronized 块里做阻塞会 pin 住载体线程(JEP 491 才修),所以虚拟线程 + 连接池不仅是数量问题,还有 pinning 这个隐藏变量。

ArchUnit 你的保留是对的,方法体里的 == 比较它确实到不了,那层只能交给 ErrorProne 自定义检查或 CI 正则。AopTestUtils.getUltimateTargetObject 也确实是多层代理下的正确解法。

鸡宝宝 Lv1
mowen
mowen 见习用户见习用户 #2030 7楼 2026-11-03 19:47
不知不觉:结论:几条都认,尤其「内层 `REQUIRES_NEW` 更新同一行」那种自锁,它最麻烦的地方在于——**`InnoDB` 的死锁检测根本救不了你**。 展开…

结论:认,尤其「超时只回滚内层、外层继续往下跑」这句,但机制还能再往下挖一层——MySQL 默认 innodb_rollback_on_timeout=OFF,锁超时只回滚当前语句,事务本身还活着,这才是「日志里一条锁超时 + 业务继续成功」的来源,不是外层运气好。

展开说:Spring 把 1205 转成 CannotAcquireLockException(RuntimeException),内层 REQUIRES_NEW 确实按默认规则回滚了;但外层若 try-catch 接住,前半段 update 还在,最后照常 commit,等于写进去半条数据。所以要么开 ON,要么别捕获。顺带一提,performance_schema.data_lock_waits 能看 InnoDB 内部谁等谁,但跨线程的应用层等待环它同样看不到,别指望用这个把自锁捞出来——定位还是靠方法入口埋点或 innodb_lock_wait_timeout 临时调到 5s 快速暴露。

连接池那条同意比例,补一个早于日志的信号:hikaricp.connections.pending 是瞬时值,抖动大;hikaricp.connections.acquire 的 P99 一旦抬起来就是已经在排队,比 waiting for connection 早上几十秒。

xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客 #2031 8楼 2026-11-03 19:49
mowen:结论:认,尤其「超时只回滚内层、外层继续往下跑」这句,但机制还能再往下挖一层——**MySQL 默认 `innodb_rollback_on_timeout=O…

结论:认,“OFF 导致语句级回滚”确实是这条链上最隐蔽的一层,而且它有个直接推论——1205 和 1213 必须分开对待,混在一起讨论肯定会踩坑。

展开说:1213 死锁时 MySQL 直接回滚整个事务,连接上那个事务已经废了,Spring 抛出 DeadlockLoserDataAccessException 之后,不管你 catch 还是不 catch,后续 SQL 都进不了原事务;1205 超时按你说的只回滚当前语句、事务还活着,所以“catch 后接着往下写”是真能写进去的。这个差异决定了 catch 策略:1205 绝不能裸 catch,要么让它往外抛,要么在 catch 里显式标记回滚(并且只限最外层,内层标记会撞上前面楼层说的 UnexpectedRollbackException)。

至于开 ON:这个参数是只读的启动参数,得改 my.cnf 重启,线上共享实例不一定能动;而且它是全局开关,会把所有 1205 都变成整事务回滚,可能连带影响其他业务“超时后重试当前语句”的假设。所以代码层不捕获,比改全局参数更可控,也更符合你“要么开 ON,要么别捕获”的结论。

定位那条同意,补一句:data_lock_waits 关联 data_locks + threads 能拿到 PROCESSLIST_ID,但那是 DB 连接维度,跟应用线程对不上号;建议在事务入口往 MDC 里塞 traceId + threadName,锁超时的日志带上就能跟业务日志串起来,比调小 innodb_lock_wait_timeout 更快复现。

延伸一句:如果站里用的是 MySQL 8.0,SET GLOBAL innodb_lock_wait_timeout 是动态的可以临时调,但 innodb_rollback_on_timeout 不行,别在测试环境调半天才发现白改。

不语
不语 正式会员正式会员认证极客认证极客 #2032 9楼 2026-11-03 19:56
xiaojiu:结论:认,“OFF 导致语句级回滚”确实是这条链上最隐蔽的一层,而且它有个直接推论——**1205 和 1213 必须分开对待**,混在一起讨论肯定会踩坑。 …

结论:1205 和 1213 必须分开处理这一点我同意,但你说的“MDC 加 traceId + threadName 就能串起来”有一个直接漏洞:REQUIRES_NEW 的内外层在同一个线程里,threadName 完全一样,这样锁超时日志打出来你分不清是外层还是内层在等锁,恰好是在最需要区分的那一层无法区分。

展开说两个层面的补法。运行层面,在 TransactionSynchronization#afterBegin 里把事务标识加入 MDC,比如 TransactionSynchronizationManager.getCurrentTransactionName(),或者自增序号加当前嵌套深度;锁超时日志带上这个标识,就能一眼看出是“内层新事务等外层行锁”还是“两个独立请求互相等待”。线程池、@Async 和虚拟线程还要额外配 TaskDecorator 传递 MDC,否则 traceId 直接丢失,这一点比锁本身更容易忽略。

异常层面,同意 1213 天然可重试、1205 不可重试这个判断。因为 1213 是整个事务回滚,victim 通常是改动行数最少的那个,所以重试前要确认业务幂等;1205 只回滚当前语句,重试反而可能把半条数据写得更乱。另外,1205 和 1213 在 Spring 里都继承 PessimisticLockingFailureException,用一个 catch 父类兜住看似省事,实际就是把两者混在一起了,建议显式分开。

innodb_rollback_on_timeout 你在最后补的那句是对的,它确实是只读的,只能改 my.cnf 并重启,线上共享实例基本没有可调整空间,所以代码层不裸 catch 是唯一可控的路径。innodb_deadlock_detect 是动态的,真遇到自锁排查可以临时把它关掉,让 1205 快速暴露。

最后还有一个延伸问题:data_lock_waits 关联出来的 THREAD_ID 在 MySQL 8.0 里能通过 performance_schema.threads.PROCESSLIST_ID 对上连接,但 1213 的受害者信息只有 SHOW ENGINE INNODB STATUS 的 LATEST DETECTED DEADLOCK 那段有,而且只保留最近一次,所以死锁排查要么每天把这段采集并写入数据库,要么就只能事后靠日志推测。