ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

动态代理深入解析:JDK Proxy、CGLIB 与 Spring AOP 实战避坑指南

2026/10/7 17:52:21 拓冰建站 浏览量
动态代理深入解析:JDK Proxy、CGLIB 与 Spring AOP 实战避坑指南 动态代理Proxy这个词只要是写过 Java 的同学基本都见过。Spring AOP 切面、MyBatis 的 Mapper 接口、Feign 的客户端背后全是它在起作用。但很多人会用却不明白为什么一个接口不需要写实现类直接注入就能用为什么加了 Transactional 有时候却不生效答案往往都藏在动态代理里。这篇文章不打算抄官方文档我会从最朴素的代理模式说起把 JDK 动态代理、CGLIB、Spring AOP 的 ProxyFactory 串成一条线再把代理对象转 Object 时的类型坑、常见异常排查一并讲清楚。适合那些想深入框架源码或者打算自己写中间件的同学就算你目前只做业务读完也能排掉不少“玄学 bug”。1. 先搞清楚概念代理模式与动态代理1.1 代理模式到底解决什么问题代理模式本身不是某门语言特有的它就是一个设计思想不要直接去操作目标对象而是通过一个代理对象来间接操作代理在中间把调用前、调用后、异常处理这些横切逻辑给接管了。举个例子你租房子的时候一般不是直接找房东而是找中介。中介会先带你看房、签合同、收租金这些动作本质上是“代理”了房东的某些职责。你不需要知道房东电话也不需要亲自处理每一件琐事中介在流程前后帮你做了额外的事情。对应到代码里房东就是目标对象中介就是代理对象而看房签约这些动作就是被代理的业务方法。整个过程对租客来说感知不到中间经过了谁他只知道“我是在和房东达成交易”这就实现了对调用方的透明增强。用代码写一个最简单的手工代理大概是这样的public interface UserService { void saveUser(String name); } public class UserServiceImpl implements UserService { Override public void saveUser(String name) { System.out.println(保存用户: name); } } public class UserServiceProxy implements UserService { private final UserService target; public UserServiceProxy(UserService target) { this.target target; } Override public void saveUser(String name) { System.out.println(before: 开启事务); try { target.saveUser(name); System.out.println(after: 提交事务); } catch (Exception e) { System.out.println(exception: 回滚事务); throw e; } } }这是最典型的静态代理一个接口对应一个代理类代理类里写死要增强的逻辑。问题是如果系统里有几十个接口每个接口都想加事务、加日志、做权限校验那你就得维护几十个代理类而且每个代理类里的逻辑几乎一模一样。这是静态代理最大的痛点——重复代码太多扩展性太差。1.2 JDK 动态代理三要素和第一个例子动态代理解决的就是“能不能只写一次增强逻辑然后让它对所有接口生效”的问题。JDK 从 1.3 开始提供了java.lang.reflect.Proxy它可以在程序运行期间动态生成一个代理类这个代理类会实现指定的接口并且把接口上所有方法调用都转发到一个InvocationHandler里面去。要使用 JDK 动态代理只需要三个要素一个 ClassLoader用来加载运行时生成的代理类一组接口代理类要实现这些接口一个 InvocationHandler所有代理方法调用都会进入这个处理器。用代码写出来就是这样UserService userService (UserService) Proxy.newProxyInstance( UserService.class.getClassLoader(), new Class[]{UserService.class}, (proxy, method, args) - { System.out.println(before: 开启事务); Object result method.invoke(target, args); System.out.println(after: 提交事务); return result; } );InvocationHandler是一个函数式接口只有一个invoke(Object proxy, Method method, Object[] args)方法。proxy是生成的代理对象本身method是被调用的目标方法args是调用参数。在 invoke 方法内部你可以决定在真正目标方法调用前后做什么甚至可以直接拦截掉不调用目标方法。比如权限校验不通过就抛异常业务方法根本不会执行。这个写法只写了一次增强逻辑却可以代理任意接口。你把这个 handler 换到别的接口上依然能工作。1.3 动态代理为什么“动态”静态代理是在编译期把代理类写好动态代理则是在运行期才“凭空捏造”出一个类。JDK 内部通过ProxyGenerator生成代理类的字节码然后用传入的 ClassLoader 把这个类加载到 JVM 里。这个类名称通常长这样com.sun.proxy.$Proxy0。也就是说你调用Proxy.newProxyInstance的那一刻JVM 里多了一个类这个类实现了你传入的所有接口并且继承了java.lang.reflect.Proxy。因为 Java 是单继承代理类既然已经继承了 Proxy就不可能再去继承某个业务类所以 JDK 动态代理有一个硬性限制只能代理接口不能代理类。这一点后面会直接影响我们对框架的理解。2. 动态代理选型JDK 动态代理还是 CGLIB2.1 JDK 动态代理的边界JDK 动态代理的使用场景非常清楚目标类型是接口或者至少调用方持有的是接口引用。Spring 早期的 AOP、MyBatis 的 Mapper、Feign Client 都属于这一类。但它也有明显的天花板。假设你的目标类根本没有实现接口比如一个普通类public class OrderService { public void createOrder() { System.out.println(创建订单); } }你想在createOrder前后加逻辑用Proxy.newProxyInstance是做不到的因为 JDK 代理类无法继承OrderService也就无法覆写createOrder方法。这时候就需要 CGLIB 登场。2.2 CGLIB 通过子类实现代理CGLIB 的思路完全不同它直接在运行期生成目标类的子类然后通过覆写非 final 方法来实现拦截。因为生成的是子类所以目标类不能被 final 修饰被代理的方法也不能是 final 的。CGLIB 的核心类是Enhancer和MethodInterceptor一个典型的用法如下Enhancer enhancer new Enhancer(); enhancer.setSuperclass(OrderService.class); enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) - { System.out.println(before); Object result proxy.invokeSuper(obj, args); System.out.println(after); return result; }); OrderService proxy (OrderService) enhancer.create(); proxy.createOrder();注意这里和 JDK 动态代理有个本质区别在 CGLIB 的回调里调用目标方法不是method.invoke(target, args)而是proxy.invokeSuper(obj, args)。为什么因为obj本身就是生成的子类实例如果你在回调里再调用method.invoke(obj, args)就会再次触发代理逻辑形成无限递归。invokeSuper是 CGLIB 专门提供的“跳过当前拦截器直接调用父类实现”的方法这也算是一个新手特别容易踩的坑。2.3 对比一张表说清楚JDK 动态代理和 CGLIB 的差异用一张表看最清楚对比项JDK 动态代理CGLIB代理对象继承关系继承Proxy实现指定接口继承目标类是否必须接口是否final 类可以代理接口实现类但代理类不继承实现类不能代理 final 类final 方法不影响只拦截接口方法不能拦截 final 方法是否需要额外依赖不需要JDK 内置需要spring-core或单独引入cglib代理生成方式运行期生成字节码类加载运行期生成字节码通过 ASM 操作还有一点值得说JDK 动态代理生成代理类时如果接口上的方法是 default 方法处理起来会麻烦一些Java 16 提供了InvocationHandler.invokeDefault但如果你还在用老版本 JDK要手动判断。CGLIB 因为是子类覆写反而没这个烦恼。2.4 Spring 框架里的选择逻辑Spring 的 AOP 虽然抽象出了一套Advisor、Advice、Pointcut的概念但脚下踩的还是这两种代理。它的选择逻辑并不复杂如果目标对象实现了接口且没有强制要求使用 CGLIB默认使用 JDK 动态代理否则使用 CGLIB。Spring Boot 2.x 之后默认把spring.aop.proxy-target-classtrue也就是默认走 CGLIB不再优先使用 JDK 代理。这也是为什么很多人在 Spring Boot 项目里把一个实现类当作 Bean 注入时即使它实现了接口也不会报错的原因。但如果你在传统 Spring 项目里注入类型写的是具体实现类而容器里实际放的是一个 JDK 动态代理对象启动时就有可能会遇到BeanNotOfRequiredTypeException。这个问题我见到过不少次根源就在这里。3. Spring AOP 与动态代理的底层关系3.1 ProxyFactory 与 AopProxy 创建链路Spring 的 AOP 不会像你平时写Proxy.newProxyInstance那样直接用原生 API它封装了一个ProxyFactory。这个工厂给你提供了一个相对高级的入口你可以手动设置目标对象、添加通知Advice然后 getProxy。ProxyFactory factory new ProxyFactory(); factory.setTarget(new UserServiceImpl()); factory.addAdvice((MethodInterceptor) invocation - { System.out.println(before); Object result invocation.proceed(); System.out.println(after); return result; }); UserService proxy (UserService) factory.getProxy(); proxy.saveUser(张三);如果你顺着ProxyFactory.getProxy()翻源码最终会走到DefaultAopProxyFactory.createAopProxy(...)。这个地方的判断逻辑大概是这样的public AopProxy createAopProxy(AdvisedSupport config) { if (config.isOptimize() || config.isProxyTargetClass() || hasNoUserSuppliedProxyInterfaces(config)) { Class? targetClass config.getTargetClass(); if (targetClass.isInterface() || Proxy.isProxyClass(targetClass)) { return new JdkDynamicAopProxy(config); } return new ObjenesisCglibAopProxy(config); } return new JdkDynamicAopProxy(config); }翻译成人话就是如果开启了proxyTargetClass或者目标对象没有用户自定义的接口那就优先 CGLIB如果目标类型是接口那就用 JDK 动态代理。理解了这一段你读 Spring AOP 源码就有了抓手。很多人一上来就翻JdkDynamicAopProxy不知道它是怎么被创建的其实重点应该在DefaultAopProxyFactory这个分叉口它是整条代理链路的“交通枢纽”。3.2 默认配置下到底选哪种代理很多人问Spring Boot 里默认 AOP 用的到底是 JDK 动态代理还是 CGLIB如果你用的是 Spring Boot 2.x 以上并且没有改配置默认是spring.aop.proxy-target-classtrue所以默认是 CGLIB。但这里有一个很容易忽略的细节就算你设置了proxyTargetClasstrue如果目标类型本身就是一个接口Spring 依然会选择 JDK 动态代理。原因很简单——CGLIB 需要继承一个具体类它无法针对“裸接口”生成子类。所以“强制 CGLIB”不等于“任何目标都能 CGLIB”它只是告诉 Spring“如果目标是一个类优先用子类方式”而目标本身就是接口时CGLIB 也无能为力。我在实际项目里排查过一个问题某个服务类实现了接口但用了一个非常老的自研 RPC 框架框架要求 Bean 必须能被转换成目标类结果注入时一直报类型不匹配。最后发现就是 JDK 动态代理返回的是$Proxy0和具体实现类没有任何继承关系。解决方案有两个一是把注入类型改成接口二是强制proxyTargetClasstrue走 CGLIB。3.3 三个真实踩过的坑第一个坑是自调用导致注解失效。比如你在一个方法里调用了同类的方法Service public class OrderService { Transactional public void createOrder() { updateStock(); } Transactional public void updateStock() { System.out.println(扣减库存); } }这里createOrder()方法里的this.updateStock()其实是直接在当前对象上调用的而当前对象的this是原始业务对象不是 Spring 放入容器的代理对象。代理对象才会执行权限校验、事务开启这些增强逻辑原始对象不会。所以注解看起来“时不时失效”其实是你绕过了代理。解决办法是把this换成注入的代理对象或者通过AopContext.currentProxy()获取当前代理。第二个坑是 JDK 代理下注入类型写的实现类。Spring 容器里放进去的是接口代理对象它和你的实现类没有继承关系如果你在其他类里这么写Autowired private UserServiceImpl userService;容器里实际存在的是一个$Proxy0对象类型跟UserServiceImpl毫无关系启动阶段就会报错。这也是为什么 Spring 官方建议大家面向接口编程尤其是开了 AOP 的项目里。第三个坑是 final 方法与 CGLIB。CGLIB 可以对一个非 final 类生成子类但如果方法被 final 修饰子类无法覆写它这个方法上的增强逻辑就完全被跳过了。Spring 从 4.0 开始内置了CglibAopProxy不再依赖外部 cglib jar但限制依然存在。遇到 final 方法又非得增强的时候别跟框架较劲要么去掉 final要么把逻辑改成组合模式在调用方包一层。4. 动手实现一个可复用的动态代理工具4.1 通用代理工厂的接口设计纸上谈兵没意思咱们自己写一个通用的小工具。目标很简单给定一个接口、一个目标实现、一个增强回调返回一个代理对象。这个工具平时可以用来做接口级的日志记录、重试、缓存也可以当成你理解Proxy的一个模板。第一步先定义增强回调为了简单直接用InvocationHandlerpublic class ProxyFactory { SuppressWarnings(unchecked) public static T T create(ClassT interfaceType, T target, InvocationHandler handler) { return (T) Proxy.newProxyInstance( interfaceType.getClassLoader(), new Class[]{interfaceType}, handler ); } }这里注意interfaceType.getClassLoader()和target.getClass().getClassLoader()很多时候一样但最好使用接口的 ClassLoader因为代理类要实现的是这个接口类的可见性跟你用的加载器有直接关系。跨 ClassLoader 的场景虽然少一旦遇到报错会比较奇怪。4.2 带日志和重试的 InvocationHandler接着实现一个带日志和重试的 handler。重试的思路也很简单在 invoke 里面捕获异常如果异常类型是允许重试的且重试次数没超就递归调用method.invoke不然就抛出去。public class RetryableLogHandler implements InvocationHandler { private final Object target; private final int maxRetries; public RetryableLogHandler(Object target, int maxRetries) { this.target target; this.maxRetries maxRetries; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { int retries 0; while (true) { try { long start System.currentTimeMillis(); Object result method.invoke(target, args); System.out.println([ method.getName() ] 成功, 耗时 (System.currentTimeMillis() - start) ms); return result; } catch (Throwable ex) { // 业务异常可能被包装成 InvocationTargetException Throwable real ex instanceof java.lang.reflect.InvocationTargetException ? ex.getCause() : ex; if (retries maxRetries real instanceof RuntimeException) { retries; System.out.println([ method.getName() ] 失败, 第 retries 次重试); continue; } throw real; } } } }使用方式是这样的UserService proxy ProxyFactory.create(UserService.class, new UserServiceImpl(), new RetryableLogHandler(new UserServiceImpl(), 3)); proxy.saveUser(李四);这段代码能跑通但有两个细节值得注意。一是method.invoke(target, args)中的target是被代理的真实对象这一点和 CGLIB 不同二是method是从接口反射出来的UserService.saveUser方法用它 invoke 一个实际是UserServiceImpl的对象是可以的因为接口方法会被实现类覆写JVM 会正确分派到实现类的方法。4.3 proxy(Object) 转 Object 时为什么容易翻车很多同事在写通用代码时会这么干先把代理对象赋值给一个Object变量再强转回具体实现类。Object proxy ProxyFactory.create(UserService.class, new UserServiceImpl(), handler); UserServiceImpl impl (UserServiceImpl) proxy; // 一定会抛 ClassCastException原因前面也说了JDK 代理对象和实现类之间没有任何继承关系。代理类实现了UserService接口所以下面的赋值是可以的UserService service (UserService) proxy;但你要是想把它当成UserServiceImpl用就等于让“房东中介”去充当“房东本人”类型系统绝不会允许。这个坑在框架集成时特别常见尤其是你从一个容器或注册中心拿到Object然后写死强转成具体类的时候。正确的姿势是始终用接口引用。如果确实需要拿到被代理的真实目标可以在 InvocationHandler 里把 target 存储起来或者暴露一个getTarget()方法。Spring 里也有Advised接口如果你的代理对象是 Spring AOP 生成的可以尝试((Advised) proxy).getTargetSource().getTarget()但是要注意只有实现了Advised的代理才能这样操作JDK 原生的$Proxy0并不实现这个接口。5. 动态代理常见问题与排查技巧实录5.1 ClassCastException$Proxy0 不能强制转为实现类这是我把动态代理往外包给团队时见过最多的问题。报错信息通常长这样java.lang.ClassCastException: com.sun.proxy.$Proxy0 cannot be cast to com.example.service.UserServiceImpl排查顺序可以固定成三步。第一步看报错代码里是不是用了“接口引用指向实现类”的反模式第二步如果项目用了 Spring看是不是把Autowired的类型写成了实现类而容器里实际放的是 JDK 代理第三步看有没有跨 ClassLoader 加载类导致类型不一致。绝大多数情况都是第二步。如果确实必须强转成实现类那就让 Spring 强制走 CGLIB。配置项是spring.aop.proxy-target-classtrue配置后代理对象是目标类的子类自然就能强转成目标类。但这条路不是万能的目标类和方法必须非 final。5.2 方法没被拦截自调用、final、非 public“为什么我的 Transactional 没生效” 这类问题在 Spring 论坛里永远有新的帖子。大部分原因都可以归到三类方法直接由同一个对象内部调用也就是this.method()不会经过代理方法或类是 final 的CGLIB 无法覆写方法不是由 Spring 管理的 Bean 调用而是被 new 出来的对象调用。排查方法很简单打一个断点看当前对象是不是$$EnhancerBySpringCGLIB$$或$Proxy开头。如果对象是原始的UserServiceImpl1234说明容器里放进去的不是代理或者你持有的引用不是从 Spring 容器拿到的。还有一种情况是你手动new UserServiceImpl()后强制用 AOP 工具切进去那难度就大多了。5.3 InvocationTargetException 与反射调用被“包装”用method.invoke(target, args)的时候如果目标方法内部抛了异常反射调用不会直接把原始异常抛出来而是包装成java.lang.reflect.InvocationTargetException。你捕获的时候如果只看最外层会觉得很懵真正的业务异常信息被藏在了cause里。所以在 InvocationHandler 里做异常处理时必须记得把InvocationTargetException拆开看Throwable real ex instanceof InvocationTargetException ? ex.getCause() : ex;这个不是我瞎提醒是真的因为见过有人把异常堆栈打出来排查半天最后才发现是反射包装层搞的鬼。如果你在写一个框架建议在兜底异常处理前先把这层包装拆掉不然下游拿到的异常类型也不对。5.4 保存代理类字节码来看它到底长什么样JDK 动态代理生成的类是在内存里的平时看不到。但有一个开关可以把它落盘-Dsun.misc.ProxyGenerator.saveGeneratedFilestrue如果是 JDK 9 以上模块化系统可能需要额外--add-opens但早期 JDK 基本都能用。设置之后生成代理类时会写到磁盘目录是com/sun/proxy/$Proxy0.class。拿到这个 class 文件以后直接javap -c反编译就能看到里面的真实结构它继承java.lang.reflect.Proxy实现了业务接口还把接口每个方法映射到一个编号invoke 方法根据编号分发到 handlers。这个技巧对理解动态代理的“底层感”特别有帮助。很多抽象的概念一落到字节码上瞬间就变得具体。你可以自己试试代理类里的m0、m1是什么、equals和hashCode有没有被代理反编译出来一目了然。6. 动态代理的进阶玩法从框架源码到业务落地6.1 MyBatis、Feign 是怎么“凭空”实现接口的看到这里你应该已经明白Proxy.newProxyInstance最大的价值是只要定义接口不需要写实现类运行时就能有一个对象当作接口的实现来用。MyBatis 的 Mapper 就是这个思路的极致。你写一个接口public interface UserMapper { Select(select * from user where id #{id}) User selectById(Long id); }MyBatis 启动时会扫描每个 Mapper 接口用MapperProxyFactory为它创建一个代理对象。这个代理对象刚开始的时候甚至都不解析 SQL等你真正调用selectById时MapperProxy.invoke才拿到方法上的 Select 注解拼 SQL、处理参数、执行查询。这就是“接口驱动一切”的典型代表。Feign 也是同类套路。你定义一个调用远程服务的接口Feign 通过ReflectiveFeign生成代理对象每个接口方法在调用时对应一个 HTTP 请求。没有动态代理这类框架就得为每个接口生成一堆实现类想想都恐怖。读源码时只要看到接口 Proxy.newProxyInstance基本上就是这套模式。6.2 基于注解加动态代理做一个小切面纯原生 API 也可以做出类似 Spring AOP 的效果。比如自定义一个 Log 注解标记需要打印日志的接口然后用动态代理把接口包装起来Retention(RetentionPolicy.RUNTIME) Target(ElementType.METHOD) public interface Log { String value() default ; }Handler 里判断当前方法有没有标注 Logpublic class LogProxyHandler implements InvocationHandler { private final Object target; Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (method.isAnnotationPresent(Log.class)) { System.out.println(日志注解: method.getAnnotation(Log.class).value()); // 打印入参、执行时间等 } return method.invoke(target, args); } }这个迷你示例真正说明的是动态代理并不神秘它就是把“在方法调用前后做点什么”这件事从硬编码中解放出来了。Spring AOP 做的事情本质上完全相同只是把匹配条件抽象成了更为复杂的 Pointcut 表达式。6.3 什么时候不该用动态代理动态代理不是银弹。我个人经验里至少有三类场景要慎重。第一类是方法调用频率极高的语音、视频协议解析类场景。每次反射调用都有开销虽然 JVM 做了很多优化和直接方法调用还是没法比。如果性能指标很苛刻优先考虑静态代理或者直接用 MethodHandle、invokedynamic 做优化。第二类是目标类型被深度封装、层次特别复杂的场景。动态代理会把调用链路拉长排错时堆栈会多出框架的代理帧遇到不太熟悉代理原理的人他会很崩溃。第三类是完全没有扩展需求的纯内部方法。如果只是简单打印日志没必要强行上代理。我见过有人因为想“统一增强”给简单类也整了 CGLIB结果排错成本比收益还大。工具的价值在于解决问题不在于炫技。6.4 动态代理与编译期代码生成的分界线动态代理是在运行期生成字节码与其对应的是编译期代码生成比如注解处理器APT在编译阶段生成源码或字节码常见的有 Lombok、MapStruct。两者的分界线很清楚如果增强逻辑依赖运行时才能确定的某些信息比如方法参数、上下文、注解动态变化动态代理更合适如果生成代码是确定的、可预见的编译期生成会更高效、更安全还能省掉反射开销。近年来 ByteBuddy 这类库提供了更细粒度的运行时生成能力可以在方法级别做 instrumentation甚至可以重新定义已有类的方法体。它在很多 Java Agent 工具里已经取代了部分 CGLIB 的场景。但不管底层怎么变核心思想没有跳出“在运行时生成代码去拦截方法调用”这一范畴。读框架源码时你抓Proxy、Enhancer、ByteBuddy这些核心入口就不会迷路。最后聊一个我在排错时一直用的习惯看到任何和代理相关的诡异现象先确认当前对象是不是代理对象再看它属于哪一种代理。这两个信息往往比盲目看堆栈更早指向答案。动态代理这个能力说穿了就是“运行时给接口安一个临时实现”你在理解了它的边界之后用它解决业务问题、读框架源码都会顺手很多。