资讯动态

JDK动态代理从入门到源码级拆解:原理、字节码与避坑指南

发布时间:2026/10/1 17:36:13 来源:尧图企业网站定制
如果你手头的项目里有几十个Service接口某天业务方突然要求所有对外方法调用前统一校验权限调用后统一记录日志。你脑子里是不是马上冒出一堆代码重复的惨案其实这个问题有一个很成熟的解法——动态代理。动态代理能在运行期替你生成一个代理对象在不改动原有实现类的前提下把通用逻辑集中到一个地方处理而不用挨个方法改代码。JDK动态代理是Java原生提供的一种实现方式不依赖任何第三方库。这篇文章我会从实际场景切入把JDK动态代理的用法、底层原理、字节码生成逻辑、常见坑位全部拆开讲文末还会放一组可运行的示例代码看完就能直接照着写。先说明一下本文约定“图文并茂”不一定非要贴截图我会用代码块把类结构、调用链路、反编译结果等以文字图的方式画出来比截图更有信息量也方便你直接复制到编辑器里对照分析。1. 动态代理到底在解决什么问题1.1 一个最原始的加日志需求假设你有一个很简单的用户服务接口定义如下public interface UserService { String sayHello(String name); void saveUser(String username); }实现类也老老实实写着public class UserServiceImpl implements UserService { Override public String sayHello(String name) { System.out.println(hello name); return hello name; } Override public void saveUser(String username) { System.out.println(save user: username); } }现在老板说线上出问题了所有方法调用前后都要打日志方便排查。最简单粗暴的做法就是改实现类每个方法里加两行打印Override public String sayHello(String name) { System.out.println([before] 调用 sayHello参数: name); long start System.currentTimeMillis(); String result hello name; System.out.println([after] 调用结束耗时: (System.currentTimeMillis() - start)); return result; }一个方法改起来不痛不痒但你想想如果项目里有几十个Service、几百个方法呢每个方法都这么加一遍代码量上去了和核心业务逻辑纠缠在一起后面维护的时候眼前一黑。更要命的是日志逻辑一变比如要加TraceId你又要遍历几百个方法逐个改。这个场景几乎每个做Java后端的人都遇到过。1.2 静态代理的治标不治本有人会说那我写一个代理类把业务逻辑和日志拆开不行吗可以这就是常见的静态代理。比如public class UserServiceProxy implements UserService { private final UserService target; public UserServiceProxy(UserService target) { this.target target; } Override public String sayHello(String name) { System.out.println([before] 调用 sayHello); String result target.sayHello(name); System.out.println([after] 调用结束); return result; } Override public void saveUser(String username) { System.out.println([before] 调用 saveUser); target.saveUser(username); System.out.println([after] 调用结束); } }这种做法的缺点很直观每给一个Service做增强就要手写一个代理类。而且这些代理类里的模板代码几乎一模一样只是方法签名不同。如果接口方法变了代理类也要跟着改。说白了静态代理把“每个方法改一遍”变成了“每个接口写一个代理类”量级是下来了但本质还是手工劳动。1.3 动态代理的思路转变动态代理的核心思想是代理类不在编译期写死而在运行期动态生成。你只需要写一个“处理器”把通用的逻辑放进去然后告诉JDK“我要代理哪个接口用哪个处理器”JDK就会在内存里生成一个代理类把接口所有方法都转发到处理器上。这样无论接口有多少个方法你只写一次处理器所有方法都自动被拦截和处理。这个思想就是AOP面向切面编程的雏形后来Spring AOP底层就是靠这套机制做的。所以在理解Spring AOP之前把JDK动态代理吃透非常关键。2. 一张图看明白JDK动态代理的整个结构2.1 三个参与者的职责划分要理解JDK动态代理先要分清这几个角色接口定义业务方法的契约JDK代理强行要求必须有接口。目标对象Target真正干活的实现类比如UserServiceImpl。InvocationHandler增强逻辑的容器所有代理方法的调用都会进到它的invoke方法里。ProxyJDK提供的一个核心类负责在运行期生成代理对象。$Proxy0JDK在运行期动态生成的代理类继承Proxy实现你传入的接口。注意$Proxy0是在运行期肉眼看不到的类默认存在于内存中。想看到它也简单后面我会讲怎么把生成的字节码保存到磁盘。这套结构里最核心的分工是Proxy负责“生成代理类”InvocationHandler负责“接到方法调用后干什么”目标对象负责“真正的业务逻辑”。2.2 用文字代码块画一张调用结构图为了方便观察我这里用代码块画一张结构图你把它当成图来看调用方 Application │ │ 调用 sayHello(zhangsan) ▼ $Proxy0运行期生成的代理类实现 UserService │ │ 内部持有 InvocationHandler h调用 h.invoke(...) ▼ LogHandler你写的 InvocationHandler 实现 │ │ method.invoke(target, args) 反射调用 ▼ UserServiceImpl真正的业务实现类这张图基本概括了JDK动态代理的全部流程。后面的源码级拆解本质上就是围绕这张图展开解释$Proxy0是怎么来的h.invoke为什么会被触发最终又怎么回到目标对象上。2.3 为什么这个设计优秀这个设计最聪明的一点是把“方法调用的分发”和“业务增强逻辑”解耦了。代理类只负责一件事情把接口方法的调用转交到InvocationHandler它不需要知道你的日志怎么打、权限怎么校验。而InvocationHandler可以按方法区分处理逻辑比如方法名以save开头的做写操作审计以query开头的做读操作缓存。这样就不需要为每个接口单独写代理类一份处理器通吃所有Service。不过这套机制也有它的边界代理类只能生成“接口的实现”不能生成“某个类的子类”。这就是为什么JDK动态代理天生要求“必须有接口”也解释了网络上为什么总有人拿“JDK动态代理和CGLIB的区别”来对比。3. 手写一套最小可运行的示例3.1 定义接口和目标实现示例代码我用最简单的用户服务来演示。定义接口public interface UserService { String sayHello(String name); void saveUser(String username); }实现类保持不变为了后面看效果我在方法里加一行打印表示业务真正执行了public class UserServiceImpl implements UserService { Override public String sayHello(String name) { System.out.println(执行真正的 sayHello 业务参数: name); return hello name; } Override public void saveUser(String username) { System.out.println(执行真正的 saveUser 业务参数: username); } }这里的UserServiceImpl就是前面说的目标对象target它不需要知道动态代理的存在很干净。3.2 编写InvocationHandler接下来是关键。写一个类实现InvocationHandler接口把通用增强逻辑放在invoke方法里import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; public class LogHandler implements InvocationHandler { private final Object target; public LogHandler(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println([before] 方法: method.getName()); long start System.currentTimeMillis(); // 反射调用目标对象的真实方法 Object result method.invoke(target, args); System.out.println([after] 方法: method.getName() 耗时: (System.currentTimeMillis() - start)); return result; } }invoke方法有三个参数这是理解动态代理的重中之重proxy运行期生成的代理对象本身也就是$Proxy0的实例。注意不要在invoke里使用proxy.toString()、proxy.hashCode()这类方法否则会无限递归。method当前被调用方法的Method对象。它来源于接口的方法所以可以通过method.getName()拿到方法名。args调用时传入的参数数组。没有参数时是null如果有参数则按顺序排列。这个LogHandler只依赖接口的Method不依赖具体接口类型所以它天然可以复用到任何接口上。3.3 通过Proxy.newProxyInstance生成代理对象现在写一个入口类把三者组装起来import java.lang.reflect.Proxy; public class Main { public static void main(String[] args) { // 目标对象 UserService target new UserServiceImpl(); // 处理器持有目标对象 LogHandler handler new LogHandler(target); // 生成代理对象 UserService proxy (UserService) Proxy.newProxyInstance( target.getClass().getClassLoader(), new Class?[]{UserService.class}, handler ); // 调用代理方法 proxy.sayHello(zhangsan); proxy.saveUser(lisi); } }运行结果会是[before] 方法: sayHello 执行真正的 sayHello 业务参数: zhangsan [after] 方法: sayHello耗时: 16 [before] 方法: saveUser 执行真正的 saveUser 业务参数: lisi [after] 方法: saveUser耗时: 0注意调用方拿到的proxy是一个代理对象但它实现了UserService接口所以可以赋值给UserService类型。这也是为什么接口接收非常重要。3.4 三个参数的含义和选型技巧Proxy.newProxyInstance的三个参数我逐个拆一下loader类加载器用来加载运行期生成的代理类。一般传目标对象.getClass().getClassLoader()或者接口的ClassLoader都可以。这里有个隐含要求传入的类加载器必须能够加载你传递的接口类否则会报ClassNotFoundException。interfaces要代理的接口数组。注意传的是接口不是实现类。如果是多个接口代理类会一次性实现所有接口。handler你写好的InvocationHandler实例。每次调用代理方法最终都会走进这个实例的invoke。在实际项目里我习惯在工具类中封装一段生成代理对象的公共代码把loader和interfaces从目标对象里反射拿调用方只需要传target和handler这样使用起来更干净。但这不是必须的纯手写时按上面三段式组装就够了。4. 源码级拆解代理对象到底是怎么被“造”出来的4.1 newProxyInstance的执行流程光会写代码还不够JDK动态代理的精华在于它内部做了什么。跟着源码走一遍很多疑问就都解开了。打开Proxy.newProxyInstance的源码核心流程可以简化成四步public static Object newProxyInstance(ClassLoader loader, Class?[] interfaces, InvocationHandler h) { // 1. 校验处理器不为空 Objects.requireNonNull(h); // 2. 克隆接口数组避免调用方后续修改影响内部逻辑 final Class?[] intfs interfaces.clone(); // 3. 查找或生成代理类重点 Class? cl getProxyClass0(loader, intfs); // 4. 获取构造器反射创建代理实例 final Constructor? cons cl.getConstructor(constructorParams); return cons.newInstance(new Object[]{h}); }第一步很好理解InvocationHandler是必传的。第二步克隆接口数组是为了防止外部在调用过程中篡改接口列表属于防御性编程。第三步的getProxyClass0是整个流程的核心它在内部做了缓存判断如果这个loader和接口组合之前已经生成过代理类就直接从缓存取如果没生成过才真正去生成新的代理类。这一步保证了同一个接口组合不会反复生成重复的类。第四步值得注意生成的代理类会有一个参数类型为InvocationHandler的构造器。newProxyInstance在拿到构造器之后通过反射调用cons.newInstance(new Object[]{h})把处理器实例传进去这样就完成了代理对象和处理器之间的绑定。4.2 代理类的缓存机制——按接口组合区分getProxyClass0的代码很简单private static Class? getProxyClass0(ClassLoader loader, Class?... interfaces) { if (interfaces.length 65535) { throw new IllegalArgumentException(interface limit exceeded); } return proxyClassCache.get(loader, interfaces); }这里有两个信息点上限校验接口数量不能超过65535个。这个数字来自JVM类文件结构的限制字段数量和方法数量都有类似的上限实际开发中几乎不可能触发但源码层面还是做了防御。proxyClassCache这是一个WeakCache结构key是类加载器value是ConcurrentMap二级key是接口组合。这也就是说只要类加载器和接口组合相同生成的代理类是同一个Class对象不会重复生成。我之前在一个老项目里看到有人频繁地调用Proxy.newProxyInstance担心性能问题。其实因为有缓存相同接口组合的代理类只会生成一次。真正每次调用都发生的只是构造器反射创建实例这步开销远小于类生成。所以不需要为了性能刻意去缓存代理对象除非你的创建频率实在高得离谱。4.3 字节码生成$Proxy0 是什么样真正的代理类是由ProxyGenerator.generateProxyClass生成字节码再通过defineClass0加载到JVM里的。整个生成过程比较繁琐但结果很清晰。为了搞明白内部结构我强烈建议你把生成的字节码保存下来然后反编译看一眼。在JDK 8的环境下启动时加这个参数-Dsun.misc.ProxyGenerator.saveGeneratedFilestrueJDK 9以后参数变成了-Djdk.proxy.ProxyGenerator.saveGeneratedFilestrue运行完示例代码后项目目录下会出现一个com/sun/proxy/$Proxy0.class文件不同JDK版本生成路径可能不同。用javap -p反编译你会看到一个类似这样的类public final class $Proxy0 extends Proxy implements UserService { private static Method m1; private static Method m2; private static Method m3; private static Method m4; public $Proxy0(InvocationHandler h) { super(h); } public final boolean equals(Object obj) { try { return (Boolean) h.invoke(this, m1, new Object[]{obj}); } catch (RuntimeException | Error e) { throw e; } catch (Throwable t) { throw new UndeclaredThrowableException(t); } } public final String toString() { try { return (String) h.invoke(this, m2, (Object[]) null); } catch (RuntimeException | Error e) { throw e; } catch (Throwable t) { throw new UndeclaredThrowableException(t); } } public final int hashCode() { try { return (Integer) h.invoke(this, m3, (Object[]) null); } catch (RuntimeException | Error e) { throw e; } catch (Throwable t) { throw new UndeclaredThrowableException(t); } } public final String sayHello(String name) { try { return (String) h.invoke(this, m4, new Object[]{name}); } catch (RuntimeException | Error e) { throw e; } catch (Throwable t) { throw new UndeclaredThrowableException(t); } } public final void saveUser(String username) { try { h.invoke(this, m5, new Object[]{username}); } catch (RuntimeException | Error e) { throw e; } catch (Throwable t) { throw new UndeclaredThrowableException(t); } } static { try { m1 Class.forName(java.lang.Object).getMethod(equals, new Class[]{Class.forName(java.lang.Object)}); m2 Class.forName(java.lang.Object).getMethod(toString, new Class[0]); m3 Class.forName(java.lang.Object).getMethod(hashCode, new Class[0]); m4 Class.forName(com.demo.UserService).getMethod(sayHello, new Class[]{Class.forName(java.lang.String)}); m5 Class.forName(com.demo.UserService).getMethod(saveUser, new Class[]{Class.forName(java.lang.String)}); } catch (NoSuchMethodException e) { throw new NoSuchMethodError(e.getMessage()); } } }这段反编译代码信息量极大值得逐点拆解。第一$Proxy0继承Proxy实现UserService而且是final类。这印证了前面说的“只能实现接口不能继承具体类”因为Java单继承的坑位已经被Proxy占掉了。第二代理类中不仅有你接口里的业务方法还自动生成了equals、hashCode、toString的代理逻辑。这意味着调用proxy.toString()也会走进InvocationHandler.invoke很容易引发无意识的递归这点后面细说。第三每个方法内部都调用了h.invoke(this, mX, args)这里的h就是从父类Proxy继承来的protected InvocationHandler h而m1到m5是静态代码块里通过反射初始化好的Method对象。第四异常处理逻辑很有讲究捕获了RuntimeException和Error直接抛其他受检异常则包装成UndeclaredThrowableException。因为代理类要实现接口接口方法声明时没有抛出某些受检异常代理类不能违反方法签名约束所以必须包一层。4.4 从代理方法到invoke的完整调用链现在再把这条链路完整捋一遍proxy.sayHello(zhangsan) - $Proxy0.sayHello(String name) - h.invoke(this, m4, new Object[]{zhangsan}) - LogHandler.invoke(Object proxy, Method method, Object[] args) - method.invoke(target, args) - UserServiceImpl.sayHello(String name) - 返回hello zhangsan上层的LogHandler拿到返回值后再一层层返回给调用方。你看到的效果就是调用方调的明明是代理对象的方法但真正执行业务的是目标对象而且中间可以插入任何增强逻辑。这里有一个容易绕晕的点invoke方法里的第一个参数proxy和你在main方法里持有的proxy是同一个对象。如果在一个接口方法内部再调用另一个代理方法也会再次进入invoke形成递归。比如UserServiceImpl.sayHello里调了this.saveUser()那走的是目标对象自己的引用不会进入代理但如果中间代码持有的是代理对象再调方法就会重复走增强逻辑。这个区别在排查线上问题时很关键。5. 那些容易被忽略的机制与细节5.1 为什么toString、equals、hashCode也会被拦截很多新手第一次跑通示例后会在invoke里顺手打一行日志System.out.println(proxy class: proxy.getClass());然后发现程序栈溢出或者日志疯狂刷屏一脸懵。原因就是proxy.getClass()不会进入invoke因为getClass是Object的final方法代理类不会重写它。但如果你写的是proxy.toString()那就踩坑了。代理类生成的toString方法内部调用了h.invoke(this, m2, null)而你的invoke实现里如果又调用了proxy.toString()就会再次进入invoke无限递归直到栈溢出。所以实际项目中我建议在invoke里避免直接调用代理对象的toString、equals、hashCode方法。如果只是打印信息用System.identityHashCode(proxy)或者直接打印方法名和参数即可。同时处理equals方法时要格外小心代理对象的equals也会被拦截如果invoke里的逻辑依赖equals很可能写出隐蔽的递归问题。5.2 代理对象为什么只能强转成接口运行期生成的$Proxy0继承自Proxy实现了UserService接口。如果你在代码里这么写UserServiceImpl impl (UserServiceImpl) proxy; // 运行时异常会直接报ClassCastException因为$Proxy0和UserServiceImpl在类继承关系上没有任何交集它们之间没有父子关系。正确的姿势只有UserService proxy (UserService) Proxy.newProxyInstance(...);这也是为什么使用JDK动态代理时接口设计非常讲究。如果一个类没有接口你就不能用JDK动态代理来增强它要么给这个类抽象出接口要么换用CGLIB。这里要补充一个常见误区很多人认为Spring AOP默认用JDK动态代理所以Bean必须实现接口。这说法对也不对。准确说是如果Bean实现了接口Spring默认选择JDK动态代理如果Bean没有实现接口Spring会退而使用CGLIB生成子类代理。所以严格来说有没有接口都可以被代理只是机制不同。5.3 为什么JDK动态代理要求“必须有接口”这个问题从原理层面很好回答。$Proxy0已经继承了ProxyJava又不支持多继承所以它不可能再继承某个业务实现类。想要让代理对象在被调用时展现出“和目标对象长得一样”的效果唯一的办法就是通过接口来约定方法签名。接口就是代理类和目标类之间的公共契约。代理类实现接口后接口里有什么方法代理类就必须生成对应的方法实现。调用方拿着接口引用去调用方法运行时实际执行的是代理类里的方法再转发给InvocationHandler。这个方法分发的通道就是靠接口完成的。没有接口的类JDK动态代理就束手无策了。这也是CGLIB存在的理由CGLIB不要求接口它在运行期直接生成目标类的子类通过覆盖方法来实现增强。继承和接口这两种路线各有各的边界。5.4 JDK动态代理与CGLIB的对比及选型因为热词里也有“cglib和jdk动态代理区别”这里就专门拉一张对比表来说维度JDK动态代理CGLIB实现方式运行期生成接口的实现类运行期生成目标类的子类是否要求接口必须实现接口不要求接口是否要求类可继承不关注目标类不能是final方法不能是final字节码技术JDK内置的ProxyGeneratorASM字节码操作框架性能特点每次调用走Method.invoke反射有一定开销方法调用不走JDK反射性能相对更优依赖纯JDK需要引入第三方库Spring内置了它典型应用Spring AOP默认有接口时Spring AOP无接口时使用MyBatis等框架也有使用实际选型时我给的建议是有接口优先用JDK动态代理不要为了“炫技”非要去上CGLIB。JDK动态代理是Java标准库的一部分没有额外依赖排查问题时只要懂JDK源码就能看明白。没有接口的类再用CGLIB比如第三方库的类、老旧系统的类这种情况躲不开。另外Spring Boot 2.x之后Spring AOP默认虽然还是看接口决定但整体上Spring对CGLIB的依赖越来越重尤其在使用Configuration类代理时经常能看到CGLIB的身影。做底层框架可以深入研究这两个机制的差异业务开发阶段掌握到这里基本够用。6. 常见问题与排查实录6.1 常见异常一览表动态代理相关的问题在项目里出现频率挺高。我把平时排查时经常遇到的异常整理成一个速查表异常现象直接原因解决办法ClassCastException: $Proxy0 cannot be cast to UserServiceImpl代理对象和实现类没有继承关系用接口类型接收代理对象IllegalArgumentException: xxx is not an interfaceProxy.newProxyInstance第二个参数传了实现类传入接口ClassUndeclaredThrowableException目标方法抛出了受检异常但接口方法签名没有声明在invoke里捕获受检异常包装成运行时异常抛出StackOverflowErrorinvoke内部调用了代理对象的toString等方法无限递归避免调用proxy的方法改用日志框架直接打字符串InvocationTargetException反射调用目标方法时目标方法内部自己抛了异常查看e.getCause()拿到真实异常NullPointerExceptionProxy.newProxyInstance传入的handler为null检查handler实例化逻辑6.2 一组真实排查案例有一次我在一个微服务里给远程调用Client加了动态代理做链路追踪结果发现调用报UndeclaredThrowableException。查了半天问题根源是接口方法声明了throws IOException而invoke里直接向上抛了IOException代理类的方法签名里没有声明这个受检异常最终被包装成了UndeclaredThrowableException。解决方式很简单在invoke里对异常统一处理转成运行时异常再抛出或者在方法签名上不做过度声明。还有一次同事在InvocationHandler里为了排查问题加了一行log.info(proxy proxy)线上瞬间有一批请求超时。我看代码后直接让他去掉。原因就是前面反复提到的proxy.toString()会被代理拦截递归进入invoke再执行toString无限循环。这个坑特别隐蔽因为它不会在你本机跑简单示例时报错只有特定调用路径反复触发时才会暴露。另一个比较多的场景是代理类加载器选错。比如在Tomcat这种多ClassLoader环境下如果传入的loader加载不了接口会出现ClassNotFoundException。处理办法是统一用接口的ClassLoader来加载也就是UserService.class.getClassLoader()不要随手拿target.getClass().getClassLoader()。6.3 我踩过的几个坑最后分享几个实战中沉淀下来的心得。第一接口设计要克制。JDK动态代理会为接口里的每一个方法生成代理逻辑意味着接口方法越多代理类越臃肿invoke里做方法分发时的分支判断也越复杂。接口尽量遵循单一职责一个接口别塞几十个方法。第二InvocationHandler尽量做成无状态或弱状态。如果一个Handler里保存了会变的上下文多个线程同时调用代理方法时容易出现串数据的问题。因为它本身是共享的不是每个线程一份。如果确实需要上下文用ThreadLocal或者把上下文放进方法参数里而不要放在Handler的字段中。第三在invoke里判断方法时优先用method.getName()和参数类型结合不要只比对方法名。因为同名不同参的方法在接口里是允许存在的比如load(String id)和load(String id, String type)只比名字容易误判。第四如果项目里要对生成的代理类做性能压测建议把每次反射调用的开销纳入考量。JDK动态代理在调用目标方法时走的是method.invoke相比直接调用确实有一定开销。在做高频调用路径时可以尝试缓存Method对象、拆分支判断或者评估是否需要换成CGLIB。最后再说一个小技巧排查动态代理问题时先把生成的代理类dump出来看。JDK 8加启动参数-Dsun.misc.ProxyGenerator.saveGeneratedFilestrueJDK 9以后改成-Djdk.proxy.ProxyGenerator.saveGeneratedFilestrue跑一次就能在磁盘上看到$Proxy0.class配合javap反编译所有调用细节一目了然。这个做法我在定位线上问题时用过很多次比闷头看源码要直观得多。动态代理这套机制往浅了说是一个工具类往深了说是Java反射和字节码生成的结合体。把它的原理吃透之后再去接触Spring AOP、MyBatis Mapper代理、Feign Client等框架时你会发现自己能理解的东西突然就多了一层。我是建议每个Java开发者都亲自跑一遍上面的示例再顺藤摸瓜翻一下Proxy和ProxyGenerator的源码花不了多少时间但收获会很大。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑