动态代理双雄:JDK Proxy 与 CGLIB 原理实战

bee2026-10-0853 分钟0 次阅读
手写一个 JDK 动态代理看清 InvocationHandler,再拆开 CGLIB 的字节码子类;性能对比、限制边界、Spring 的选择逻辑,一次全部拿下。
1 / 116
小节
〇、30 秒看懂
2 / 116

上一节说 AOP 靠「运行期造一个替身」。这一节就是把那个替身拆开看:它到底是谁造的、长什么样、调用怎么走到你的代码里。三个词先按小白口径钉死:

3 / 116
  • 反射(reflection)——程序在运行时反过来操作类本身的能力,比如拿着一个 Method 对象调 method.invoke(target, args),等价于「把方法名写在纸上,照着喊人干活」,而不是编译时就写死调用哪一行。
  • 字节码(bytecode)——.java 编译后给 JVM 读的中间语言,存在 .class 文件里;CGLIB 之所以能「凭空造子类」,就是直接在内存里拼这段指令,不经过 javac。
  • 动态代理——程序跑到一半才现造一个「长得和目标一样」的类并实例化它,你写的源码里从来没有这个类。
4 / 116
类比

JDK 动态代理像前台替身。你去一家公司找总经理,接待你的前台长得跟他一模一样、名片上也写着同一个职位(实现同一个接口),你说的每句话(每个方法调用)都被前台记下来转述给真正的总经理,再把答复递回给你。总经理本人毫不知情,也毫未改变——变的只是「谁来接待你」。而 CGLIB 更像认了个干儿子:直接以目标类为父类生成子类,在子类方法里插手脚,所以父类不能是 final(没爹可认)、方法不能是 final(不许重写)。

5 / 116
类比

InvocationHandler 就是快递分拣中心。所有包裹(调用)不管寄给谁,先统一送到这一个地方,贴着的单据上写着「收件人 + 面单号」(Method + args);分拣中心决定是先扫码登记再派送(前置通知)、直接派送(透传)、还是退回(抛异常)。这就是为什么增强逻辑只写一遍却能覆盖全部方法。

6 / 116
架构图
图 · JDK 还是 CGLIB:两维定生死
图 · JDK 还是 CGLIB:两维定生死
7 / 116

学完这节你要能回答:

8 / 116
  1. $Proxy0 凭什么只能「实现接口」而不能继承我的实现类?
  2. 什么情况下 Spring 只能用 CGLIB?final 类和 final 方法的后果有何不同?
  3. (UserServiceImpl) proxy 为什么会炸,@Transactional 自调用又为什么会静默失效?
9 / 116
小节
一、静态代理的维护地狱
10 / 116

上一节我们手写过一个静态代理,当时它的「写不完」还只是隐约的痛感。把场景放大,痛感立刻变成切肤之痛:系统里有三个服务接口。

11 / 116
代码对照
代码java
public interface UserService  { User findById(Long id); void save(User user); }public interface OrderService { Order create(OrderDTO dto); void cancel(Long id); }public interface PayService   { void pay(BigDecimal amount); }// 每加一个接口,就要多写一个代理类;每个方法都要手写一遍「转发 + 打日志」public class UserServiceLogProxy  implements UserService  { /* 方法逐个转发…… */ }public class OrderServiceLogProxy implements OrderService { /* 方法逐个转发…… */ }public class PayServiceLogProxy   implements PayService   { /* 方法逐个转发…… */ }
解读
  • 代理类和接口是 1:1 绑定,接口数量增长,代理类线性增长
  • 接口每加一个方法,对应代理类就要补一段几乎一样的转发代码
  • 一旦要加第二个切面(事务、权限),代理类的数量还会按切面组合继续翻倍
12 / 116

问题的根子在于:「转发 + 增强」这套动作对所有接口都一样,却被我们手写了很多遍。既然它和具体接口无关,就应该交给程序在运行时自动生成——这就是「动态代理」的动机。

13 / 116
小节
二、JDK 动态代理手写实战
14 / 116

JDK 自带了一套动态代理机制,核心只有两个类:java.lang.reflect.Proxy 和 InvocationHandler。下面这个例子可以直接运行:

15 / 116
代码对照
代码java
package com.example.proxy;import java.lang.reflect.*;import java.util.Arrays;public class JdkProxyDemo {    public static void main(String[] args) {        UserService target = new UserServiceImpl();   // 真实目标        UserService proxy = (UserService) Proxy.newProxyInstance(                target.getClass().getClassLoader(),   // 1. 用哪个类加载器加载代理类                new Class<?>[]{ UserService.class },  // 2. 代理类要实现哪些接口                new LogInvocationHandler(target)      // 3. 调用转发给谁处理        );        proxy.save(new User("alice"));        System.out.println("main 拿到: " + proxy.findById(1L));    }}class LogInvocationHandler implements InvocationHandler {    private final Object target;    LogInvocationHandler(Object target) { this.target = target; }    @Override    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {        System.out.println("[前] " + method.getName() + " 参数=" + Arrays.toString(args));        Object result = method.invoke(target, args);   // 反射调用真实目标        System.out.println("[后] " + method.getName() + " 返回=" + result);        return result;    }}
解读

注意:Proxy.newProxyInstance 必须在有接口的前提下工作。把 new Class<?>[]{ UserService.class } 换成 UserServiceImpl.class 并不改变本质——代理类仍然只会实现「接口」,不会继承实现类。如果 UserServiceImpl 没有实现任何接口,JDK 代理直接无从下手。

16 / 116

运行结果:

17 / 116
代码对照
代码text
[前] save 参数=[User{name='alice'}][后] save 返回=null[前] findById 参数=[1][后] findById 返回=User{name='alice'}main 拿到: User{name='alice'}
解读
  • 三个参数的含义:类加载器、代理类要实现的接口数组、负责转发调用的 InvocationHandler
  • 增强逻辑全部集中在 invoke 一个方法里,与具体是哪个接口、哪个方法无关
  • method.invoke(target, args) 依靠反射调用真实目标,这是「转发」的实现方式
18 / 116

为什么 JDK 代理必须要求目标实现接口? 因为在 Java 里一个类只能继承一个父类,而 JDK 生成的代理类已经 extends Proxy 了,没办法再继承你的实现类,于是只能通过「实现接口」来做到「看起来和目标同类型」。这条限制直接决定了它的适用边界,也就引出了下一节的 CGLIB。

19 / 116
小节
三、反编译看看生成的 $Proxy0
20 / 116

JDK 运行时会在内存里生成一个代理类,名字形如 $Proxy0、$Proxy1……它大概长这样(简化示意):

21 / 116
代码对照
代码java
// JDK 运行时生成的代理类(示意,非源码)public final class $Proxy0 extends Proxy implements UserService {    private static Method m3;   // 对应 save(User)    public $Proxy0(InvocationHandler h) {        super(h);   // 把 handler 交给父类 Proxy 保存    }    @Override    public void save(User user) {        try {            // 关键:这一切都在把「方法 + 参数」原样打包,交给 handler.invoke            super.h.invoke(this, m3, new Object[]{ user });        } catch (Throwable e) {            throw new UndeclaredThrowableException(e);        }    }}
解读
  • 代理类由 JVM 在运行时生成,可以加 -Djdk.proxy.ProxyGenerator.saveGeneratedFiles=true 把它落盘查看
  • 它 extends Proxy,所以只能通过「实现接口」来跟目标「同类型」——这正是 Java 单继承限制的直接后果
  • 每个接口方法体里几乎只有一行:把 Method 和 args 交给 h.invoke,真正的逻辑全在 handler 里
22 / 116
原理动画
动图 · $Proxy0 怎么把调用转给 InvocationHandler
动图 · $Proxy0 怎么把调用转给 InvocationHandler
23 / 116

这条动图把上面那一段代码放慢成六帧:注意第 3 帧之后,你的源码才第一次被执行——在 $Proxy0 眼里,整个世界只有「打包」和「转交」两件事。

24 / 116
小节
四、CGLIB 原理:生成子类,而不是实现接口
25 / 116

CGLIB(Code Generation Library)走的是另一条路:运行时为目标类生成一个子类,重写父类方法并插入回调。它依赖 ASM 直接操作字节码。核心是 Enhancer 和 MethodInterceptor:

26 / 116
代码对照
代码java
package com.example.proxy;import net.sf.cglib.proxy.*;public class CglibProxyDemo {    public static void main(String[] args) {        Enhancer enhancer = new Enhancer();        enhancer.setSuperclass(OrderService.class);           // 指定「父类」,不是接口        enhancer.setCallback(new LogMethodInterceptor());     // 方法拦截器        OrderService proxy = (OrderService) enhancer.create(); // 生成子类实例        proxy.create(new OrderDTO("A1001"));    }}class LogMethodInterceptor implements MethodInterceptor {    @Override    public Object intercept(Object obj, Method method, Object[] args, MethodProxy mp)            throws Throwable {        System.out.println("[前] " + method.getName());        Object result = mp.invokeSuper(obj, args);   // 调用父类(真实)的方法        System.out.println("[后] " + method.getName());        return result;    }}
解读
  • CGLIB 生成的代理对象是目标类的子类,不需要目标实现任何接口
  • mp.invokeSuper(obj, args) 调用的是「父类的原方法」,等价于 JDK 代理里的 method.invoke(target, args)
  • 无需接口这一点,正好补上了 JDK 代理的短板
27 / 116

「凭空造一个子类」听着像魔法,这段六帧动画把它拆成了流水线:注意第 ② 帧——字节码是 ASM 在内存里拼出来的,不经过 javac,所以你在 target 目录里永远找不到这个类;第 ⑤ 帧才是真正调到父类原方法的那一下:

28 / 116
原理动画
动图 · CGLIB 怎么凭空造出一个子类
动图 · CGLIB 怎么凭空造出一个子类
29 / 116

但它也有代价——既然是「继承」,就吃 Java 继承的全部限制。下面两条是踩坑重灾区:

30 / 116
代码对照
代码java
public final class FinalService {   // final 类:无法被继承,CGLIB 直接报错    public void hello() { }}public class NormalService {    public final void fast() { }     // final 方法:拦不到,通知会「静默失效」    private void inner() { }         // private 方法:同样拦不到}
解读

坑:CGLIB 挑不出 final 类的毛病——它会直接抛 IllegalArgumentException: Cannot subclass final class,一下就能定位。真正难查的是 final / private / static 方法:代理能创建成功、程序能跑,但那几个方法上的通知一声不响地失效了,日志里什么都看不到。

31 / 116
小节
五、两者对比表
32 / 116
架构图
图 1 · JDK 代理 vs CGLIB
图 1 · JDK 代理 vs CGLIB
33 / 116
对照表
维度JDK 动态代理CGLIB
实现方式运行时生成 $Proxy0,实现接口运行时生成子类,继承目标类
前置条件目标必须实现接口目标类与目标方法不能是 final
生成时机首次调用时生成并缓存首次调用时生成并缓存
性能JDK 8 之后大幅优化,差距已明显缩小早期更快;创建代理较慢,调用较快
适用场景面向接口编程、API 稳定无接口、需要代理具体类
常见异常强转实现类抛 ClassCastExceptionfinal 类抛 IllegalArgumentException
34 / 116

一句话记忆:JDK 代理靠接口,CGLIB 靠继承。谁有接口、谁没有接口,就是选型的第一个岔路口。

35 / 116
小节
六、Spring 如何选择代理
36 / 116

Spring AOP 的选型逻辑集中在 DefaultAopProxyFactory,它会在创建代理前做一次判断。简化后的核心逻辑如下:

37 / 116
代码对照
代码java
// DefaultAopProxyFactory.createAopProxy 的核心判断(简化)if (config.isOptimize()        || config.isProxyTargetClass()        || hasNoUserSuppliedProxyInterfaces(config)) {    // 走 CGLIB:强制 CGLIB,或目标没有可用接口    return new CglibAopProxy(config);}// 有接口、又没有强制 -> 走 JDKreturn new JdkDynamicAopProxy(config);
解读
  • 默认规则:目标实现了接口 → 优先 JDK 代理;没有接口 → 只能用 CGLIB
  • 强制开关:@EnableAspectJAutoProxy(proxyTargetClass = true) 或配置 spring.aop.proxy-target-class=true
  • Spring Boot 2.x 起默认 CGLIB:AopAutoConfiguration 把 proxy-target-class 默认设为 true,这样「按实现类注入」也不会再撞上 ClassCastException
38 / 116

三段 if 读起来抽象,把它画成判定树就是四次追问,任何一格都能对上你自己项目里的现象:

39 / 116
架构图
图 · Spring 造替身的判定树
图 · Spring 造替身的判定树
40 / 116

理解这条规则很实用:当你明明配了切面却报 ClassCastException,大概率是「用实现类类型接收了 JDK 代理」。要么注入时改用接口类型,要么打开 proxyTargetClass。

41 / 116
小节
七、性能实测:别被旧结论坑了
42 / 116

「JDK 代理比 CGLIB 慢」是一个流传很久、但已经过时的结论。JDK 8 之后,Proxy 的调用路径被大幅优化,两者差距已经缩小到多数业务场景可以忽略的程度。下面是一段简化的基准代码:

43 / 116
java
@Testvoid proxyThroughput() {    // 关键:先预热,让 JIT 编译生效,否则测的是解释执行    for (int i = 0; i < 200_000; i++) {        jdkProxy.method();        cglibProxy.method();    }    long t1 = System.nanoTime();    for (int i = 0; i < 10_000_000; i++) jdkProxy.method();    long jdk = System.nanoTime() - t1;    long t2 = System.nanoTime();    for (int i = 0; i < 10_000_000; i++) cglibProxy.method();    long cglib = System.nanoTime() - t2;    System.out.printf("JDK=%dms CGLIB=%dms%n", jdk / 1_000_000, cglib / 1_000_000);}
44 / 116

在一台普通机器上的相对量级(仅用于理解趋势,具体数值随 JDK 版本与硬件变化):

45 / 116
对照表
指标JDK 动态代理CGLIB说明
创建代理较慢(反射 + 字节码生成)更慢(ASM 生成子类)都只发生一次,可忽略
单次调用略慢略快JDK 8+ 后差距很小
冷启动开销低高代理类生成更重
46 / 116
说明

代理的创建成本发生在启动期且只发生一次,调用成本才是运行期大头。除非你在极端热点路径上做纳秒级优化,否则不必因为「性能」而纠结选型——真正该考虑的是「目标有没有接口」这个决定性因素。

47 / 116
小节
八、坑:强转与注入类型
48 / 116
坑

把代理对象强转成实现类,只有 CGLIB 才走得通。JDK 代理生成的是 $Proxy0,它只实现了接口、不继承 UserServiceImpl,所以 (UserServiceImpl) proxy 会抛 ClassCastException。注入时统一使用接口类型(UserService),而不是实现类型(UserServiceImpl),就能从根上避开这个坑。

49 / 116
坑

CGLIB 会「静默失败」在 final / private / static 方法上。它们既不会报错,也不会触发通知,只能靠你在排查时意识到「这些方法天生不可代理」。反过来,如果目标类本身被声明为 final,CGLIB 会直接创建失败——这一点反而更好定位。

50 / 116
小节
九、动手体验:两种代理的生成与调用
51 / 116

下面这个演示在 JDK 与 CGLIB 之间切换,把代理类的「签名形态」和「通知链」同时展示出来。对比着看,你会更直观地感受到:一个实现接口、一个继承子类,但拦截调用的方式殊途同归。

52 / 116
内核实验
TeaVM两种代理的生成与调用对比未启动
在 JDK / CGLIB 之间切换,对比代理类签名与通知链
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
53 / 116
原理动画
动图 · 一次调用穿过动态代理
动图 · 一次调用穿过动态代理
54 / 116
小节
十、决策:新项目要不要强制 proxyTargetClass
55 / 116
决策
决策一个全新的 Spring Boot 3 项目,团队约定「Controller 注入 Service、Service 注入其他 Service 时统一使用接口类型」,但偶尔有人图省事直接注入实现类。切面代理这块要不要全程强制 `proxyTargetClass=true`(全用 CGLIB)?
56 / 116
小节
十一、上手实验:把两种替身各造一遍
57 / 116

四个实验按顺序跑。第一个是本章主菜——在 JDK 与 CGLIB 之间切换,看代理类的签名形态与通知链如何变化,error 参数还会演示目标方法抛异常时通知链怎么走:

58 / 116
内核实验
TeaVM两种代理的生成与调用未启动
jdk / cglib 对比签名,self 看自调用失效,error 看异常路径上的 @AfterThrowing
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
59 / 116

有了替身,才谈得上织入。weave 把「同一个方法在没有切面 / 有切面 / 通知链内部 / 代价」四种状态并排给你看,正好印证第十一节 AOP 那套术语落到运行时的样子:

60 / 116
内核实验
TeaVM切面织入前后对比未启动
plain → aspect 看清多出来的层,chain 钻进通知链,cost 看性能与调试代价
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
61 / 116

替身是谁给套上去的?autoptr 演示自动代理创建器在后置处理里做候选判定、再由代理工厂产出对象的全过程——把第 9 篇「AOP 代理诞生于初始化后置」这句话变成可见的时序:

62 / 116
内核实验
TeaVM自动代理创建器怎么选中你的 Bean未启动
bpp 看拦截时机,candidate 看匹配判定,factory 看 JDK/CGLIB 的分叉
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
63 / 116

最后补一个必须知道的组合题:多个切面同时命中一个方法时,洋葱的进出顺序由 @Order 决定。txaspect 专门演示 @Transactional 与自定义切面的先后陷阱:

64 / 116
内核实验
TeaVM多切面嵌套顺序未启动
order 与 reverse 对比,inner 看嵌套时间线,txaspect 看事务与自定义切面谁在外层
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
65 / 116

上面这一组讲的是「几个替身叠在一起」,还没回答「单个替身里到底装了什么」。advices 把一个方法上的五种通知全挂出来,看它们如何被塞进代理的调用链:

66 / 116
内核实验
TeaVM替身里装的是这条通知链未启动
选 order 看多切面穿插,再选 normal 数一遍 @Around 与 @Before 的真实先后
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
67 / 116

再往前追一步时间线:这个类到底在 Bean 生命周期的哪一秒被换成替身?答案是初始化后置(postProcessAfterInitialization)——Bean 本身已经造好、属性也填完了,代理是在它「出院」前最后一道手续上套的:

68 / 116
内核实验
TeaVM替身是在哪一步被换上场的未启动
按生命周期顺序走一遍,重点看初始化后置那一步返回的对象和入场的不是同一个
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
69 / 116

前面第三节看过 $Proxy0 的源码示意,第四节的动图也讲了 CGLIB,但「一次调用究竟怎么从你手上走到 handler 里」这件事,还是得逐行走。下面是 JDK 代理那一路的单步台:连点「下一步」,盯住标着 (4) 的那一行——你的代码第一次被执行,比调用点晚了两层栈:

70 / 116
单步调试台
单步台单步穿过 $Proxy78:一次 svc.save(user) 的旅行1 / 8
按顺序点下一步,注意第 5 步你的类才第一次出场,第 6 步才真正调到实现对象
被调试的代码
1UserService svc = ctx.getBean(UserService.class); // svc 其实是 jdk.proxy2.$Proxy78
2svc.save(new User("alice")); // (1) 你写的这一行就是调用点
3// ---- 镜头进入 $Proxy78 ----
4public void save(User u) { // (2) 代理类里现生成的方法体
5 super.h.invoke(this, m3, new Object[]{ u }); // (3) 打包「方法号 + 参数」转交 handler
6// ---- 镜头进入你写的 LogInvocationHandler ----
7System.out.println("[前] " + method.getName()); // (4) 前置增强:从这里起才是你的代码
8Object r = method.invoke(target, args); // (5) 反射调用真实实现对象
9System.out.println("[后] " + r); // (6) 后置增强
10return r; // (7) 交回 $Proxy78,再回到调用方
11} // (8) 未声明的受检异常在这里被包成一层壳
此刻的变量
svc 的类名jdk.proxy2.$Proxy78
Proxy.isProxyClasstrue
targetUserServiceImpl@1a2b3c
调用栈
1JdkProxyDemo.main
1第一行就该警觉:变量声明的是接口,getBean 还给你的却是你从没写过的类。此刻 target 还安安静静躺在 handler 的字段里,没有任何一条路径能直接调到它。
71 / 116

六种 JDK / CGLIB 特有的写法与报错,靠位置猜没用——右侧全是各不相同的事实,只能真认出来:

72 / 116
配对闯关
闯关这一行现象是谁家的?已配对 0/7 · 配错 0
左边是真实日志里的符号与报错原文,右边认领它说明的事实
先点左边一个
73 / 116

实验做到这里,可以换成命令行自己敲了。这台控制台连着浏览器里的同一个容器,回显全部由内核算出来——先 boot,再 beans 挨个看类名后缀,你就知道每个 Bean 家是哪条路:

74 / 116
内核控制台
75 / 116
小节
十二、沙盘:三个条件决定用哪种代理
76 / 116

「Spring 到底给我造了哪种代理」不是玄学,就是这三个条件的组合:

77 / 116
沙盘
沙盘这次会生成 JDK 还是 CGLIB 代理
运行结果
# DefaultAopProxyFactory: 有接口且未强制 -> JDK
proxy = com.sun.proxy.$Proxy78 implements UserService
inject by interface: OK
结论:教科书里的默认组合
最干净的搭配:面向接口编程 + JDK 代理
78 / 116

用法建议:先把第一格当基准,然后每次只动一个开关。你会自己发现两条铁律——① proxyTargetClass=true 一旦为真,「有没有接口」就不再影响代理类型;② 接收变量的类型必须与生成的替身兼容,这正是 ClassCastException 的全部来源。

79 / 116
小节
十三、常见报错速查
80 / 116

这一节的报错有一半炸得很响,另一半一声不响——后者更难查。

81 / 116

先看一段最常抄错的栈:

82 / 116
text
java.lang.ClassCastException: class com.sun.proxy.$Proxy78 cannot be cast to classcom.example.service.UserServiceImpl (com.sun.proxy.$Proxy78 andcom.example.service.UserServiceImpl are in unnamed module of loader 'app')    at com.example.controller.UserController.<init>(UserController.java:23)
83 / 116

读法:前半句告诉你替身是 JDK 造的(com.sun.proxy.$Proxy78),后半句告诉你你用了实现类去接它。两者不可能兼容——$Proxy0 只继承 Proxy、只实现接口,跟你写的 UserServiceImpl 没有任何继承关系。

84 / 116
对照表
报错原文(片段)真实原因30 秒自救深挖看第几篇
java.lang.ClassCastException: class com.sun.proxy.$Proxy78 cannot be cast to class com.example.UserServiceImpl用 JDK 代理(有接口、未强制 CGLIB)却按实现类类型接收/强转首选把字段类型改成接口;确需按实现类注入就设 spring.aop.proxy-target-class=true(Boot 默认已如此)本篇第六节
org.springframework.cglib.core.CodeGenerationException: java.lang.IllegalArgumentException: Cannot subclass final class com.example.FinalService目标类是 final,CGLIB 无法生成子类去掉 final;或给它抽一个接口让 Spring 走 JDK 路线;或对这一个 Bean 局部关掉代理本篇第四节
@Transactional 加在 final 方法上:启动正常、无警告、更新却在异常后提交了子类代理不能重写 final 方法,通知静默丢失去掉 final;打 getClass().getName() 确认代理存在,再逐个检查方法修饰符第 31 篇
private/static 方法上的通知完全不执行私有与静态方法不是代理的拦截点(静态属于类,代理持有实例)改成 public 实例方法;确需静态逻辑就抽到工具类并由被代理的方法调用第 11 篇第七节
同类里 this.save(x) 导致 @Transactional/@Cacheable 失效(无任何报错)this 指向原始对象而非容器里的替身,调用没经过代理拆到另一个 Bean;或注入自身代理;或 ((MyService) AopContext.currentProxy()).save(x) 配合 @EnableAspectJAutoProxy(exposeProxy = true)第 14、15 篇
java.lang.reflect.UndeclaredThrowableException 从代理调用处抛出InvocationHandler.invoke 抛出了接口方法签名上没声明的受检异常,$Proxy0 只能把它包一层在 handler 里判断:非接口声明的 RuntimeException/Error 原样抛,其他明确包装;或在接口方法上补 throws本篇第三节
NoClassDefFoundError: net/sf/cglib/proxy/MethodInterceptor(手写 CGLIB demo 时)示例代码引的是老 cglib 包名,而 Spring 5+ 用的是内联的 org.springframework.cglib.*改用 org.springframework.cglib.proxy.Enhancer,或直接引 spring-boot-starter-aop本篇第四节
代理对象的 equals/hashCode/toString 行为诡异这三种方法同样会被路由到 InvocationHandler,而 AOP 框架内部对它们有特殊处理不要依赖 toString() 判断业务身份;比对对象请用明确的业务 ID 字段第 14 篇
85 / 116
提示

分不清自己拿到的是哪种替身时,就打一行 svc.getClass().getName():出现 $Proxy 是 JDK,出现 $$SpringCGLIB$$0 是 CGLIB,两个都没有说明压根没被代理。

86 / 116

本篇最经典的那句 ClassCastException,栈其实短得离谱——难的不是读栈,是看懂「$Proxy78 凭什么不是 UserServiceImpl」。点出你认为的凶手帧:

87 / 116
报错急救
报错急救ClassCastException: $Proxy78 cannot be cast to UserServiceImpl
用实现类去接 JDK 替身的报错现场

同事把 spring.aop.proxy-target-class 改成 false(或者项目从 Boot 1.x 升级上来的路上),UserController 的构造器参数写的是实现类,应用一起来就炸。

APPLICATION FAILED TO START
java.lang.ClassCastException: class com.sun.proxy.$Proxy78 cannot be cast to class com.example.service.UserServiceImpl (com.sun.proxy.$Proxy78 and com.example.service.UserServiceImpl are in unnamed module of loader 'app')
at com.example.controller.UserController.<init>(UserController.java:23)
at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:62)
at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:211)
at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:111)
at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:315)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1355)
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
88 / 116
小节
十四、随堂自测
89 / 116
随堂自测
随堂自测`(UserServiceImpl) ctx.getBean(UserService.class)` 抛出 ClassCastException,最可能的原因是?
先自己选一个,选中立刻告诉你对不对
90 / 116
随堂自测
随堂自测以下哪种情况会让通知「静默失效」(既不报错也不执行)?
先自己选一个,选中立刻告诉你对不对
91 / 116
小节
十五、动手练习
92 / 116
小节
第一档 · 照做
93 / 116

亲手造一个 JDK 动态代理,并把 JVM 现生成的 $Proxy0 源码落盘看一眼——这一步最能建立「替身是真的存在的类」的体感。完整可跑代码:

94 / 116
java
package com.example.proxy;public interface UserService {    String find(Long id);}public class UserServiceImpl implements UserService {    @Override    public String find(Long id) {        return "user-" + id;    }}
95 / 116
java
package com.example.proxy;import java.lang.reflect.InvocationHandler;import java.lang.reflect.Method;import java.lang.reflect.Proxy;import java.util.Arrays;public class JdkProxyLab {    public static void main(String[] args) throws Throwable {        UserService target = new UserServiceImpl();        UserService proxy = (UserService) Proxy.newProxyInstance(                target.getClass().getClassLoader(),                new Class<?>[]{ UserService.class },                new InvocationHandler() {                    @Override                    public Object invoke(Object p, Method m, Object[] a) throws Throwable {                        System.out.println("[handler] 收到调用: " + m.getName()                                + " args=" + Arrays.toString(a));                        Object r = m.invoke(target, a);       // 反射调到真实实现                        System.out.println("[handler] 返回: " + r);                        return r;                    }                });        System.out.println("[class] " + proxy.getClass().getName());        System.out.println("[ifaces] " + Arrays.toString(proxy.getClass().getInterfaces()));        System.out.println("[isProxy?] " + Proxy.isProxyClass(proxy.getClass()));        System.out.println("[call ] " + proxy.find(7L));        // 亲手验证「不能强转实现类」这条铁律        try {            UserServiceImpl bad = (UserServiceImpl) proxy;            System.out.println("[bad] 竟然成功了?" + bad);        } catch (ClassCastException e) {            System.out.println("[bad] " + e.getMessage());        }    }}
96 / 116

预期输出:

97 / 116
text
[class] jdk.proxy2.$Proxy7[ifaces] [interface com.example.UserService][isProxy?] true[handler] 收到调用: find args=[7][handler] 返回: user-7[call ] user-7[bad] class jdk.proxy2.$Proxy7 cannot be cast to class com.example.UserServiceImpl ...
98 / 116

再把生成的代理类存成文件看看(Java 9+ 的参数):

99 / 116
text
-Djdk.proxy.ProxyGenerator.saveGeneratedFiles=true
100 / 116

跑完在项目根目录会出现 jdk/proxy2/$Proxy7.class,用 javap -p jdk.proxy2.$Proxy7 反编译,你能看到每个接口方法体里那句 h.invoke(this, ...) ——和本文第三节的示意代码逐行对得上。

101 / 116
小节
第二档 · 变体
102 / 116

目标:证明「final 方法上的通知静默失效」。

103 / 116

提示:把 UserServiceImpl.find 复制一份改成 public final String findCached(Long id),再在 handler 里打印方法名,最后通过接口调用这两个方法各一次。若你想验证 Spring 场景,就给实现类加一个 @Transactional public final void tx(),并从外部调用它。

104 / 116

你会观察到:非 final 的 find 每次都经过 handler 并打印;findCached 若经由接口暴露也照样会被拦(因为 JDK 代理只看接口),但在 CGLIB 场景下 tx() 一条通知都不会触发,也没有任何报错或警告——这就是为什么本节反复强调「打 getClass().getName() 再逐个查方法修饰符」。

105 / 116
小节
第三档 · 造一个
106 / 116

做一个「迷你 IoC + AOP」玩具容器:只用 JDK 动态代理,为注册表里的每个接口提供一层「日志 + 计时」增强,并支持 -Dxxx.proxy=jdk|none 两种模式切换。

107 / 116

验收清单:

108 / 116
  • [ ] 用 Map<String, Object> 存 Bean,取用时若有切面就用 Proxy.newProxyInstance 现场包一层
  • [ ] 连续两次 getBean 返回同一个代理实例(缓存代理,而不是每次新建)
  • [ ] 代理能被强转成接口类型,但不能被强转成实现类——把这条差异写成一行的测试断言
  • [ ] handler 里对 equals/hashCode/toString 做特判,说明为什么要特判
  • [ ] 说清一件事:如果某个实现类没有接口,这个玩具容器还能不能增强它?该怎么办?
109 / 116
小节
十六、要点自查
110 / 116
自检

能否口述 Proxy.newProxyInstance 三个参数的含义(类加载器、接口数组、handler),以及为什么第二个参数只能是接口?

111 / 116
自检

$Proxy0 的方法体里那一行是什么?(h.invoke(this, method, args))——写出它,你就懂了 JDK 代理的全部。

112 / 116
自检

Spring 选择 CGLIB 的三个条件分别是什么?(强制 proxyTargetClass/optimize/没有可用接口)

113 / 116
自检

final 类与 final 方法的失败方式有何不同?(前者启动即抛、后者静默失效)

114 / 116
自检

ClassCastException: cannot be cast to ...Impl 的两条自救路径是什么?(按接口注入;或打开 proxyTargetClass)

115 / 116
口诀

JDK 靠接口、CGLIB 靠继承;final 类当场炸、final 方法悄悄哑;替身转成本人必炸,自调用永远听不见。

116 / 116
总结

动态代理是 Spring AOP 的引擎,它只有两条实现路线——JDK 代理靠 Proxy.newProxyInstance 生成实现接口的 $Proxy0,把调用转发给 InvocationHandler;CGLIB 靠 Enhancer 生成目标类的子类,用 MethodInterceptor 拦截。选型的决定性因素是「目标有没有接口」;Spring 默认有接口用 JDK、无接口用 CGLIB,Spring Boot 2.x 起默认 CGLIB。记住两条坑就够了:强转实现类只有 CGLIB 可行,final / private / static 方法无法被 CGLIB 拦截。