ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

静态代理与动态代理:JDK Proxy和CGLIB原理、坑点与选型

静态代理与动态代理:JDK Proxy和CGLIB原理、坑点与选型 静态代理和动态代理这两个词凡是写过 Java 的都听过面试也常被问到。可你要是真去翻老项目里的代码会发现很多人对代理的理解停留在“会用 Proxy.newProxyInstance”这一层能写出两段示例换个场景照样翻车。这个系列的第二篇我不打算再讲一遍“什么是代理模式”这种入门内容而是想聊聊静态代理和动态代理在真实工程里的分水岭——为什么静态代理会让项目越来越难维护JDK 动态代理到底在底层做了什么CGLIB 又是怎么绕开接口限制的这些机制背后有哪些坑以及我们选型时到底该看什么。如果你还没看过第一篇不用回头补课接下来我会用自己的视角重新梳理一遍。只是和普通教程不同这篇文章会更赶“现场”每一步都会告诉你为什么这么做踩过的坑和排查思路也会一并列出来。1. 从“会写”到“真懂”静态代理的三种硬伤1.1 一个很标准的静态代理示例先说静态代理。大多数人对它的印象是一个接口、一个目标类、一个代理类代码大概是这个样式public interface UserService { void save(String name); } public class UserServiceImpl implements UserService { Override public void save(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 save(String name) { System.out.println(开始保存用户...); target.save(name); System.out.println(保存用户结束...); } }这种写法的核心思想是“组合优于继承”代理类和目标类实现同一个接口代理类内部持有目标对象调用时在前后插入增强逻辑。接口对外暴露的是同一个类型调用方感知不到代理的存在。1.2 静态代理的三种硬伤这段代码看着干净但放进大型项目里问题很快暴露。第一种是接口膨胀每增加一个业务接口你就得手写一个对应的代理类。如果系统里有二十个接口就可能同时存在二十个代理类而且这些代理类里大多是一样的日志、事务、权限逻辑只是目标类型和方法不同。改一个公共逻辑所有代理类都得跟着动漏掉一个就会出问题。第二种是“职责固化”静态代理的增强逻辑是写死在代理类里的。今天要在 save 前面加缓存明天要在 delete 后面做审计每次都要改代理类的代码。代理类代码越多偏离“代理”这个词本身就越远最终沦为一座写满业务杂项的垃圾堆。第三种更隐蔽——静态代理对“非接口类型”几乎无计可施。如果目标类没有实现任何接口你想给它做代理静态代理这条路直接被堵死。要么给目标类硬塞一个接口要么就得在代理类里继承目标类然后重写方法。这样做的耦合度比接口代理还要高还要防止目标类方法被 final 修饰。所以我一直有个观点静态代理不是不能用而是它更适合在规模可控、接口稳定、增强点非常有限的场景下使用。一旦系统进入快速迭代期静态代理的维护成本会指数级上升。这也是动态代理真正被需要的起点。2. JDK 动态代理反射只是入门重点是“生成”2.1 三行代码里藏着一个“方法调用转发器”动态代理和静态代理相比最大的变化是不需要为每个接口手写代理类。以 JDK 动态代理为例核心就三要素一个 InvocationHandler、一个接口数组、一个类加载器。public class LogInvocationHandler implements InvocationHandler { private final Object target; public LogInvocationHandler(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println([前置通知] method.getName()); Object result method.invoke(target, args); System.out.println([后置通知] method.getName()); return result; } }使用的时候UserService target new UserServiceImpl(); UserService proxy (UserService) Proxy.newProxyInstance( target.getClass().getClassLoader(), new Class?[]{UserService.class}, new LogInvocationHandler(target) ); proxy.save(张三);从调用方视角来看proxy.save(张三) 执行后控制台先打印“前置通知”再打印“保存用户”再打印“后置通知”。这个效果和静态代理一模一样区别在于你不需要再写 UserServiceProxy 这个文件了任何接口都可以用同一个 LogInvocationHandler 来做日志增强。这里的核心原理是方法调用转发代理对象上的方法调用最终全部被转发到 InvocationHandler.invoke()。你在代理对象上调用的方法名、参数列表、目标方法反射信息都通过 Method 对象暴露给了 Handler于是 Handler 可以在调用目标方法前后做任何事。2.2 为什么 JDK 代理一定要面对接口很多人被 JDK 动态代理的第一个限制卡住目标类没有实现接口直接用 Proxy.newProxyInstance 就会报错。原因需要看 JDK 生成的代理类结构。Proxy.newProxyInstance 在运行时动态生成一个代理类这个代理类会继承 java.lang.reflect.Proxy同时实现你传入的接口。Java 是单继承的代理类已经继承了 Proxy就不可能再继承你的目标类所以它只能通过接口去暴露代理行为。如果你连接口都没有JDK 动态代理就没有“形状”去描述代理对象到底该长什么样。换句话说JDK 动态代理的本质是“面向接口的代理”它代理的是接口而不是类。调用方接收到的类型只能是接口引用而不是目标类的具体类型。这也能解释为什么你把代理对象强转成 UserServiceImpl 时会抛 ClassCastException——代理类压根不是 UserServiceImpl 的子类。2.3 反编译代理类它到底多了哪些方法如果你好奇运行时的代理类长什么样可以加一个系统属性把生成的代理类保存到磁盘-Dsun.misc.ProxyGenerator.saveGeneratedFilestrue更高版本的 JDK 也可以这样设置-Djdk.proxy.ProxyGenerator.saveGeneratedFilestrue。反编译后你会发现代理类里有几个比较特别的地方它继承了 Proxy 父类并且有一个以 InvocationHandler 为参数的构造器。它实现了我们在 newProxyInstance 里传入的所有接口方法。接口里的方法在代理类中并不是简单转发给目标对象而是把 this、Method、参数列表组装成参数调用父类 Proxy 持有的 InvocationHandler.invoke()。除接口方法之外它还重写了 equals、hashCode、toString这三个方法同样会被转发到 InvocationHandler。这个细节非常重要。很多人在 InvocationHandler 里对方法名做过滤只拦截业务方法结果发现代理对象的 equals() 和 hashCode() 也被拦截了。如果 Handler 里没有对特殊方法做区分就可能会在集合操作中出现诡异行为。2.4 newProxyInstance 的三个参数每一个都可能埋雷第一个参数是类加载器。很多人图省事直接传目标类的类加载器这本身没有错但如果接口是另一个类加载器加载的你需要保证类加载器能同时看到目标类和接口。否则运行时会出现 ClassCastException 或 NoClassDefFoundError。第二个参数是接口数组。这个数组的顺序其实无所谓但接口数量不能为空传一个空数组会直接抛 IllegalArgumentException。另外如果接口中有重复的接口或者接口不是接口而是普通类也会抛异常。第三个参数是 InvocationHandler。这个不能传 null否则代理对象一旦调用方法就会抛 NullPointerException。不过网上那个“传 null 用来生成一个空代理”的玩法我劝你不要在生产环境碰毫无意义。// 正确实例化避免直接用 Proxy.newProxyInstance 返回 Object 时的强转问题 UserService proxy (UserService) Proxy.newProxyInstance( UserService.class.getClassLoader(), new Class?[]{UserService.class}, handler );我看到不少新手在这段代码里翻车强转没问题问题是接口数组长度为零或者类加载器和接口不匹配。写代码前先想清楚这三件事能省掉一大半的代理排错时间。3. 没有接口时的解药CGLIB 的工作原理与选择3.1 CGLIB 实现“无接口代理”的最小示例JDK 动态代理面向接口但对那些没有接口的目标类就得请出 CGLIB。CGLIB 不是一个 JDK 自带的库它基于 ASM 字节码生成技术在运行时生成目标类的子类。Spring、MyBatis 等框架都把 CGLIB 集成得很深但在没有框架的场景下你也能直接依赖cglib包来用。public class UserService { public void save(String name) { System.out.println(保存用户: name); } } Enhancer enhancer new Enhancer(); enhancer.setSuperclass(UserService.class); enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) - { System.out.println([前置]); Object result proxy.invokeSuper(obj, args); System.out.println([后置]); return result; }); UserService proxy (UserService) enhancer.create(); proxy.save(张三);关键点是 MethodInterceptor 里的 proxy.invokeSuper(obj, args)。它调用的不是目标对象的方法而是生成子类中继承自父类的原始方法。CGLIB 把目标类的方法体复制进了子类由子类去执行真正的业务逻辑我们只是在这个执行过程前后插入增强。3.2 继承能解决的问题和不能解决的问题CGLIB 用继承天然就带上了继承的边界final 类无法被代理final 方法无法被覆写private 方法不会参与代理逻辑。如果目标类被 final 修饰运行时会抛出 IllegalArgumentException异常信息会直接告诉你“无法代理 final 类”。这一点很多人的第一反应是“换 JDK 动态代理”但换的前提是目标类得有接口。假如一个老项目里的类既没有接口又是 final这在设计上本身就是个定时炸弹遍历一遍关键的 service 层尽早把 final 去掉或补上接口比纠结用什么代理机制更重要。另外CGLIB 生成的代理对象是目标类的子类所以代理对象能直接赋给目标类类型。这也是很多人觉得 CGLIB 比 JDK 动态代理“更自由”的原因。但自由带来的坑是父类内部的方法调用链中如果某个方法被子类覆写并增加了增强逻辑而另一个未被代理的方法内部调用了它行为会变得很难追踪。3.3 JDK 代理和 CGLIB 如何选择我从 Spring 源码里看出的端倪Spring 在早期版本里遵循一个原则如果目标类实现了接口优先使用 JDK 动态代理如果没有接口则退回到 CGLIB。后来 Spring Boot 2.x 开始默认把proxyTargetClass设为 true也就是在很多场景下直接用 CGLIB。但这个默认值的切换在社区里没少引发讨论因为它直接改变了注入行为。我个人的理解是优先选择 JDK 动态代理不仅是因为它能生成比 CGLIB 更“轻”的代理类更因为面向接口的编程习惯对后续维护更友好。而 CGLIB 更适合那些没有接口、无法改造历史代码的场合。别看到 Spring Boot 默认 CGLIB 就觉得 CGLIB 一定更好框架的默认值是为了开箱即用不是让你放弃接口设计。这两者的差异我用一张表简单对比对比维度JDK 动态代理CGLIB代理目标接口类非 final生成方式生成实现接口的代理类生成目标类的子类底层技术Proxy InvocationHandlerASM 字节码生成对 final 限制无影响针对接口final 类/方法无法代理调用方式方法转发到 HandlerFastClass 机制直接调用典型场景Spring AOP 的接口方案第三方库无接口代理4. 动态代理的常见坑每个都是线上事故级别的教训4.1 强转失败与 instanceof 判断错误有人说“动态代理不就是返回生成的对象吗直接强转不就完了”真正动手后你会发现JDK 动态代理返回的对象只能转成接口类型不能转成目标实现类。UserService proxy (UserService) Proxy.newProxyInstance(...); UserServiceImpl impl (UserServiceImpl) proxy; // ClassCastException根因是代理类与 UserServiceImpl 没有任何继承关系。这个问题初次遇到可能会让人蒙圈觉得明明底层就是同一个 Handler、同一个目标对象转个型怎么就不行但理解了代理类是独立生成的类之后这就顺理成章了。排查思路也很直接先看代理有没有实现目标类如果没有就别做这个强转。代码里建议始终面向接口编程不要依赖具体实现类型。4.2 类加载器不一致导致 NoClassDefFoundError另一个高频事故是类加载器问题。假设接口由 A 类加载器加载目标类由 B 类加载器加载你用了 B 去加载代理类结果代理类在实现接口方法时找不到接口定义直接抛 NoClassDefFoundError。实际案例里最典型的场景是发生在 Web 容器或插件化架构中。不同模块的类加载器是隔离的你在一个模块里用另一个模块的接口做代理传错类加载器就很容易翻车。稳妥的做法是用“接口自己的类加载器”UserService proxy (UserService) Proxy.newProxyInstance( UserService.class.getClassLoader(), new Class?[]{UserService.class}, handler );这样生成代理类的加载器一定能看到 UserService 接口避免了常见的“目标类加载器看不到接口”的坑。4.3 self-invocation代理内部的“漏网之鱼”这个是 Spring AOP 用户最常见的困惑也是很多人的“代理认知分水岭”。看下面这段代码public class OrderService { Transactional public void createOrder() { ... this.updateStock(); } public void updateStock() { ... } }如果 updateStock 也被标注了 Transactional而它是在 createOrder 内部通过 this 调用的那么事务不会在 updateStock 上生效。原因很简单this 调用发生在目标对象内部没有经过代理对象。代理对象只从“外部方法入口”进入你从外部调 createOrder代理生效但 createOrder 内部调 this.updateStock这个 this 是目标对象本身代理根本不知道这回事。知道这个坑之后解决方式一般有两种一是拆开调用注入代理对象自身通过代理去调方法二是把内部方法挪到另一个 Bean 里从外部注入再调用。无论哪种都是为了让第二次调用也经过代理层。4.4 反射异常的包装与误读Handler 里调用目标方法用的是 method.invoke(target, args)。如果目标方法内部抛了异常这个异常会被包装成 InvocationTargetException 抛出来。很多人只是看异常栈看到顶部是 InvocationTargetException往往误以为是代理的问题实际上真正的业务异常躺在 cause 里。try { proxy.save(张三); } catch (InvocationTargetException e) { Throwable real e.getCause(); // 这里才是业务异常的真实信息 }在排查日志时记得打开 cause 链别盯着表面的 InvocationTargetException 发愁。这个细节在动态代理的异常链路里非常常见写日志或者接监控的时候建议直接输出 getCause。5. 拿得出手的封装一个可复用的通用代理工具类5.1 泛型与缓存的落地写法把动态代理封装成工具类最忌讳的是把业务逻辑写死到工具类里。通用工具类只解决两件事创建代理实例、缓存代理实例。增强逻辑应该由调用方以 Handler 的形式传入。我给一个自己项目里用得很顺手的简化版public class ProxyFactory { private static final MapClass?, Object CACHE new ConcurrentHashMap(); SuppressWarnings(unchecked) public static T T createProxy(ClassT interfaceType, InvocationHandler handler) { if (!interfaceType.isInterface()) { throw new IllegalArgumentException(必须传入接口类型); } return (T) Proxy.newProxyInstance( interfaceType.getClassLoader(), new Class?[]{interfaceType}, handler ); } public static T T createCachedProxy(ClassT interfaceType, InvocationHandler handler) { return (T) CACHE.computeIfAbsent(interfaceType, type - createProxy(interfaceType, handler)); } }代码里那个缓存很关键。每次 newProxyInstance 都会生成新的代理对象如果调用方反复创建会造成不必要的对象和类元数据膨胀。缓存按接口维度来保证同一接口下所有代理对象复用同一个 Handler。实际在写的时候Handler 的创建也得考虑线程安全不要把带状态的变量共享到线程不安全的 Handler 里。5.2 多 Handler 代理链的简单实现单个 Handler 负责一个维度比如日志是一层、权限是一层、事务是一层。如果每个代理只能绑定一个 Handler就需要设计链式调用。最简单的实现方式是把多个 Handler 串成一条链参考责任链模式public class CompositeInvocationHandler implements InvocationHandler { private final ListInvocationHandler handlers; private final Object target; public CompositeInvocationHandler(Object target, ListInvocationHandler handlers) { this.target target; this.handlers handlers; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { for (InvocationHandler handler : handlers) { handler.invoke(proxy, method, args); } return method.invoke(target, args); } }我这个简版看着简洁但真实场景里往往需要更精细的“前、后置”定义比如要求前置增强按顺序执行、后置增强逆序执行、异常时不继续执行后续增强。真要做得稳建议参考 AOP 拦截器链的路子来设计而不是只做一个简单的 for 循环。5.3 性能优化从反射到 MethodHandle 的取舍JDK 动态代理的核心是反射调用 Method.invoke()。JDK 在后续版本里对反射做了大量优化但反射调用的性能依然低于直接调用。如果真的处在高性能路径上比如每秒调用几万次的场景可以考虑把 Method.invoke 换成 MethodHandle。MethodHandle 在 JDK 7 引入设计目标就是提供一种轻量级、可被 JIT 优化的方法调用方式。用一个简单的例子感受一下MethodHandles.Lookup lookup MethodHandles.lookup(); MethodHandle handle lookup.findVirtual(UserService.class, save, MethodType.methodType(void.class, String.class)); handle.invokeExact(target, 张三);这段代码运行后能明显感觉到 MethodHandle 比反射更轻量。但换取性能的同时代码可读性下降、异常处理更麻烦所以我的结论是先用反射做正确性验证性能瓶颈出现后再按热点方法逐批替换成 MethodHandle。不要在项目一开始就过度优化。6. 我的选型建议与真正的实用心得6.1 静态代理并非一无是处写了两篇代理相关的内容可能有人以为我在否定静态代理。实际情况恰恰相反小规模项目里静态代理反而最可控、最好维护。尤其是你只有一两个业务接口、增强逻辑非常固定的时候手写代理类比引一整套动态代理框架更省心代码也更好读。我见过有人一上来就在项目里引入 Spring AOP只为了给两个方法加日志。结果是方法调用链路变复杂、调试变得更困难原本一个简单的日志功能被迫理解代理、切面、自动代理配置。这种复杂度完全没必要。先算一下“代理的场景有多少”再决定要不要用动态代理。6.2 动态代理的三个适用信号什么时候可以考虑动态代理我自己的判断标准有三条增强逻辑会随着业务变化频繁调整比如权限规则、埋点统计。目标类型很多且会持续增加手写代理类不可持续。需要在运行时决定“哪些方法被拦截”而不是编译期写死。这三条只要满足一条动态代理就是比静态代理更合适的选择。如果三条都不满足还是老老实实手写吧别给自己找“架构感”。6.3 一个小众但有效的调试技巧代理问题比起普通代码问题难就难在你看到运行时类型和源代码类型不一样。分享一个我常用的小技巧在 Handler.invoke 前打印这一次调用的类名、方法名、参数类型会非常直观地帮你定位是不是代理没走到目标方法。Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println(proxy class proxy.getClass()); System.out.println(method method.toGenericString()); System.out.println(args Arrays.toString(args)); ... }这个方法一度帮我查清了 self-invocation 导致的代理失效问题方法名确实出现在日志里但调用方是目标对象内部而不是代理入口。看到 proxy class 是目标对象本身而非代理类后很快就定位到了问题根源。另外给代理类生成文件的功能别一直开着否则生产环境会留下一堆额外的类文件反而增加排障难度。需要调试的时候再用定位完马上关掉。静态代理和动态代理没有那么神秘无非是运行时“插入逻辑”这一件事的两种实现路径。真正拉开差距的是你对这个插入过程的理解有多深方法的转发链路、类加载器的约束、内部调用的盲区、异常包装的层次。把这些想清楚再去碰 Spring AOP、MyBatis Mapper 代理之类的高级应用你会发现自己不再只是“会用”而是可以看清它们每一步在做什么。我在实际项目里用过很多次代理后回头再看这些基础概念总要感慨一句真要深入理解一个东西还得是动手踩坑来得快。
返回列表